Files
platform/src/servers/api/dev-proxy/html-rewrite.test.ts
T
pastilhasandClaude Opus 5 d771736a0d the preview panel follows the app's navigation
The panel knew one URL: the one you typed. Navigate three pages into an app and the
header still showed the front door, "open in a new tab" still went there, and a
reload — which fires by itself whenever a dev server restarts — threw away where you
were and started again at `/`.

Now the app reports where it is, and a reload composes it back.

── How the panel finds out ──

The frame is same-origin for a proxied preview, so `contentWindow.location` is
readable. It is PUSHED rather than read, because a dev server is an SPA: a route
change is `history.pushState`, which fires no event and never reloads the document.
Polling would have to be constant and would still lag. The script the proxy already
injects now patches `pushState`/`replaceState`, listens for `popstate`/`hashchange`,
and posts the path — with the proxy prefix stripped, since that is plumbing and the
panel should not have to know about it to show a URL.

Only proxied previews can do this. A cross-origin frame reports nothing, and grafting
a remembered path onto someone else's host would be inventing a URL, so `targetUrl`
composes only when there is a port.

── Two fields, and the loop that forced them ──

`url` is what you pointed the panel at; `current` is where it has got to. The obvious
design is one field — a browser has one address bar — and it feeds itself: the stored
value IS the iframe's `src`, so recording a route change hands the frame a new `src`
for the page it is already showing, which browsers treat as a navigation. Every click
would cost a reload.

Belt and braces, because one of the two would be enough only until someone changed
the other: the fields are separate, AND `PreviewFrame` captures its `src` at mount
and ignores later changes. Remount-by-key is still how a reload happens.

`ev.source` identifies which frame is talking. `message` is a window-level event and
two same-origin previews hear each other; comparing the posting window to this
iframe's `contentWindow` is exact, where matching on URL would break the moment two
panels pointed at the same port.

Writes are debounced 800ms. `useDashboardState.setValue` PATCHes the server on every
call and an SPA emits a route change per click; the value is only read at mount, so
being a second behind costs nothing.

Also: the header shows `localhost:3001/settings` rather than
`api/dev-proxy/3001/settings`, and editing starts from that — one is a thing you can
retype, the other is not.

── Tests ──

The reporter is RUN, not string-matched: `new Function` against a fake window, which
also means a syntax error in the injected script fails the suite rather than silently
breaking every proxied page. Covers prefix stripping, the app root reporting `/`
rather than empty, query and hash surviving, the report on load, pushState, and
popstate. Plus the composition helpers, including that an ordinary URL is never
composed with a path.

── One stale comment corrected while in there ──

The file claimed `allow-same-origin` "is present, and has to be". There is no
`sandbox` attribute at all, so nothing was present — and a sandbox with
`allow-same-origin` plus `allow-scripts` is worth roughly nothing anyway, since the
frame can remove its own sandbox attribute. Now says that.

Not verified against a live dev server through the real proxy — that needs the server
restarted with this code. tsgo clean, 934 pass / 7 fail unchanged, frontend builds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 18:39:38 +00:00

178 lines
6.9 KiB
TypeScript

