chore: version packages

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-07-29 09:01:18 +02:00
parent 77bcbc9b94
commit a406aef2fa
16 changed files with 732 additions and 58 deletions
+157
View File
@@ -0,0 +1,157 @@
/**
* @fileoverview Frontend test for the "Web / URL" rows in the Run dropdown
* (webview-tabs.js).
*
* A saved URL used to render as a single open-button, so the ONLY way to remove one
* was to open it as a tab and go through the tab's gear, which is a dead end for a
* URL you no longer want open. These pin the per-row edit/delete affordance and the
* delete path behind it, because a UI affordance is exactly the kind of thing a
* later render refactor drops silently.
*
* Builds a JSDOM window in-test under the default node env, same shape as
* test/admin-ui.test.ts. Do NOT declare a per-file jsdom environment: it
* externalizes node:fs under vite and the readFileSync calls below stop working.
* ⚠ Do not name that directive in a comment either, vitest matches the string
* anywhere in the file.
*/
import { describe, it, expect, vi } from 'vitest';
import { readFileSync } from 'node:fs';
import { JSDOM } from 'jsdom';
const CONSTANTS = readFileSync(new URL('../src/web/public/constants.js', import.meta.url), 'utf-8');
const WEBVIEW_TABS = readFileSync(new URL('../src/web/public/webview-tabs.js', import.meta.url), 'utf-8');
interface AppLike {
webviews: Map<string, { id: string; name: string; url: string; icon?: string }>;
webviewOrder: string[];
activeWebviewId: string | null;
renderWebviewMenuItems(): void;
renderSessionTabs(): void;
deleteWebviewById(id: string): Promise<void>;
_confirmAndDeleteWebview(id: string): Promise<boolean>;
_removeWebviewTab(id: string): void;
_apiDelete(path: string): Promise<{ ok: boolean } | null>;
showToast?: (msg: string, kind: string) => void;
showWebviewModal(id?: string): void;
}
function boot(deleteOk = true) {
const dom = new JSDOM(
`<!doctype html><body>
<div class="run-mode-menu active" id="runModeMenu">
<div class="run-mode-webviews" id="runModeWebviews"></div>
</div>
<div id="sessionTabs"></div>
<div id="webviewLayer"></div>
</body>`,
{ url: 'http://localhost/', runScripts: 'outside-only' }
);
const win = dom.window as unknown as Window &
typeof globalThis & { app: AppLike; CodemanApp: new () => AppLike; confirm: () => boolean };
// webview-tabs.js is a prototype mixin, so it needs the class it extends plus the
// escapeHtml global from constants.js. Everything else it touches is stubbed.
// One eval, not three: lexical declarations in a global eval do not survive into
// the next one, and the class must be a window property for the same reason.
(win as unknown as { eval: (s: string) => void }).eval(
['window.CodemanApp = class CodemanApp {};', CONSTANTS, WEBVIEW_TABS].join('\n')
);
const deleted: string[] = [];
const app = new win.CodemanApp();
app.webviews = new Map([
['id-a', { id: 'id-a', name: 'Bio Dashboard', url: 'https://box.ts.net:4000', icon: '📈' }],
['id-b', { id: 'id-b', name: 'Grafana', url: 'http://127.0.0.1:3000/d/x' }],
]);
app.webviewOrder = [];
app.activeWebviewId = null;
app.renderSessionTabs = () => {};
app._apiDelete = async (path: string) => {
deleted.push(path);
return deleteOk ? { ok: true } : { ok: false };
};
win.app = app;
win.confirm = () => true;
app.renderWebviewMenuItems();
return { dom, win, app, deleted };
}
const rows = (win: Window) => win.document.querySelectorAll('#runModeWebviews .run-mode-row--web');
describe('Run dropdown Web/URL rows', () => {
it('gives every saved URL an open, edit and delete control', () => {
const { win } = boot();
expect(rows(win)).toHaveLength(2);
expect(win.document.querySelectorAll('#runModeWebviews .run-mode-option--web')).toHaveLength(2);
expect(win.document.querySelectorAll('#runModeWebviews .run-mode-webview-edit')).toHaveLength(2);
expect(win.document.querySelectorAll('#runModeWebviews .run-mode-webview-delete')).toHaveLength(2);
});
it('escapes the name and url rather than interpolating them raw', () => {
const { win, app } = boot();
const name = '<img src=x onerror=alert(1)>';
const url = 'https://h/"onmouseover="x';
app.webviews.set('id-x', { id: 'id-x', name, url });
app.renderWebviewMenuItems();
// Assert on the DOM, not on innerHTML: attribute serialization does not
// re-escape `<`, so a string check reads as a breakout when there is none.
expect(win.document.querySelectorAll('#runModeWebviews img')).toHaveLength(0);
const row = rows(win)[2];
expect(row.querySelector('.run-mode-option--web')!.textContent).toContain(name);
expect(row.querySelector('.run-mode-option--web')!.getAttribute('title')).toBe(url);
expect(row.querySelector('.run-mode-webview-delete')!.getAttribute('aria-label')).toBe(`Delete ${name}`);
});
it('stops the delete click from also opening the dashboard', () => {
const { win, app } = boot();
let opened = 0;
(app as unknown as { openWebviewFromMenu: () => void }).openWebviewFromMenu = () => {
opened++;
};
const del = win.document.querySelector<HTMLElement>('#runModeWebviews .run-mode-webview-delete')!;
expect(del.getAttribute('onclick')).toContain('event.stopPropagation()');
del.click();
expect(opened).toBe(0);
});
it('deletes server-side and drops the row, leaving the menu open', async () => {
const { win, app, deleted } = boot();
app._removeWebviewTab = () => {};
await app.deleteWebviewById('id-a');
expect(deleted).toEqual(['/api/webviews/id-a']);
expect(app.webviews.has('id-a')).toBe(false);
expect(rows(win)).toHaveLength(1);
// Deleting one of several URLs should leave you looking at the rest of the list.
expect(win.document.getElementById('runModeMenu')!.classList.contains('active')).toBe(true);
});
it('does nothing when the confirm is declined', async () => {
const { win, app, deleted } = boot();
win.confirm = () => false;
await app.deleteWebviewById('id-a');
expect(deleted).toEqual([]);
expect(app.webviews.has('id-a')).toBe(true);
expect(rows(win)).toHaveLength(2);
});
it('keeps the row and warns when the server refuses the delete', async () => {
const { win, app } = boot(false);
const toast = vi.fn();
app.showToast = toast;
app._removeWebviewTab = () => {};
await app.deleteWebviewById('id-a');
expect(toast).toHaveBeenCalledWith('Could not delete URL', 'error');
expect(app.webviews.has('id-a')).toBe(true);
expect(rows(win)).toHaveLength(2);
});
it('still renders the empty state when nothing is saved', () => {
const { win, app } = boot();
app.webviews.clear();
app.renderWebviewMenuItems();
expect(rows(win)).toHaveLength(0);
expect(win.document.querySelector('.run-mode-empty')).toBeTruthy();
});
});
+124
View File
@@ -6,6 +6,7 @@
*/
import { describe, it, expect } from 'vitest';
import { JSDOM } from 'jsdom';
import {
buildDownstreamResponseHeaders,
buildProxyCorsHeaders,
@@ -474,6 +475,129 @@ describe('runtimeUrlShim', () => {
});
});
/**
* The DOM half of the shim, run in a real jsdom document rather than the fake
* window above, because these patches ARE DOM behavior: what matters is the URL the
* browser would end up requesting after `innerHTML = ...`, not whether some
* function got wrapped.
*
* The bug being pinned: a dashboard rendering `<img src="/api/hero?slug=x">` from
* page script escapes `<base>` (which never applies to root-absolute URLs) and
* escapes `rewriteHtml()` (which only sees the initial document), so every image
* 404s on Codeman's own root while the dashboard's fetch-driven data loads fine.
*
* Node environment on purpose, like test/markdown-sanitizer.test.ts: a per-file
* `@vitest-environment jsdom` externalizes node builtins under vite.
*/
describe('runtimeUrlShim DOM sinks', () => {
const body = runtimeUrlShim(PREFIX)
.replace(/^<script>/, '')
.replace(/<\/script>$/, '');
function newDom() {
const dom = new JSDOM('<!doctype html><html><head></head><body><div id="box"></div></body></html>', {
url: `https://codeman.local${PREFIX}page`,
runScripts: 'outside-only',
});
dom.window.eval(body);
return dom;
}
/** src of the first <img> in #box, as the attribute the browser would fetch. */
function imgSrc(dom: JSDOM): string | null {
return dom.window.document.querySelector('#box img')!.getAttribute('src');
}
it('rewrites a root-absolute img src injected via innerHTML', () => {
const dom = newDom();
dom.window.document.getElementById('box')!.innerHTML =
'<img class="thumb" loading="lazy" src="/api/hero?slug=x" alt="">';
expect(imgSrc(dom)).toBe(`${PREFIX}api/hero?slug=x`);
});
it('rewrites single-quoted markup and insertAdjacentHTML too', () => {
const dom = newDom();
dom.window.document.getElementById('box')!.insertAdjacentHTML('beforeend', "<img src='/api/logo'>");
expect(imgSrc(dom)).toBe(`${PREFIX}api/logo`);
});
it('rewrites the img.src property setter', () => {
const dom = newDom();
const img = new dom.window.Image();
img.src = '/api/slide?owner=o&n=01';
expect(img.getAttribute('src')).toBe(`${PREFIX}api/slide?owner=o&n=01`);
});
it('rewrites setAttribute and media src/poster', () => {
const dom = newDom();
const video = dom.window.document.createElement('video');
video.setAttribute('src', '/api/video?owner=o');
video.poster = '/thumb.png';
expect(video.getAttribute('src')).toBe(`${PREFIX}api/video?owner=o`);
expect(video.getAttribute('poster')).toBe(`${PREFIX}thumb.png`);
});
it('rewrites every candidate in a srcset, leaving cross-origin ones alone', () => {
const dom = newDom();
const img = dom.window.document.createElement('img');
img.setAttribute('srcset', '/a.png 1x, /b.png 2x, https://cdn.example/c.png 3x');
expect(img.getAttribute('srcset')).toBe(`${PREFIX}a.png 1x, ${PREFIX}b.png 2x, https://cdn.example/c.png 3x`);
});
it('leaves relative, cross-origin, hash, data: and already-proxied URLs untouched', () => {
const dom = newDom();
const box = dom.window.document.getElementById('box')!;
box.innerHTML = [
'<img id="rel" src="api/rel.png">',
'<img id="cross" src="https://cdn.example/z.png">',
'<img id="data" src="data:image/gif;base64,AAAA">',
`<img id="done" src="${PREFIX}api/hero">`,
'<a id="hash" href="#top">t</a>',
].join('');
const at = (id: string, attr: string) => dom.window.document.getElementById(id)!.getAttribute(attr);
expect(at('rel', 'src')).toBe('api/rel.png');
expect(at('cross', 'src')).toBe('https://cdn.example/z.png');
expect(at('data', 'src')).toBe('data:image/gif;base64,AAAA');
expect(at('done', 'src')).toBe(`${PREFIX}api/hero`);
expect(at('hash', 'href')).toBe('#top');
});
it('catches a node built through an UNPATCHED sink via the MutationObserver net', async () => {
const dom = newDom();
const { document } = dom.window;
// createContextualFragment parses markup without going through innerHTML or
// setAttribute, so only the observer can fix this one.
const frag = document.createRange().createContextualFragment('<img id="net" src="/api/net.png">');
expect(frag.querySelector('img')!.getAttribute('src')).toBe('/api/net.png');
document.getElementById('box')!.appendChild(frag);
await new Promise((resolve) => setTimeout(resolve, 10));
expect(document.getElementById('net')!.getAttribute('src')).toBe(`${PREFIX}api/net.png`);
});
it('does not double-prefix when a page re-injects its own markup', () => {
const dom = newDom();
const box = dom.window.document.getElementById('box')!;
box.innerHTML = '<img src="/api/hero">';
const roundTrip = box.innerHTML;
box.innerHTML = roundTrip;
expect(imgSrc(dom)).toBe(`${PREFIX}api/hero`);
});
it('leaves an empty src empty, the "no image for this row" case', () => {
const dom = newDom();
dom.window.document.getElementById('box')!.innerHTML = '<img class="thumb" src="" alt="">';
expect(imgSrc(dom)).toBe('');
});
it('is idempotent when a value passes through two layers', () => {
const dom = newDom();
const img = dom.window.document.createElement('img');
img.src = '/api/hero';
img.setAttribute('src', img.getAttribute('src')!);
expect(img.getAttribute('src')).toBe(`${PREFIX}api/hero`);
});
});
describe('misc helpers', () => {
it('identifies HTML content types, parameters included', () => {
expect(isHtmlContentType('text/html; charset=utf-8')).toBe(true);