mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-20 09:09:35 +02:00
test(file-preview): pin the HTML page view, its fallback and the Page pill
The HTML branch of openFilePreview(), the Page pill and the fall-through to the source view on a refused mint had no test. The jsdom harness from file-preview-markdown.test.ts now drives them with a stubbed fetch: - a successful mint renders an iframe at the minted URL whose sandbox has allow-scripts and never allow-same-origin, with the pill pressed; - a refused mint shows the source view with its Edit button and hides the pill; - the pill flips page and source and persists codeman:filePreviewHtmlRendered, and with the pref off the preview opens as source without a mint request. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
8f62faf130
commit
5a13073657
1 file changed
+177
-1
@@ -7,6 +7,15 @@
|
|||||||
* 1. `rendersAsPage()` (constants.js) and the server's HTML_VIEW_EXTENSIONS
|
* 1. `rendersAsPage()` (constants.js) and the server's HTML_VIEW_EXTENSIONS
|
||||||
* name the same extensions, so the terminal, the preview and the route
|
* name the same extensions, so the terminal, the preview and the route
|
||||||
* agree on what a page is.
|
* agree on what a page is.
|
||||||
|
* 2. A successful mint renders an iframe whose `sandbox` carries scripts but
|
||||||
|
* never `allow-same-origin` (with it, the page could drive Codeman), with
|
||||||
|
* the Page pill shown and pressed.
|
||||||
|
* 3. A refused mint (remote case, broad folder, hidden directory) falls
|
||||||
|
* through to the source view with its Edit button, and hides the pill,
|
||||||
|
* which would only retry a page the server just refused.
|
||||||
|
* 4. The pill flips between page and source and persists
|
||||||
|
* `codeman:filePreviewHtmlRendered` per device; with it off the preview
|
||||||
|
* opens as source without asking the server for a page at all.
|
||||||
*
|
*
|
||||||
* Loaded via `vm` with a jsdom document injected, like
|
* Loaded via `vm` with a jsdom document injected, like
|
||||||
* file-preview-markdown.test.ts: constants.js + panels-ui.js only.
|
* file-preview-markdown.test.ts: constants.js + panels-ui.js only.
|
||||||
@@ -15,11 +24,108 @@
|
|||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { resolve } from 'node:path';
|
import { resolve } from 'node:path';
|
||||||
import vm from 'node:vm';
|
import vm from 'node:vm';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { JSDOM } from 'jsdom';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import { HTML_VIEW_EXTENSIONS } from '../src/web/routes/html-view-routes.js';
|
import { HTML_VIEW_EXTENSIONS } from '../src/web/routes/html-view-routes.js';
|
||||||
|
|
||||||
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
|
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
|
||||||
const constantsJs = readFileSync(resolve(PUBLIC, 'constants.js'), 'utf8');
|
const constantsJs = readFileSync(resolve(PUBLIC, 'constants.js'), 'utf8');
|
||||||
|
const panelsJs = readFileSync(resolve(PUBLIC, 'panels-ui.js'), 'utf8');
|
||||||
|
|
||||||
|
// A real origin: jsdom's localStorage getter throws on an opaque one.
|
||||||
|
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', { url: 'http://localhost/' });
|
||||||
|
const { document } = dom.window;
|
||||||
|
|
||||||
|
const PAGE_PATH = '/home/u/cases/demo/report/index.html';
|
||||||
|
const PAGE_URL = '/html-view/AAAAAAAAAAAAAAAAAAAAAAAA/index.html';
|
||||||
|
const HTML_SOURCE = '<!doctype html>\n<h1>Report</h1>\n';
|
||||||
|
|
||||||
|
function jsonResponse(body: unknown, status = 200) {
|
||||||
|
return { ok: status < 400, status, json: async () => body, text: async () => JSON.stringify(body) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The two routes the preview talks to for an HTML file: the mint (answered or
|
||||||
|
* refused per `mint`) and file-content, which serves the source view.
|
||||||
|
*/
|
||||||
|
function makeFetch(mint: 'ok' | 'refuse') {
|
||||||
|
return vi.fn(async (url: string, init?: { method?: string; body?: string }) => {
|
||||||
|
if (url.endsWith('/html-view')) {
|
||||||
|
return mint === 'ok'
|
||||||
|
? jsonResponse({ success: true, data: { url: PAGE_URL } })
|
||||||
|
: jsonResponse({ success: false, error: 'This folder is too broad to render a page from' }, 403);
|
||||||
|
}
|
||||||
|
if (url.includes('/file-content')) {
|
||||||
|
return jsonResponse({
|
||||||
|
success: true,
|
||||||
|
data: { content: HTML_SOURCE, totalLines: 3, size: HTML_SOURCE.length, truncated: false, editable: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw new Error(`unexpected fetch ${init?.method || 'GET'} ${url}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadApp(mint: 'ok' | 'refuse', prefs: Record<string, string> = {}) {
|
||||||
|
const store = new Map(Object.entries(prefs));
|
||||||
|
const CodemanApp = function CodemanApp(this: unknown) {} as unknown as new () => Record<string, any>;
|
||||||
|
const fetchMock = makeFetch(mint);
|
||||||
|
const context = vm.createContext({
|
||||||
|
CodemanApp,
|
||||||
|
console: { ...console, warn: vi.fn(), error: vi.fn() },
|
||||||
|
localStorage: {
|
||||||
|
getItem: (k: string) => (store.has(k) ? store.get(k) : null),
|
||||||
|
setItem: (k: string, v: string) => store.set(k, v),
|
||||||
|
removeItem: (k: string) => store.delete(k),
|
||||||
|
},
|
||||||
|
document,
|
||||||
|
window: { addEventListener: vi.fn(), removeEventListener: vi.fn(), open: vi.fn() },
|
||||||
|
MobileDetection: {},
|
||||||
|
setTimeout,
|
||||||
|
clearTimeout,
|
||||||
|
confirm: () => true,
|
||||||
|
fetch: fetchMock,
|
||||||
|
});
|
||||||
|
vm.runInContext(`${constantsJs}\n${panelsJs}`, context, { filename: 'panels-ui.js' });
|
||||||
|
|
||||||
|
// The overlay's elements, as index.html ships them.
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<div id="filePreviewOverlay"></div><span id="filePreviewTitle"></span>
|
||||||
|
<button id="filePreviewMdBtn" hidden></button>
|
||||||
|
<button id="filePreviewPageBtn" hidden></button>
|
||||||
|
<button id="filePreviewLinesBtn" hidden></button>
|
||||||
|
<button id="filePreviewWrapBtn" hidden></button>
|
||||||
|
<button id="filePreviewEditBtn" hidden></button>
|
||||||
|
<button id="filePreviewDetachBtn" hidden></button>
|
||||||
|
<div id="filePreviewBody"></div><div id="filePreviewFooter"></div>`;
|
||||||
|
|
||||||
|
const app = new CodemanApp();
|
||||||
|
app.$ = (id: string) => document.getElementById(id);
|
||||||
|
app._resetFilePreviewEdit = () => {
|
||||||
|
const edit = document.getElementById('filePreviewEditBtn');
|
||||||
|
if (edit) edit.hidden = true;
|
||||||
|
};
|
||||||
|
app._isExternalPreviewPath = () => false;
|
||||||
|
app.formatFileSize = (n: number) => `${n} B`;
|
||||||
|
app.filePreviewContent = '';
|
||||||
|
|
||||||
|
const byId = (id: string) => document.getElementById(id) as HTMLElement;
|
||||||
|
return {
|
||||||
|
app,
|
||||||
|
fetchMock,
|
||||||
|
store,
|
||||||
|
body: byId('filePreviewBody'),
|
||||||
|
footer: byId('filePreviewFooter'),
|
||||||
|
page: byId('filePreviewPageBtn'),
|
||||||
|
edit: byId('filePreviewEditBtn'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The mint requests the stub saw, as parsed bodies. */
|
||||||
|
function mintCalls(fetchMock: ReturnType<typeof makeFetch>) {
|
||||||
|
return fetchMock.mock.calls
|
||||||
|
.filter(([url]) => url.endsWith('/html-view'))
|
||||||
|
.map(([url, init]) => ({ url, method: init?.method, body: JSON.parse(init?.body || '{}') }));
|
||||||
|
}
|
||||||
|
|
||||||
describe('rendersAsPage', () => {
|
describe('rendersAsPage', () => {
|
||||||
it('names exactly the extensions the html-view route renders', () => {
|
it('names exactly the extensions the html-view route renders', () => {
|
||||||
@@ -39,3 +145,73 @@ describe('rendersAsPage', () => {
|
|||||||
expect(rendersAsPage(undefined as unknown as string)).toBe(false);
|
expect(rendersAsPage(undefined as unknown as string)).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('file viewer HTML page', () => {
|
||||||
|
it('renders a minted page in a sandboxed iframe that never gets allow-same-origin', async () => {
|
||||||
|
const { app, fetchMock, body, footer, page, edit } = loadApp('ok');
|
||||||
|
|
||||||
|
await app.openFilePreview(PAGE_PATH, 's1');
|
||||||
|
|
||||||
|
expect(mintCalls(fetchMock)).toEqual([
|
||||||
|
{ url: '/api/sessions/s1/html-view', method: 'POST', body: { path: PAGE_PATH } },
|
||||||
|
]);
|
||||||
|
const frame = body.querySelector('iframe')!;
|
||||||
|
expect(frame.getAttribute('src')).toBe(PAGE_URL);
|
||||||
|
const sandbox = (frame.getAttribute('sandbox') || '').split(/\s+/);
|
||||||
|
expect(sandbox).toContain('allow-scripts');
|
||||||
|
expect(sandbox).not.toContain('allow-same-origin');
|
||||||
|
expect(footer.textContent).toBe('HTML');
|
||||||
|
expect(app.filePreviewDetachUrl).toBe(PAGE_URL);
|
||||||
|
expect(page.hidden).toBe(false);
|
||||||
|
expect(page.getAttribute('aria-pressed')).toBe('true');
|
||||||
|
// A page has no text for Edit to act on.
|
||||||
|
expect(edit.hidden).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the source view with Edit and no Page pill when the mint is refused', async () => {
|
||||||
|
const { app, fetchMock, body, page, edit } = loadApp('refuse');
|
||||||
|
|
||||||
|
await app.openFilePreview(PAGE_PATH, 's1');
|
||||||
|
|
||||||
|
expect(mintCalls(fetchMock)).toHaveLength(1);
|
||||||
|
expect(body.querySelector('iframe')).toBeNull();
|
||||||
|
const pre = body.querySelector('pre.file-preview-text')!;
|
||||||
|
expect(pre.textContent).toBe(HTML_SOURCE);
|
||||||
|
expect(page.hidden).toBe(true);
|
||||||
|
expect(edit.hidden).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flips between page and source with the pill and persists the choice per device', async () => {
|
||||||
|
const { app, fetchMock, body, page, edit, store } = loadApp('ok');
|
||||||
|
|
||||||
|
await app.openFilePreview(PAGE_PATH, 's1');
|
||||||
|
expect(body.querySelector('iframe')).not.toBeNull();
|
||||||
|
|
||||||
|
await app.toggleFilePreviewPage();
|
||||||
|
expect(store.get('codeman:filePreviewHtmlRendered')).toBe('0');
|
||||||
|
expect(body.querySelector('iframe')).toBeNull();
|
||||||
|
expect(body.querySelector('pre.file-preview-text')!.textContent).toBe(HTML_SOURCE);
|
||||||
|
expect(page.hidden).toBe(false);
|
||||||
|
expect(page.getAttribute('aria-pressed')).toBe('false');
|
||||||
|
expect(edit.hidden).toBe(false);
|
||||||
|
// Source never asks the server for a page.
|
||||||
|
expect(mintCalls(fetchMock)).toHaveLength(1);
|
||||||
|
|
||||||
|
await app.toggleFilePreviewPage();
|
||||||
|
expect(store.get('codeman:filePreviewHtmlRendered')).toBe('1');
|
||||||
|
expect(body.querySelector('iframe')!.getAttribute('src')).toBe(PAGE_URL);
|
||||||
|
expect(page.getAttribute('aria-pressed')).toBe('true');
|
||||||
|
expect(mintCalls(fetchMock)).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens as source, pill offered, when the device pref says so', async () => {
|
||||||
|
const { app, fetchMock, body, page } = loadApp('ok', { 'codeman:filePreviewHtmlRendered': '0' });
|
||||||
|
|
||||||
|
await app.openFilePreview(PAGE_PATH, 's1');
|
||||||
|
|
||||||
|
expect(mintCalls(fetchMock)).toHaveLength(0);
|
||||||
|
expect(body.querySelector('pre.file-preview-text')!.textContent).toBe(HTML_SOURCE);
|
||||||
|
expect(page.hidden).toBe(false);
|
||||||
|
expect(page.getAttribute('aria-pressed')).toBe('false');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user