Two bugs, one line apart, both silent.
The `url(/…)` rule only matched an UNQUOTED target, so `url("/fonts/x.woff2")` — the form every
generator emits — went through untouched. And it only ran on HTML, so a linked stylesheet got the
narrow `/_bun/` rule and nothing else.
Together that meant a site's own @font-face rules kept pointing at `/fonts/…`, which inside the frame
is the platform's root. Every face 404'd and the preview rendered in a system fallback: nothing looked
broken, it just looked like a different site than the one being previewed.
JavaScript keeps the narrow rule — an absolute path in JS may not be a URL. In CSS it always is.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
254 lines
10 KiB
TypeScript
254 lines
10 KiB
TypeScript
import { describe, expect, test } from 'bun:test';
|
|
import { proxyOverrideScript, rewriteAssetPaths, rewriteCss, 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('every candidate in a srcset is prefixed, descriptors intact', () => {
|
|
expect(rewriteHtml('<img srcset="/a.webp 96w, /b.webp 320w">', BASE)).toContain(
|
|
`srcset="${BASE}/a.webp 96w, ${BASE}/b.webp 320w"`,
|
|
);
|
|
});
|
|
|
|
/*
|
|
The failure this was found by. An <img> with a srcset also has a src, and the src WAS prefixed — but
|
|
a browser given a valid srcset picks from it and never reads src. So the logo was missing while the
|
|
markup looked right.
|
|
*/
|
|
test('a srcset is chosen over src by the browser, so prefixing src alone is not enough', () => {
|
|
const out = rewriteHtml('<img src="/logo.webp" srcset="/logo.webp 96w">', BASE);
|
|
expect(out).toContain(`src="${BASE}/logo.webp"`);
|
|
expect(out).toContain(`srcset="${BASE}/logo.webp 96w"`);
|
|
});
|
|
|
|
test('a candidate with no descriptor is prefixed too', () => {
|
|
expect(rewriteHtml('<img srcset="/only.webp">', BASE)).toContain(`srcset="${BASE}/only.webp"`);
|
|
});
|
|
|
|
test('imagesrcset on a preload link is prefixed — the one that is easy to miss', () => {
|
|
expect(rewriteHtml('<link rel="preload" as="image" imagesrcset="/hero.webp 1280w">', BASE)).toContain(
|
|
`imagesrcset="${BASE}/hero.webp 1280w"`,
|
|
);
|
|
});
|
|
|
|
test('a protocol-relative candidate is left alone, like everywhere else here', () => {
|
|
expect(rewriteHtml('<img srcset="//cdn.example.com/a.webp 96w, /b.webp 320w">', BASE)).toContain(
|
|
`srcset="//cdn.example.com/a.webp 96w, ${BASE}/b.webp 320w"`,
|
|
);
|
|
});
|
|
|
|
test('`..` is collapsed in a srcset candidate as well', () => {
|
|
expect(rewriteHtml('<img srcset="/../../x.webp 96w">', BASE)).toContain(`srcset="${BASE}/x.webp 96w"`);
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|
|
|
|
describe('stylesheets', () => {
|
|
test('a quoted url is prefixed — the form every generator emits', () => {
|
|
expect(rewriteCss('@font-face{src:url("/fonts/Averta.woff2") format("woff2")}', BASE)).toContain(
|
|
`url("${BASE}/fonts/Averta.woff2")`,
|
|
);
|
|
});
|
|
|
|
test('single quotes and bare urls too', () => {
|
|
expect(rewriteCss("a{background:url('/bg.png')}", BASE)).toContain(`url('${BASE}/bg.png')`);
|
|
expect(rewriteCss('a{background:url(/bg.png)}', BASE)).toContain(`url(${BASE}/bg.png)`);
|
|
});
|
|
|
|
test('the quote style is preserved, not normalised', () => {
|
|
expect(rewriteCss('a{background:url("/bg.png")}', BASE)).not.toContain("url('");
|
|
});
|
|
|
|
test('a url that is already absolute is left alone', () => {
|
|
const external = 'a{background:url("https://cdn.example.com/bg.png")}';
|
|
expect(rewriteCss(external, BASE)).toBe(external);
|
|
const protocolRelative = 'a{background:url("//cdn.example.com/bg.png")}';
|
|
expect(rewriteCss(protocolRelative, BASE)).toBe(protocolRelative);
|
|
});
|
|
|
|
test('a relative url is left alone — it already resolves against the stylesheet', () => {
|
|
const relative = 'a{background:url("./bg.png")}';
|
|
expect(rewriteCss(relative, BASE)).toBe(relative);
|
|
});
|
|
|
|
test('a stylesheet still gets the /_bun/ rule', () => {
|
|
expect(rewriteCss('@import "/_bun/x.css";', BASE)).toContain(`${BASE}/_bun/x.css`);
|
|
});
|
|
|
|
test('inline CSS in HTML gets the same treatment, quotes included', () => {
|
|
expect(rewriteHtml('<div style=\'background:url("/a.png")\'></div>', BASE)).toContain(
|
|
`url("${BASE}/a.png")`,
|
|
);
|
|
});
|
|
});
|