import { describe, expect, test } from 'bun:test';
import { proxyOverrideScript, rewriteAssetPaths, rewriteHtml } from './html-rewrite';
const BASE = '/api/dev-proxy/3001';
const attrs = (html: string) => [...html.matchAll(/(?:src|href)="([^"]+)"/g)].map((m) => m[1]!);
// What a browser would resolve the rewritten path to. The bug this file exists for is invisible without
// this step: the rewrite looked right and the browser threw the prefix away.
const resolved = (path: string) => new URL(path, 'https://platform.officer.dev/').pathname;
describe('rewriteHtml', () => {
test('a plain root-absolute asset gets the prefix', () => {
expect(attrs(rewriteHtml('<link href="/style.css">', BASE))).toEqual(['/api/dev-proxy/3001/style.css']);
});
// The blank preview. Bun's dev server emits `href="/../../../chunk-qmw09gd9.css"` — root-absolute AND
// climbing. Prefixed naively the browser normalises it straight back to the platform root and 404s.
test('`..` segments are collapsed BEFORE the prefix, not handed to the browser', () => {
const out = attrs(rewriteHtml('<link href="/../../../chunk-qmw09gd9.css">', BASE));
expect(out).toEqual(['/api/dev-proxy/3001/chunk-qmw09gd9.css']);
});
test('and the result survives the browser resolving it — the part that actually failed', () => {
const [href] = attrs(rewriteHtml('<script src="/../../../chunk-bmp7wy3m.js">', BASE));
expect(resolved(href!)).toBe('/api/dev-proxy/3001/chunk-bmp7wy3m.js');
});
test('the naive version would have escaped, which is why this is asserted at all', () => {
// Not testing our code — testing the claim that made the fix necessary.
expect(resolved(`${BASE}/../../../chunk-x.css`)).toBe('/chunk-x.css');
});
test('single quotes are handled too', () => {
expect(rewriteHtml("<img src='/a/../b.png'>", BASE)).toContain("src='/api/dev-proxy/3001/b.png'");
});
test('a protocol-relative URL is left alone — it names another host', () => {
expect(attrs(rewriteHtml('<script src="//cdn.example.com/x.js">', BASE))).toEqual(['//cdn.example.com/x.js']);
});
test('an absolute URL is left alone', () => {
expect(attrs(rewriteHtml('<img src="https://example.com/x.png">', BASE))).toEqual(['https://example.com/x.png']);
});
test('a relative path is left alone — it already resolves under the prefix', () => {
expect(attrs(rewriteHtml('<img src="./logo.png">', BASE))).toEqual(['./logo.png']);
});
test('css url() gets the same treatment', () => {
expect(rewriteHtml('<style>a{background:url(/../img/x.png)}</style>', BASE)).toContain(
'url(/api/dev-proxy/3001/img/x.png)',
);
});
test('the interception script is injected into head', () => {
const out = rewriteHtml('<html><head><title>x</title></head></html>', BASE);
expect(out).toContain('window.fetch=function');
expect(out.indexOf('<script>')).toBeLessThan(out.indexOf('<title>'));
});
test('a query string and hash survive normalisation', () => {
expect(attrs(rewriteHtml('<script src="/../x.js?v=2#frag">', BASE))).toEqual(['/api/dev-proxy/3001/x.js?v=2#frag']);
});
});
describe('rewriteAssetPaths', () => {
test("Bun's chunk imports are prefixed", () => {
expect(rewriteAssetPaths('import "/_bun/client/index.js"', BASE)).toBe(
'import "/api/dev-proxy/3001/_bun/client/index.js"',
);
});
test('nothing else in the file is touched', () => {
const js = 'const re = /^\\/api\\/users$/; fetch("/api/users");';
expect(rewriteAssetPaths(js, BASE)).toBe(js);
});
});
// ── The location reporter ──
//
// The injected script tells the panel where the app has navigated to, so a reload can come back to the same
// page. Asserting on the script's TEXT would prove only that a string contains a substring, so it is run
// here against a fake window — which is what catches the part that can actually be wrong: stripping the
// proxy prefix back off to recover the app's own path.
type Posted = { path: string };
const runReporter = (base: string, startAt: string) => {
const origin = 'https://platform.officer.dev';
const body = proxyOverrideScript(base)
.replace(/^<script>/, '')
.replace(/<\/script>$/, '');
const posted: Posted[] = [];
const listeners: Record<string, (() => void)[]> = {};
let url = new URL(startAt, origin);
const location = {
get origin() {
return origin;
},
get pathname() {
return url.pathname;
},
get search() {
return url.search;
},
get hash() {
return url.hash;
},
get href() {
return url.href;
},
};
const history = { pushState: () => {}, replaceState: () => {} };
const parent = { postMessage: (msg: Posted) => posted.push(msg) };
const addEventListener = (name: string, fn: () => void) => {
(listeners[name] ??= []).push(fn);
};
const win = { fetch: () => {}, WebSocket: class {} };
const xhr = { prototype: { open: () => {} } };
// eslint-disable-next-line no-new-func
new Function('window', 'location', 'history', 'parent', 'addEventListener', 'XMLHttpRequest', body)(
win,
location,
history,
parent,
addEventListener,
xhr,
);
return {
posted,
// What an SPA does on a route change: change the URL, then call the patched pushState.
navigate: (to: string) => {
url = new URL(to, origin);
history.pushState(); // the PATCHED one: the script replaced it on the object above
},
fire: (event: string) => listeners[event]?.forEach((fn) => fn()),
};
};
describe('the injected location reporter', () => {
test('reports the app path with the proxy prefix stripped off', () => {
const { posted } = runReporter(BASE, '/api/dev-proxy/3001/settings/profile');
expect(posted.at(-1)?.path).toBe('/settings/profile');
});
test('the app root reports `/`, not an empty string', () => {
expect(runReporter(BASE, '/api/dev-proxy/3001/').posted.at(-1)?.path).toBe('/');
// No trailing slash at all — the shape a bare `localhost:3001` produces.
expect(runReporter(BASE, '/api/dev-proxy/3001').posted.at(-1)?.path).toBe('/');
});
test('query and hash are carried, since a route can live in either', () => {
const { posted } = runReporter(BASE, '/api/dev-proxy/3001/search?q=bun#hit');
expect(posted.at(-1)?.path).toBe('/search?q=bun#hit');
});
test('it reports once on load, without waiting for a navigation', () => {
expect(runReporter(BASE, '/api/dev-proxy/3001/x').posted).toHaveLength(1);
});
test('a pushState route change is reported — the case no browser event covers', () => {
const r = runReporter(BASE, '/api/dev-proxy/3001/');
r.navigate('/api/dev-proxy/3001/deep/page');
expect(r.posted.at(-1)?.path).toBe('/deep/page');
expect(r.posted).toHaveLength(2);
});
test('the back button reports too', () => {
const r = runReporter(BASE, '/api/dev-proxy/3001/a');
r.fire('popstate');
expect(r.posted).toHaveLength(2);
});
});