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>
204 lines
7.9 KiB
JavaScript
204 lines
7.9 KiB
JavaScript
// 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');
|
|
});
|
|
});
|