test(shared): smoke tests for the zddc-source.js FS-Access polyfill

The polyfill in shared/zddc-source.js is the seam between every tool
and zddc-server's HTTP API; if it drifts, every HTTP-mode tool breaks
together. Until now it had no direct test coverage — it was only
exercised through tool-level specs that load via file://.

Seven tests cover the documented surface:

- Public API shape on window.zddc.source matches the contract
- detectServerRoot() returns {handle:null, status:0} on file://
  (the negative path most tools branch on)
- httpListing() parses arrays, propagates 403 with err.status
- HttpDirectoryHandle.values() yields HttpFile/DirectoryHandles
  with correct kind+name from server-side is_dir/name fields
- HttpFileHandle.getFile() fetches, surfaces ETag, sets size
- moveFile() POSTs with X-ZDDC-Op: move, X-ZDDC-Destination, and
  optional If-Match — and returns the server's new ETag

Tests host inside classifier/dist/classifier.html (the smallest tool
that bundles zddc-source.js — browse, archive, landing, form do not),
mock fetch via page.route(), and add CORS + preflight responses so
file://-origin pages can read response headers like ETag.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-05-09 18:49:11 -05:00
parent 0024172be6
commit 0c48a583ad
2 changed files with 208 additions and 0 deletions

View file

@ -55,6 +55,10 @@ export default defineConfig({
name: 'browse',
testMatch: 'browse.spec.js',
},
{
name: 'zddc-source',
testMatch: 'zddc-source.spec.js',
},
{
name: 'zddc',
testMatch: 'zddc.spec.js',

204
tests/zddc-source.spec.js Normal file
View file

@ -0,0 +1,204 @@
// 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');
});
});