// Tests for shared/zddc-source.js — the FS-Access-API polyfill that // lets every tool work transparently against zddc-server. The polyfill // is the seam between every tool and the server's HTTP API; if it // drifts, every tool's HTTP-mode behavior breaks together. // // Strategy: load classifier's compiled HTML over file:// (it bundles // shared/zddc-source.js — browse, archive, landing, form do not), then // drive the polyfill API directly. page.route() intercepts fetch() so // we can assert request shape and stub responses without spinning up a // real server. Cross-origin (file:// → http://) calls require CORS // headers + OPTIONS preflight handling for non-simple requests. import { test, expect } from '@playwright/test'; import * as path from 'path'; const HTML_PATH = path.resolve('classifier/dist/classifier.html'); // The polyfill never inspects origin — any URL reachable by fetch() // works. We pick one that's clearly synthetic so failures don't read // as real network problems. const FAKE = 'http://example.test'; // CORS headers needed because the page is on file:// and the test // fetches a different origin. Expose every response header the // polyfill reads (ETag, Last-Modified) so JS can see them. const CORS = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, HEAD, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, If-Match, X-ZDDC-Op, X-ZDDC-Destination', 'Access-Control-Expose-Headers': 'ETag, Last-Modified', }; test.describe('shared/zddc-source.js polyfill', () => { test.beforeEach(async ({ page }) => { await page.goto(`file://${HTML_PATH}`, { waitUntil: 'load' }); }); test('exposes the documented surface on window.zddc.source', async ({ page }) => { const surface = await page.evaluate(() => { const s = window.zddc && window.zddc.source; if (!s) return null; return { hasHandleCtor: typeof s.HttpDirectoryHandle === 'function', hasFileCtor: typeof s.HttpFileHandle === 'function', hasDetect: typeof s.detectServerRoot === 'function', hasMove: typeof s.moveFile === 'function', hasIsHttp: typeof s.isHttpHandle === 'function', hasListing: typeof s.httpListing === 'function', }; }); expect(surface).toEqual({ hasHandleCtor: true, hasFileCtor: true, hasDetect: true, hasMove: true, hasIsHttp: true, hasListing: true, }); }); test('detectServerRoot() returns {handle:null,status:0} on file://', async ({ page }) => { // The page is loaded from file:// so detect should bail out // immediately without hitting the network. const r = await page.evaluate(() => window.zddc.source.detectServerRoot()); expect(r).toEqual({ handle: null, status: 0 }); }); test('httpListing() parses an array body and issues the right request', async ({ page }) => { const seen = []; await page.route(`${FAKE}/dir/`, route => { if (route.request().method() === 'OPTIONS') { route.fulfill({ status: 204, headers: CORS }); return; } seen.push({ method: route.request().method(), accept: route.request().headers()['accept'], }); route.fulfill({ status: 200, contentType: 'application/json', headers: CORS, body: JSON.stringify([ { name: 'a.txt', is_dir: false, size: 10 }, { name: 'sub/', is_dir: true, size: 0 }, ]), }); }); const result = await page.evaluate(async (url) => { return await window.zddc.source.httpListing(url); }, `${FAKE}/dir/`); expect(seen.length).toBe(1); expect(seen[0].method).toBe('GET'); expect(seen[0].accept).toBe('application/json'); expect(result).toHaveLength(2); expect(result[0].name).toBe('a.txt'); }); test('httpListing() throws with .status on 403', async ({ page }) => { await page.route(`${FAKE}/forbidden/`, route => { route.fulfill({ status: 403, headers: CORS, body: 'forbidden' }); }); const err = await page.evaluate(async (url) => { try { await window.zddc.source.httpListing(url); return null; } catch (e) { return { message: e.message, status: e.status }; } }, `${FAKE}/forbidden/`); expect(err).not.toBeNull(); expect(err.status).toBe(403); expect(err.message).toMatch(/HTTP 403/); }); test('HttpDirectoryHandle.values() yields polyfill handles for each entry', async ({ page }) => { await page.route(`${FAKE}/d/`, route => route.fulfill({ status: 200, contentType: 'application/json', headers: CORS, body: JSON.stringify([ { name: 'spec.pdf', is_dir: false, size: 1024 }, { name: 'sub/', is_dir: true, size: 0 }, ]), })); const entries = await page.evaluate(async (url) => { const root = new window.zddc.source.HttpDirectoryHandle(url, 'd'); const out = []; for await (const entry of root.values()) { out.push({ name: entry.name, kind: entry.kind }); } return out; }, `${FAKE}/d/`); expect(entries).toEqual([ { name: 'spec.pdf', kind: 'file' }, { name: 'sub', kind: 'directory' }, ]); }); test('HttpFileHandle.getFile() fetches and surfaces ETag', async ({ page }) => { await page.route(`${FAKE}/d/spec.pdf`, route => route.fulfill({ status: 200, contentType: 'application/pdf', headers: { ...CORS, 'ETag': '"abc123"', 'Last-Modified': 'Mon, 01 Jan 2024 00:00:00 GMT', }, body: '%PDF-fixture', })); const out = await page.evaluate(async (url) => { const fh = new window.zddc.source.HttpFileHandle(url, 'spec.pdf', 12); const file = await fh.getFile(); return { fileName: file.name, fileSize: file.size, etag: fh._etag, }; }, `${FAKE}/d/spec.pdf`); expect(out.fileName).toBe('spec.pdf'); expect(out.fileSize).toBeGreaterThan(0); expect(out.etag).toBe('abc123'); }); test('moveFile() POSTs with X-ZDDC-Op and X-ZDDC-Destination', async ({ page }) => { let captured = null; await page.route(`${FAKE}/old/path.txt`, route => { if (route.request().method() === 'OPTIONS') { route.fulfill({ status: 204, headers: CORS }); return; } captured = { method: route.request().method(), op: route.request().headers()['x-zddc-op'], dst: route.request().headers()['x-zddc-destination'], ifMatch: route.request().headers()['if-match'] || null, }; route.fulfill({ status: 200, headers: { ...CORS, 'ETag': '"new-etag"' }, body: '', }); }); const newEtag = await page.evaluate(async (src) => { return await window.zddc.source.moveFile(src, '/new/path.txt', { ifMatch: '"old"' }); }, `${FAKE}/old/path.txt`); expect(captured).not.toBeNull(); expect(captured.method).toBe('POST'); expect(captured.op).toBe('move'); expect(captured.dst).toBe('/new/path.txt'); expect(captured.ifMatch).toBe('"old"'); expect(newEtag).toBe('new-etag'); }); });