diff --git a/playwright.config.js b/playwright.config.js index 58f9dda..ff8bb61 100644 --- a/playwright.config.js +++ b/playwright.config.js @@ -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', diff --git a/tests/zddc-source.spec.js b/tests/zddc-source.spec.js new file mode 100644 index 0000000..70241fe --- /dev/null +++ b/tests/zddc-source.spec.js @@ -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'); + }); +});