From 81a63b00ac1fca59d55f74dbedfb0cdc64f2faeb Mon Sep 17 00:00:00 2001 From: CrazyFreak <44674613+OffCrazyFreak@users.noreply.github.com> Date: Fri, 11 Sep 2026 23:48:20 +0200 Subject: [PATCH 1/2] test(web): Cover the Client and useRun wiring Changes: - `client.test.ts` walks the worker protocol through a fake `Worker`: init base, ready and failed, ids and transfer lists, error replies, answers, reset and terminate - `use-run.test.ts` drives the hook with a fake client: the phase walk, grouping, unreadable rows, shelved catalogues, live-request answers, stop with a late answer, reset, retry, the sample, and repair in both modes including the real `client-zip` archive and the write-back handles - Add `happy-dom`, `@testing-library/react` and `@testing-library/dom` as dev dependencies; the two new files opt into happy-dom per file, the existing tests stay in node The page-to-worker wiring had no tests; only `applyEvent` from the mock simulation was covered, and manual checks in the T3 preview were the only evidence the request ids, the stale-run token, stop, retry and repair behaved. Notes: - A run parked on an unanswered propose must not be awaited inside `act`, or the open act scope swallows every later render in the file Closes #27 --- web/package.json | 3 + web/pnpm-lock.yaml | 159 ++++++++++- web/src/__tests__/client.test.ts | 159 +++++++++++ web/src/__tests__/use-run.test.ts | 421 ++++++++++++++++++++++++++++++ 4 files changed, 740 insertions(+), 2 deletions(-) create mode 100644 web/src/__tests__/client.test.ts create mode 100644 web/src/__tests__/use-run.test.ts diff --git a/web/package.json b/web/package.json index 5937795..a0217ae 100644 --- a/web/package.json +++ b/web/package.json @@ -32,11 +32,14 @@ "vite-plugin-static-copy": "4.1.1" }, "devDependencies": { + "@testing-library/dom": "^10.4.1", + "@testing-library/react": "^16.3.3", "@types/node": "^24.13.3", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.4", "@types/wicg-file-system-access": "2023.10.7", "@vitejs/plugin-react": "^6.1.0", + "happy-dom": "^20.14.3", "prettier": "^3.9.6", "typescript": "~6.0.2", "vite": "^8.2.2", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 7dac4fc..33e89a0 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -60,6 +60,12 @@ importers: specifier: 4.1.1 version: 4.1.1(vite@8.2.2(@types/node@24.13.4)(jiti@2.7.0)) devDependencies: + '@testing-library/dom': + specifier: ^10.4.1 + version: 10.4.1 + '@testing-library/react': + specifier: ^16.3.3 + version: 16.3.3(@testing-library/dom@10.4.1)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@types/node': specifier: ^24.13.3 version: 24.13.4 @@ -75,6 +81,9 @@ importers: '@vitejs/plugin-react': specifier: ^6.1.0 version: 6.1.1(vite@8.2.2(@types/node@24.13.4)(jiti@2.7.0)) + happy-dom: + specifier: ^20.14.3 + version: 20.14.3 prettier: specifier: ^3.9.6 version: 3.9.6 @@ -86,7 +95,7 @@ importers: version: 8.2.2(@types/node@24.13.4)(jiti@2.7.0) vitest: specifier: ^5.0.0 - version: 5.0.0(@types/node@24.13.4)(vite@8.2.2(@types/node@24.13.4)(jiti@2.7.0)) + version: 5.0.0(@types/node@24.13.4)(happy-dom@20.14.3)(vite@8.2.2(@types/node@24.13.4)(jiti@2.7.0)) packages: @@ -207,6 +216,10 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + '@babel/template@7.29.7': resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} engines: {node: '>=6.9.0'} @@ -1187,9 +1200,31 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 || ^8 + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/react@16.3.3': + resolution: {integrity: sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@ts-morph/common@0.27.0': resolution: {integrity: sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==} + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -1216,9 +1251,15 @@ packages: '@types/validate-npm-package-name@4.0.2': resolution: {integrity: sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==} + '@types/whatwg-mimetype@3.0.2': + resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} + '@types/wicg-file-system-access@2023.10.7': resolution: {integrity: sha512-g49ijasEJvCd7ifmAY2D0wdEtt1xRjBbA33PJTiv8mKBr7DoMsPeISoJ8oQOTopSRi+FBWPpPW5ouDj2QPKtGA==} + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@vitejs/plugin-react@6.1.1': resolution: {integrity: sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1284,6 +1325,10 @@ packages: resolution: {integrity: sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==} engines: {node: '>=12'} + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + anymatch@3.1.3: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} @@ -1295,6 +1340,9 @@ packages: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -1337,6 +1385,10 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer-image-size@0.6.4: + resolution: {integrity: sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==} + engines: {node: '>=4.0'} + bundle-name@4.1.0: resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} engines: {node: '>=18'} @@ -1503,6 +1555,10 @@ packages: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -1514,6 +1570,9 @@ packages: resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} engines: {node: '>=0.3.1'} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + dot-prop@6.0.1: resolution: {integrity: sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA==} engines: {node: '>=10'} @@ -1547,6 +1606,10 @@ packages: resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} engines: {node: '>=8.6'} + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + env-paths@2.2.1: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} @@ -1733,6 +1796,10 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + happy-dom@20.14.3: + resolution: {integrity: sha512-0KMb/Eh8rsd+aMNkOk5h8LkAjo9Na1aksnEGmsuZf+GXl6UkAdIi0VbUHU7d59lXhNmawODgwwuCMiZpCxwwCw==} + engines: {node: '>=20.0.0'} + has-symbols@1.1.0: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} @@ -2086,6 +2153,10 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -2320,6 +2391,10 @@ packages: engines: {node: '>=14'} hasBin: true + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + pretty-ms@9.3.1: resolution: {integrity: sha512-HzMy3Geq23nVALD/M2LliU+F+M+gVNsvkQWWqeBZ8HDiCgzo6YPJ/Omrmtq24EFrIsk0a3EkQGEd7bDOo+IhGA==} engines: {node: '>=18'} @@ -2369,6 +2444,9 @@ packages: peerDependencies: react: ^19.3.0 + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-remove-scroll-bar@2.3.8: resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} engines: {node: '>=10'} @@ -2773,6 +2851,10 @@ packages: jsdom: optional: true + whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -2991,6 +3073,8 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/runtime@7.29.7': {} + '@babel/template@7.29.7': dependencies: '@babel/code-frame': 7.29.7 @@ -3983,12 +4067,35 @@ snapshots: tailwindcss: 4.3.3 vite: 8.2.2(@types/node@24.13.4)(jiti@2.7.0) + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/react@16.3.3(@testing-library/dom@10.4.1)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@babel/runtime': 7.29.7 + '@testing-library/dom': 10.4.1 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + '@ts-morph/common@0.27.0': dependencies: fast-glob: 3.3.3 minimatch: 10.2.6 path-browserify: 1.0.1 + '@types/aria-query@5.0.4': {} + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -4014,8 +4121,14 @@ snapshots: '@types/validate-npm-package-name@4.0.2': {} + '@types/whatwg-mimetype@3.0.2': {} + '@types/wicg-file-system-access@2023.10.7': {} + '@types/ws@8.18.1': + dependencies: + '@types/node': 24.13.4 + '@vitejs/plugin-react@6.1.1(vite@8.2.2(@types/node@24.13.4)(jiti@2.7.0))': dependencies: '@rolldown/pluginutils': 1.0.1 @@ -4058,6 +4171,8 @@ snapshots: ansi-regex@6.3.0: {} + ansi-styles@5.2.0: {} + anymatch@3.1.3: dependencies: normalize-path: 3.0.0 @@ -4069,6 +4184,10 @@ snapshots: dependencies: tslib: 2.8.1 + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + assertion-error@2.0.1: {} ast-types@0.16.3: @@ -4113,6 +4232,10 @@ snapshots: node-releases: 2.0.55 update-browserslist-db: 1.3.2(browserslist@4.28.9) + buffer-image-size@0.6.4: + dependencies: + '@types/node': 24.13.4 + bundle-name@4.1.0: dependencies: run-applescript: 7.1.0 @@ -4245,12 +4368,16 @@ snapshots: depd@2.0.0: {} + dequal@2.0.3: {} + detect-libc@2.1.2: {} detect-node-es@1.1.0: {} diff@8.0.4: {} + dom-accessibility-api@0.5.16: {} + dot-prop@6.0.1: dependencies: is-obj: 2.0.0 @@ -4281,6 +4408,8 @@ snapshots: ansi-colors: 4.1.3 strip-ansi: 6.0.1 + entities@7.0.1: {} + env-paths@2.2.1: {} error-ex@1.3.4: @@ -4495,6 +4624,19 @@ snapshots: graceful-fs@4.2.11: {} + happy-dom@20.14.3: + dependencies: + '@types/node': 24.13.4 + '@types/whatwg-mimetype': 3.0.2 + '@types/ws': 8.18.1 + buffer-image-size: 0.6.4 + entities: 7.0.1 + whatwg-mimetype: 3.0.0 + ws: 8.21.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + has-symbols@1.1.0: {} hasown@2.0.4: @@ -4738,6 +4880,8 @@ snapshots: dependencies: react: 19.3.0 + lz-string@1.5.0: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 @@ -4931,6 +5075,12 @@ snapshots: prettier@3.9.6: {} + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + pretty-ms@9.3.1: dependencies: parse-ms: 4.0.0 @@ -5037,6 +5187,8 @@ snapshots: react: 19.3.0 scheduler: 0.28.0 + react-is@17.0.2: {} + react-remove-scroll-bar@2.3.8(@types/react@19.3.0)(react@19.3.0): dependencies: react: 19.3.0 @@ -5392,7 +5544,7 @@ snapshots: fsevents: 2.3.3 jiti: 2.7.0 - vitest@5.0.0(@types/node@24.13.4)(vite@8.2.2(@types/node@24.13.4)(jiti@2.7.0)): + vitest@5.0.0(@types/node@24.13.4)(happy-dom@20.14.3)(vite@8.2.2(@types/node@24.13.4)(jiti@2.7.0)): dependencies: '@types/chai': 5.2.3 '@vitest/mocker': 5.0.0(vite@8.2.2(@types/node@24.13.4)(jiti@2.7.0)) @@ -5410,9 +5562,12 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.13.4 + happy-dom: 20.14.3 transitivePeerDependencies: - msw + whatwg-mimetype@3.0.0: {} + which@2.0.2: dependencies: isexe: 2.0.0 diff --git a/web/src/__tests__/client.test.ts b/web/src/__tests__/client.test.ts new file mode 100644 index 0000000..48546bf --- /dev/null +++ b/web/src/__tests__/client.test.ts @@ -0,0 +1,159 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { Client, RuntimeFailed } from '@/run/client' +import type { Proposal } from '@/types' +import type { FromWorker, ToWorker } from '@/worker/protocol' + +type Posted = { message: ToWorker; transfer: Transferable[] } + +// Stands in for the browser's Worker: records what the page posts and lets a +// test post replies back, so the protocol can be walked without Pyodide. +class FakeWorker { + static instances: FakeWorker[] = [] + posted: Posted[] = [] + onmessage: ((event: MessageEvent) => void) | null = null + onerror: ((event: ErrorEvent) => void) | null = null + terminated = false + constructor() { + FakeWorker.instances.push(this) + } + postMessage(message: ToWorker, transfer: Transferable[] = []) { + this.posted.push({ message, transfer }) + } + terminate() { + this.terminated = true + } + reply(message: FromWorker) { + this.onmessage?.({ data: message } as MessageEvent) + } + fail(message: string) { + this.onerror?.({ message } as ErrorEvent) + } +} + +const proposal = { stem: 'A - B', conf: 'HIGH' } as unknown as Proposal + +function make() { + const events: FromWorker[] = [] + const client = new Client((event) => events.push(event)) + const worker = FakeWorker.instances[FakeWorker.instances.length - 1] + return { client, worker, events } +} + +beforeEach(() => { + FakeWorker.instances = [] + vi.stubGlobal('Worker', FakeWorker) +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('Client', () => { + it('starts the worker with the base the page is served from', () => { + const { worker } = make() + expect(worker.posted).toHaveLength(1) + const init = worker.posted[0].message + expect(init.type).toBe('init') + if (init.type === 'init') expect(init.base).toMatch(/^http:\/\/.+\/$/) + }) + + it('resolves ready once the worker says so and forwards the loading events', async () => { + const { client, worker, events } = make() + worker.reply({ type: 'loading', progress: 0.5, label: 'Unpacking' }) + worker.reply({ type: 'ready' }) + await expect(client.ready()).resolves.toBeUndefined() + expect(events.map((e) => e.type)).toEqual(['loading', 'ready']) + }) + + it('rejects ready with the worker reason when the runtime fails to load', async () => { + const { client, worker, events } = make() + const ready = client.ready() + worker.reply({ type: 'failed', message: 'No wheel' }) + await expect(ready).rejects.toBeInstanceOf(RuntimeFailed) + await expect(ready).rejects.toThrow('No wheel') + expect(events).toEqual([{ type: 'failed', message: 'No wheel' }]) + }) + + it('rejects ready when the worker itself cannot start', async () => { + const { client, worker, events } = make() + const ready = client.ready() + worker.fail('') + await expect(ready).rejects.toThrow('The worker could not start.') + expect(events).toEqual([{ type: 'failed', message: 'The worker could not start.' }]) + }) + + it('matches a proposal to its request by id and transfers the bytes', async () => { + const { client, worker } = make() + const epub = new ArrayBuffer(4) + const pending = client.propose('A - B', { '.epub': epub }) + const sent = worker.posted[1] + expect(sent.message).toMatchObject({ type: 'propose', id: 1, stem: 'A - B' }) + expect(sent.transfer).toEqual([epub]) + expect(client.live).toBe(1) + // Another request's reply must not settle this one. + worker.reply({ + type: 'proposed', + id: 2, + facts: {} as never, + proposal, + pause: 0, + unavailable: [], + }) + worker.reply({ + type: 'proposed', + id: 1, + facts: {} as never, + proposal, + pause: 3, + unavailable: ['openlib'], + }) + await expect(pending).resolves.toMatchObject({ id: 1, pause: 3, unavailable: ['openlib'] }) + }) + + it('gives each request a fresh id', () => { + const { client, worker } = make() + void client.propose('one', {}) + void client.propose('two', {}) + const ids = worker.posted.slice(1).map((p) => (p.message as { id: number }).id) + expect(ids).toEqual([1, 2]) + expect(client.live).toBe(2) + }) + + it('rejects a request the worker answers with an error', async () => { + const { client, worker } = make() + const pending = client.apply('A - B', {}, proposal) + worker.reply({ type: 'error', id: 1, message: 'Not an EPUB' }) + await expect(pending).rejects.toThrow('Not an EPUB') + }) + + it('resolves apply with the repaired bytes and the write outcomes', async () => { + const { client, worker } = make() + const pending = client.apply('A - B', { '.pdf': new ArrayBuffer(2) }, proposal) + const repaired = new ArrayBuffer(8) + worker.reply({ + type: 'applied', + id: 1, + files: { '.pdf': repaired }, + writes: [{ ext: '.pdf', ok: true, reason: '' }], + }) + const reply = await pending + expect(reply.files['.pdf']).toBe(repaired) + expect(reply.writes).toEqual([{ ext: '.pdf', ok: true, reason: '' }]) + }) + + it('forwards answers with the id of the request they belong to', () => { + const { worker, events } = make() + worker.reply({ type: 'answer', id: 1, stem: 'A - B', source: 'apple' }) + expect(events).toEqual([{ type: 'answer', id: 1, stem: 'A - B', source: 'apple' }]) + }) + + it('posts reset and terminates the worker on request', () => { + const { client, worker } = make() + client.reset() + expect(worker.posted[1].message).toEqual({ type: 'reset' }) + client.terminate() + expect(worker.terminated).toBe(true) + }) +}) diff --git a/web/src/__tests__/use-run.test.ts b/web/src/__tests__/use-run.test.ts new file mode 100644 index 0000000..daea4e2 --- /dev/null +++ b/web/src/__tests__/use-run.test.ts @@ -0,0 +1,421 @@ +// @vitest-environment happy-dom +import { act, renderHook, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type { Intake, IntakeFile } from '@/intake' +import { useRun } from '@/run/use-run' +import type { Proposal } from '@/types' +import type { FileBytes, FromWorker, WriteOutcome } from '@/worker/protocol' + +type Broadcast = Extract +type Proposed = Extract + +// Hoisted with the mock below: vi.mock runs before any import, so the fake +// must exist before use-run.ts asks for its Client. +const { FakeClient, script, FACTS } = vi.hoisted(() => { + const FACTS = { author: 'Ada Example', title: 'Sample', series: null, series_index: null } + // What the fake worker answers, set per test. A propose that is never + // answered leaves the run mid-book, which is how stop is exercised. + const script = { + propose: (_stem: string, _files: FileBytes): Promise> => + Promise.resolve({ facts: FACTS, proposal: null, pause: 0, unavailable: [] }), + apply: ( + _stem: string, + _files: FileBytes, + _proposal: Proposal, + ): Promise<{ files: FileBytes; writes: WriteOutcome[] }> => + Promise.resolve({ files: {}, writes: [] }), + } + class FakeClient { + static instances: FakeClient[] = [] + live = 0 + terminated = false + resets = 0 + onEvent: (event: Broadcast) => void + private nextId = 1 + constructor(onEvent: (event: Broadcast) => void) { + FakeClient.instances.push(this) + this.onEvent = onEvent + } + ready() { + return Promise.resolve() + } + reset() { + this.resets++ + } + async propose(stem: string, files: FileBytes) { + const id = this.nextId++ + this.live = id + const reply = await script.propose(stem, files) + return { type: 'proposed' as const, id, ...reply } + } + apply(stem: string, files: FileBytes, proposal: Proposal) { + return script.apply(stem, files, proposal) + } + terminate() { + this.terminated = true + } + } + return { FakeClient, script, FACTS } +}) + +vi.mock('@/run/client', () => ({ Client: FakeClient, RuntimeFailed: class extends Error {} })) + +const high = (stem: string): Proposal => ({ + stem, + files: {}, + conf: 'HIGH', + sources: ['apple', 'openlib'], + gains: { tags: ['Essays'] }, + merged: empty(), + fn_score: 1, + au_score: 1, + src_titles: {}, + scores: [], + current: empty(), + unreadable: false, +}) + +function empty() { + return { + title: '', + authors: [], + publisher: '', + description: '', + tags: [], + series: null, + sidx: null, + isbn: '', + } +} + +function file(path: string, bytes = 'x', handle?: FileSystemFileHandle): IntakeFile { + const name = path.slice(path.lastIndexOf('/') + 1) + return { path, file: new File([bytes], name), handle } +} + +function intake(files: IntakeFile[]): Intake { + return { books: files, others: 0, folders: [] } +} + +// A writable file handle that records what was written to it. +function fakeHandle(written: Record, name: string, permission = 'granted') { + return { + kind: 'file', + name, + queryPermission: () => Promise.resolve(permission), + requestPermission: () => Promise.resolve(permission), + getFile: () => Promise.resolve(new File(['old'], name)), + createWritable: () => + Promise.resolve({ + write: (data: ArrayBuffer) => { + written[name] = new Uint8Array(data) + return Promise.resolve() + }, + close: () => Promise.resolve(), + }), + } as unknown as FileSystemFileHandle +} + +const downloads: { name: string; blob: Blob }[] = [] + +beforeEach(() => { + FakeClient.instances = [] + downloads.length = 0 + script.propose = () => + Promise.resolve({ facts: FACTS, proposal: null, pause: 0, unavailable: [] }) + script.apply = () => Promise.resolve({ files: {}, writes: [] }) + vi.stubGlobal( + 'URL', + class extends URL { + static createObjectURL(blob: Blob) { + downloads.push({ name: '', blob }) + return 'blob:fake' + } + static revokeObjectURL() {} + }, + ) + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function ( + this: HTMLAnchorElement, + ) { + downloads[downloads.length - 1].name = this.download + }) +}) + +afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() +}) + +describe('useRun start', () => { + it('walks a book from pending to done with the proposal the worker returns', async () => { + script.propose = (stem) => + Promise.resolve({ facts: FACTS, proposal: high(stem), pause: 0, unavailable: [] }) + const { result } = renderHook(() => useRun()) + expect(result.current.state.phase).toBe('idle') + await act(() => result.current.start(intake([file('Ada Example - Sample.epub')]))) + await waitFor(() => expect(result.current.state.phase).toBe('done')) + const [book] = result.current.state.books + expect(book.stem).toBe('Ada Example - Sample') + expect(book.files).toEqual(['.epub']) + expect(book.status).toBe('done') + expect(book.proposal?.conf).toBe('HIGH') + expect(result.current.state.active).toBeNull() + expect(result.current.state.sample).toBe(false) + expect(FakeClient.instances).toHaveLength(1) + expect(FakeClient.instances[0].resets).toBe(1) + }) + + it('groups an EPUB and a PDF with one stem into one row', async () => { + const { result } = renderHook(() => useRun()) + await act(() => + result.current.start( + intake([file('a/Ada Example - Sample.pdf'), file('a/Ada Example - Sample.epub')]), + ), + ) + await waitFor(() => expect(result.current.state.phase).toBe('done')) + expect(result.current.state.books).toHaveLength(1) + expect(result.current.state.books[0].files).toEqual(['.epub', '.pdf']) + }) + + it('turns a book the worker cannot read into an unreadable row and carries on', async () => { + script.propose = (stem) => + stem.endsWith('Broken') + ? Promise.reject(new Error('Not a zip')) + : Promise.resolve({ facts: FACTS, proposal: high(stem), pause: 0, unavailable: [] }) + const { result } = renderHook(() => useRun()) + await act(() => + result.current.start( + intake([file('Ada Example - Broken.epub'), file('Ada Example - Fine.epub')]), + ), + ) + await waitFor(() => expect(result.current.state.phase).toBe('done')) + const [broken, fine] = result.current.state.books + expect(broken.proposal?.unreadable).toBe(true) + expect(broken.proposal?.conf).toBe('LOW') + expect(broken.proposal?.current.title).toBe('Not a zip') + expect(fine.proposal?.conf).toBe('HIGH') + }) + + it('records the catalogues the worker shelved', async () => { + script.propose = () => + Promise.resolve({ facts: FACTS, proposal: null, pause: 0, unavailable: ['openlib'] }) + const { result } = renderHook(() => useRun()) + await act(() => result.current.start(intake([file('Ada Example - Sample.epub')]))) + await waitFor(() => expect(result.current.state.phase).toBe('done')) + expect(result.current.state.unavailable).toEqual(['openlib']) + }) + + it('forwards loading and answer events for the live request only', async () => { + let release!: () => void + script.propose = () => + new Promise((resolve) => { + release = () => resolve({ facts: FACTS, proposal: null, pause: 0, unavailable: [] }) + }) + const { result } = renderHook(() => useRun()) + // Not awaited: the run is parked on the held propose. + act(() => void result.current.start(intake([file('Ada Example - Sample.epub')]))) + await waitFor(() => expect(FakeClient.instances[0].live).toBe(1)) + const client = FakeClient.instances[0] + act(() => client.onEvent({ type: 'loading', progress: 0.5, label: 'Unpacking' })) + expect(result.current.state.loading).toEqual({ progress: 0.5, label: 'Unpacking' }) + act(() => + client.onEvent({ type: 'answer', id: 1, stem: 'Ada Example - Sample', source: 'apple' }), + ) + act(() => + client.onEvent({ type: 'answer', id: 9, stem: 'Ada Example - Sample', source: 'openlib' }), + ) + expect(result.current.state.books[0].answered).toEqual(['apple']) + await act(async () => { + release() + }) + await waitFor(() => expect(result.current.state.phase).toBe('done')) + }) +}) + +describe('useRun stop, reset and retry', () => { + it('keeps the verdicts reached, marks the rest skipped and drops the late answer', async () => { + const answered: string[] = [] + let release!: () => void + script.propose = (stem) => { + answered.push(stem) + if (answered.length === 1) { + return Promise.resolve({ facts: FACTS, proposal: high(stem), pause: 0, unavailable: [] }) + } + return new Promise((resolve) => { + release = () => resolve({ facts: FACTS, proposal: high(stem), pause: 0, unavailable: [] }) + }) + } + const { result } = renderHook(() => useRun()) + act( + () => + void result.current.start( + intake([ + file('Ada Example - One.epub'), + file('Ada Example - Two.epub'), + file('Ada Example - Three.epub'), + ]), + ), + ) + await waitFor(() => expect(answered).toHaveLength(2)) + act(() => result.current.stop()) + expect(result.current.state.phase).toBe('done') + expect(result.current.state.books.map((b) => b.status)).toEqual(['done', 'skipped', 'skipped']) + expect(FakeClient.instances[0].live).toBe(0) + await act(async () => { + release() + }) + expect(result.current.state.books[1].status).toBe('skipped') + expect(result.current.state.books[1].proposal).toBeNull() + }) + + it('reset returns to idle with no books', async () => { + const { result } = renderHook(() => useRun()) + await act(() => result.current.start(intake([file('Ada Example - Sample.epub')]))) + await waitFor(() => expect(result.current.state.phase).toBe('done')) + act(() => result.current.reset()) + expect(result.current.state.phase).toBe('idle') + expect(result.current.state.books).toEqual([]) + }) + + it('retry replaces the worker and runs the same files again', async () => { + const { result } = renderHook(() => useRun()) + await act(() => result.current.start(intake([file('Ada Example - Sample.epub')]))) + await waitFor(() => expect(result.current.state.phase).toBe('done')) + await act(async () => { + result.current.retry() + }) + await waitFor(() => expect(result.current.state.phase).toBe('done')) + expect(FakeClient.instances).toHaveLength(2) + expect(FakeClient.instances[0].terminated).toBe(true) + expect(result.current.state.books[0].stem).toBe('Ada Example - Sample') + }) + + it('the sample plays without a worker and cannot be written', () => { + const { result } = renderHook(() => useRun()) + act(() => result.current.playSample()) + expect(result.current.state.sample).toBe(true) + expect(result.current.state.phase).toBe('loading') + expect(FakeClient.instances).toHaveLength(0) + expect(result.current.writable([])).toBe(true) + }) +}) + +describe('useRun repair', () => { + async function run(files: IntakeFile[]) { + script.propose = (stem) => + Promise.resolve({ facts: FACTS, proposal: high(stem), pause: 0, unavailable: [] }) + script.apply = (stem, bytes) => + Promise.resolve({ + files: Object.fromEntries( + Object.keys(bytes).map((ext) => [ext, new TextEncoder().encode(`${stem}${ext}`).buffer]), + ) as FileBytes, + writes: Object.keys(bytes).map((ext) => ({ ext: ext as '.epub', ok: true, reason: '' })), + }) + const hook = renderHook(() => useRun()) + await act(() => hook.result.current.start(intake(files))) + await waitFor(() => expect(hook.result.current.state.phase).toBe('done')) + return hook + } + + it('downloads each file on its own up to five', async () => { + const { result } = await run([ + file('lib/Ada Example - One.epub'), + file('lib/Ada Example - One.pdf'), + file('lib/Ada Example - Two.epub'), + ]) + const outcome = await result.current.repair(result.current.state.books, 'downloaded') + expect(downloads.map((d) => d.name)).toEqual([ + 'Ada Example - One.epub', + 'Ada Example - One.pdf', + 'Ada Example - Two.epub', + ]) + expect(await downloads[1].blob.text()).toBe('lib/Ada Example - One.pdf') + expect([...outcome.outcomes]).toEqual([ + ['lib/Ada Example - One', 'downloaded'], + ['lib/Ada Example - Two', 'downloaded'], + ]) + expect(outcome.failures).toEqual([]) + }) + + it('downloads one zip beyond five files, with every file under its own path', async () => { + const names = ['One', 'Two', 'Three'].flatMap((n) => [ + `lib/Ada Example - ${n}.epub`, + `lib/Ada Example - ${n}.pdf`, + ]) + const { result } = await run(names.map((n) => file(n))) + const outcome = await result.current.repair(result.current.state.books, 'downloaded') + expect(downloads.map((d) => d.name)).toEqual(['ebook-metamend-repaired.zip']) + expect(outcome.outcomes.size).toBe(3) + const zip = new Uint8Array(await downloads[0].blob.arrayBuffer()) + expect([...zip.slice(0, 4)]).toEqual([0x50, 0x4b, 0x03, 0x04]) + const text = new TextDecoder('latin1').decode(zip) + for (const name of names) expect(text).toContain(name) + // Stored, not deflated: the central directory records method 0 for each entry. + const central = text.indexOf('PK\x01\x02') + expect(central).toBeGreaterThan(0) + expect(zip[central + 10]).toBe(0) + expect(zip[central + 11]).toBe(0) + }) + + it('writes back through the handles after permission is granted', async () => { + const written: Record = {} + const { result } = await run([ + file('Ada Example - One.epub', 'x', fakeHandle(written, 'Ada Example - One.epub')), + file('Ada Example - One.pdf', 'x', fakeHandle(written, 'Ada Example - One.pdf')), + ]) + expect(result.current.writable(result.current.state.books)).toBe(true) + const outcome = await result.current.repair(result.current.state.books, 'written') + expect(Object.keys(written).sort()).toEqual(['Ada Example - One.epub', 'Ada Example - One.pdf']) + expect(new TextDecoder().decode(written['Ada Example - One.pdf'])).toBe('Ada Example - One.pdf') + expect([...outcome.outcomes]).toEqual([['Ada Example - One', 'written']]) + expect(downloads).toEqual([]) + }) + + it('does nothing when write permission is refused', async () => { + const written: Record = {} + const { result } = await run([ + file('Ada Example - One.epub', 'x', fakeHandle(written, 'Ada Example - One.epub', 'denied')), + ]) + const outcome = await result.current.repair(result.current.state.books, 'written') + expect(outcome.failures).toEqual(['Write access was not granted.']) + expect(outcome.outcomes.size).toBe(0) + expect(written).toEqual({}) + }) + + it('reports a book the worker could not write and leaves it unplaced', async () => { + const { result } = await run([file('Ada Example - One.epub'), file('Ada Example - Two.epub')]) + const good = script.apply + script.apply = (stem, bytes, proposal) => + stem.endsWith('One') + ? Promise.resolve({ files: {}, writes: [{ ext: '.epub', ok: false, reason: 'no OPF' }] }) + : good(stem, bytes, proposal) + const outcome = await result.current.repair(result.current.state.books, 'downloaded') + expect(outcome.failures).toEqual(['Ada Example - One: .epub no OPF']) + expect([...outcome.outcomes.keys()]).toEqual(['Ada Example - Two']) + expect(downloads.map((d) => d.name)).toEqual(['Ada Example - Two.epub']) + }) + + it('downloads no zip when every book failed to write', async () => { + const names = ['One', 'Two', 'Three'].flatMap((n) => [ + `lib/Ada Example - ${n}.epub`, + `lib/Ada Example - ${n}.pdf`, + ]) + const { result } = await run(names.map((n) => file(n))) + script.apply = () => + Promise.resolve({ files: {}, writes: [{ ext: '.epub', ok: false, reason: 'no OPF' }] }) + const outcome = await result.current.repair(result.current.state.books, 'downloaded') + expect(outcome.failures).toHaveLength(3) + expect(outcome.outcomes.size).toBe(0) + expect(downloads).toEqual([]) + }) + + it('is not writable when a file came without a handle', async () => { + const written: Record = {} + const { result } = await run([ + file('Ada Example - One.epub', 'x', fakeHandle(written, 'Ada Example - One.epub')), + file('Ada Example - One.pdf'), + ]) + expect(result.current.writable(result.current.state.books)).toBe(false) + }) +}) From aa8d457f695cfe4f087cb0ec24ff5f538415ea17 Mon Sep 17 00:00:00 2001 From: CrazyFreak <44674613+OffCrazyFreak@users.noreply.github.com> Date: Fri, 11 Sep 2026 23:58:26 +0200 Subject: [PATCH 2/2] test(web): Tighten the wiring tests after review Changes: - Assert the exact init base with a stubbed `BASE_URL` instead of a pattern the page URL also matches - Register the act environment and unmount every hook after its test, since the library does neither without vitest globals - Cover the pause between books, a reset during runtime load, a runtime failure followed by retry, the failed sample's retry, the folder permission prompt, a prompt that is then granted, an apply that throws, the five-file boundary and the sample after a real run - Make the retry test prove the second worker ran a book The first draft could pass with a wrong base URL, left hooks mounted across tests, and never reached the pacing, permission and failure branches. --- web/src/__tests__/client.test.ts | 9 +- web/src/__tests__/use-run.test.ts | 145 ++++++++++++++++++++++++++++-- 2 files changed, 145 insertions(+), 9 deletions(-) diff --git a/web/src/__tests__/client.test.ts b/web/src/__tests__/client.test.ts index 48546bf..c8d5e1b 100644 --- a/web/src/__tests__/client.test.ts +++ b/web/src/__tests__/client.test.ts @@ -48,15 +48,18 @@ beforeEach(() => { afterEach(() => { vi.unstubAllGlobals() + vi.unstubAllEnvs() }) describe('Client', () => { it('starts the worker with the base the page is served from', () => { + vi.stubEnv('BASE_URL', '/eBook-Metamend/') const { worker } = make() expect(worker.posted).toHaveLength(1) - const init = worker.posted[0].message - expect(init.type).toBe('init') - if (init.type === 'init') expect(init.base).toMatch(/^http:\/\/.+\/$/) + expect(worker.posted[0].message).toEqual({ + type: 'init', + base: 'http://localhost:3000/eBook-Metamend/', + }) }) it('resolves ready once the worker says so and forwards the loading events', async () => { diff --git a/web/src/__tests__/use-run.test.ts b/web/src/__tests__/use-run.test.ts index daea4e2..09d0c33 100644 --- a/web/src/__tests__/use-run.test.ts +++ b/web/src/__tests__/use-run.test.ts @@ -1,5 +1,5 @@ // @vitest-environment happy-dom -import { act, renderHook, waitFor } from '@testing-library/react' +import { act, cleanup, renderHook, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { Intake, IntakeFile } from '@/intake' @@ -17,6 +17,7 @@ const { FakeClient, script, FACTS } = vi.hoisted(() => { // What the fake worker answers, set per test. A propose that is never // answered leaves the run mid-book, which is how stop is exercised. const script = { + ready: (_onEvent: (event: Broadcast) => void): Promise => Promise.resolve(), propose: (_stem: string, _files: FileBytes): Promise> => Promise.resolve({ facts: FACTS, proposal: null, pause: 0, unavailable: [] }), apply: ( @@ -38,7 +39,7 @@ const { FakeClient, script, FACTS } = vi.hoisted(() => { this.onEvent = onEvent } ready() { - return Promise.resolve() + return script.ready(this.onEvent) } reset() { this.resets++ @@ -98,7 +99,6 @@ function intake(files: IntakeFile[]): Intake { return { books: files, others: 0, folders: [] } } -// A writable file handle that records what was written to it. function fakeHandle(written: Record, name: string, permission = 'granted') { return { kind: 'file', @@ -119,9 +119,14 @@ function fakeHandle(written: Record, name: string, permissio const downloads: { name: string; blob: Blob }[] = [] +// Without vitest globals the library registers neither its act flag nor its +// unmount, so a hook would outlive its test. +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + beforeEach(() => { FakeClient.instances = [] downloads.length = 0 + script.ready = () => Promise.resolve() script.propose = () => Promise.resolve({ facts: FACTS, proposal: null, pause: 0, unavailable: [] }) script.apply = () => Promise.resolve({ files: {}, writes: [] }) @@ -138,13 +143,16 @@ beforeEach(() => { vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function ( this: HTMLAnchorElement, ) { - downloads[downloads.length - 1].name = this.download + const last = downloads.at(-1) + if (last) last.name = this.download }) }) afterEach(() => { + cleanup() vi.unstubAllGlobals() vi.restoreAllMocks() + vi.useRealTimers() }) describe('useRun start', () => { @@ -233,6 +241,28 @@ describe('useRun start', () => { }) }) +describe('useRun pacing', () => { + it('waits the pause the worker asks for between books, not after the last', async () => { + vi.useFakeTimers() + script.propose = (stem) => + Promise.resolve({ facts: FACTS, proposal: high(stem), pause: 2, unavailable: [] }) + const { result } = renderHook(() => useRun()) + act( + () => + void result.current.start( + intake([file('Ada Example - One.epub'), file('Ada Example - Two.epub')]), + ), + ) + await act(() => vi.advanceTimersByTimeAsync(0)) + expect(result.current.state.books.map((b) => b.status)).toEqual(['done', 'pending']) + await act(() => vi.advanceTimersByTimeAsync(1999)) + expect(result.current.state.books[1].status).toBe('pending') + await act(() => vi.advanceTimersByTimeAsync(1)) + expect(result.current.state.books[1].status).toBe('done') + expect(result.current.state.phase).toBe('done') + }) +}) + describe('useRun stop, reset and retry', () => { it('keeps the verdicts reached, marks the rest skipped and drops the late answer', async () => { const answered: string[] = [] @@ -279,18 +309,65 @@ describe('useRun stop, reset and retry', () => { }) it('retry replaces the worker and runs the same files again', async () => { + let proposed = 0 + script.propose = () => { + proposed++ + return Promise.resolve({ facts: FACTS, proposal: null, pause: 0, unavailable: [] }) + } const { result } = renderHook(() => useRun()) await act(() => result.current.start(intake([file('Ada Example - Sample.epub')]))) await waitFor(() => expect(result.current.state.phase).toBe('done')) await act(async () => { result.current.retry() }) + await waitFor(() => expect(FakeClient.instances[1]?.resets).toBe(1)) await waitFor(() => expect(result.current.state.phase).toBe('done')) - expect(FakeClient.instances).toHaveLength(2) expect(FakeClient.instances[0].terminated).toBe(true) + expect(proposed).toBe(2) expect(result.current.state.books[0].stem).toBe('Ada Example - Sample') }) + it('retry after a runtime failure starts a fresh worker', async () => { + script.ready = (onEvent) => { + onEvent({ type: 'failed', message: 'No wheel' }) + return Promise.reject(new Error('No wheel')) + } + const { result } = renderHook(() => useRun()) + await act(() => result.current.start(intake([file('Ada Example - Sample.epub')]))) + expect(result.current.state.phase).toBe('failed') + expect(result.current.state.error).toBe('No wheel') + script.ready = () => Promise.resolve() + await act(async () => { + result.current.retry() + }) + await waitFor(() => expect(result.current.state.phase).toBe('done')) + expect(FakeClient.instances).toHaveLength(2) + }) + + it('a reset while the runtime loads leaves the late run unstarted', async () => { + let release!: () => void + script.ready = () => new Promise((resolve) => (release = resolve)) + const { result } = renderHook(() => useRun()) + act(() => void result.current.start(intake([file('Ada Example - Sample.epub')]))) + expect(result.current.state.phase).toBe('loading') + act(() => result.current.reset()) + await act(async () => release()) + expect(result.current.state.phase).toBe('idle') + expect(FakeClient.instances[0].resets).toBe(0) + }) + + it('retry of a failed sample replays the sample without a worker', async () => { + vi.useFakeTimers() + const { result } = renderHook(() => useRun({ failLoad: true })) + act(() => result.current.playSample()) + await act(() => vi.advanceTimersByTimeAsync(3000)) + expect(result.current.state.phase).toBe('failed') + act(() => result.current.retry()) + expect(result.current.state.phase).toBe('loading') + expect(result.current.state.sample).toBe(true) + expect(FakeClient.instances).toHaveLength(0) + }) + it('the sample plays without a worker and cannot be written', () => { const { result } = renderHook(() => useRun()) act(() => result.current.playSample()) @@ -351,7 +428,8 @@ describe('useRun repair', () => { expect([...zip.slice(0, 4)]).toEqual([0x50, 0x4b, 0x03, 0x04]) const text = new TextDecoder('latin1').decode(zip) for (const name of names) expect(text).toContain(name) - // Stored, not deflated: the central directory records method 0 for each entry. + // A pin on client-zip: the archive stays stored, as the Python one was. The + // method sits 10 bytes into a central header (signature 4, made by 2, needed 2, flags 2). const central = text.indexOf('PK\x01\x02') expect(central).toBeGreaterThan(0) expect(zip[central + 10]).toBe(0) @@ -410,6 +488,61 @@ describe('useRun repair', () => { expect(downloads).toEqual([]) }) + it('downloads five files loose and only the sixth tips into a zip', async () => { + const names = ['One', 'Two', 'Three', 'Four', 'Five'].map((n) => `Ada Example - ${n}.epub`) + const { result } = await run(names.map((n) => file(n))) + await result.current.repair(result.current.state.books, 'downloaded') + expect(downloads.map((d) => d.name)).toEqual(names) + }) + + it('reports a book whose repair threw and downloads nothing for it', async () => { + const { result } = await run([file('Ada Example - One.epub')]) + script.apply = () => Promise.reject(new Error('boom')) + const outcome = await result.current.repair(result.current.state.books, 'downloaded') + expect(outcome.failures).toEqual(['Ada Example - One: boom']) + expect(outcome.outcomes.size).toBe(0) + expect(downloads).toEqual([]) + }) + + it('asks the folder for permission too and stops when it is refused', async () => { + const written: Record = {} + const hook = await run([ + file('Ada Example - One.epub', 'x', fakeHandle(written, 'Ada Example - One.epub')), + ]) + const folder = fakeHandle({}, 'lib', 'denied') as unknown as FileSystemDirectoryHandle + await act(() => + hook.result.current.start({ + books: [file('Ada Example - One.epub', 'x', fakeHandle(written, 'Ada Example - One.epub'))], + others: 0, + folders: [folder], + }), + ) + await waitFor(() => expect(hook.result.current.state.phase).toBe('done')) + const outcome = await hook.result.current.repair(hook.result.current.state.books, 'written') + expect(outcome.failures).toEqual(['Write access was not granted.']) + expect(written).toEqual({}) + }) + + it('asks for write access only when the handle does not already have it', async () => { + const written: Record = {} + const handle = fakeHandle(written, 'Ada Example - One.epub', 'prompt') + const request = vi.spyOn(handle, 'requestPermission').mockResolvedValue('granted') + const { result } = await run([file('Ada Example - One.epub', 'x', handle)]) + const outcome = await result.current.repair(result.current.state.books, 'written') + expect(request).toHaveBeenCalledTimes(1) + expect(Object.keys(written)).toEqual(['Ada Example - One.epub']) + expect([...outcome.outcomes]).toEqual([['Ada Example - One', 'written']]) + }) + + it('the sample never reaches the worker even after a real run', async () => { + const { result } = await run([file('Ada Example - One.epub')]) + const [row] = result.current.state.books + act(() => result.current.playSample()) + const outcome = await result.current.repair([row], 'downloaded') + expect([...outcome.outcomes]).toEqual([['Ada Example - One', 'downloaded']]) + expect(downloads).toEqual([]) + }) + it('is not writable when a file came without a handle', async () => { const written: Record = {} const { result } = await run([