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:
parent
0024172be6
commit
0c48a583ad
2 changed files with 208 additions and 0 deletions
|
|
@ -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
204
tests/zddc-source.spec.js
Normal 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');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue