diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index a2cf3895b..d6cf866c6 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -50,6 +50,7 @@ jobs: - name: Build and push Docker image uses: docker/build-push-action@v7 with: + build-args: BUILD_REVISION=${{ github.sha }} context: . file: ./Dockerfile push: true diff --git a/.github/workflows/nightly-corpus.yml b/.github/workflows/nightly-corpus.yml index f36683d81..bb8f9ace0 100644 --- a/.github/workflows/nightly-corpus.yml +++ b/.github/workflows/nightly-corpus.yml @@ -160,15 +160,16 @@ jobs: retention-days: 14 browsers-shard: - name: Cross-browser (${{ matrix.browser }}) + name: Cross-browser (${{ matrix.browser }}, shard ${{ matrix.shard }}/3) runs-on: ubuntu-latest - timeout-minutes: 90 + timeout-minutes: 60 needs: gate if: needs.gate.outputs.browsers == 'true' strategy: fail-fast: false matrix: browser: [webkit, firefox] + shard: [1, 2, 3] steps: - name: Checkout code @@ -179,33 +180,59 @@ jobs: with: browsers: ${{ matrix.browser }} - # The same PR-time suites (minus the opt-in sweeps) on the two engines - # the gate does not cover. Same-realm/L0 rules apply unchanged. - - name: Run E2E on ${{ matrix.browser }} + # Per-engine measured file weights, assigned with LPT; unknown specs use + # that engine's median. Keep two workers and the existing retry budget. + - name: Run ordinary E2E on ${{ matrix.browser }} env: - E2E_REPORT_DIR: playwright-report-browsers/normal - E2E_RESULTS_DIR: test-results-browsers/normal + E2E_SHARD: ${{ matrix.shard }}/3 + E2E_BROWSER: ${{ matrix.browser }} + E2E_REPORT_DIR: playwright-report-browsers/${{ matrix.browser }}-${{ matrix.shard }}-normal + E2E_RESULTS_DIR: test-results-browsers/${{ matrix.browser }}-${{ matrix.shard }}-normal run: pnpm exec playwright test -c playwright.browsers.config.ts --project=${{ matrix.browser }} --grep-invert "api surface|corpus|@serial" --workers=2 - # The other half of that `--grep-invert`, exactly as ci.yml's e2e job - # pairs them: an @serial case is one that cannot share the run. Without - # this split sw-silent-update.spec.ts rewrites the served sw.js while - # other specs are being served from it, replacing THEIR worker mid-test - # -- which is where sw-warm's "runtime-e2e-next" and cache-first's - # missing sentinel came from on 2026-08-25. Without the second pass - # nothing runs them at all and the job stays green regardless. + - name: Upload report + if: always() + uses: actions/upload-artifact@v7 + with: + name: playwright-report-browsers-${{ matrix.browser }}-shard-${{ matrix.shard }}-normal + path: | + playwright-report-browsers/ + test-results-browsers/ + retention-days: 14 + + browsers-serial: + name: Cross-browser (${{ matrix.browser }}, serial) + runs-on: ubuntu-latest + timeout-minutes: 30 + needs: [gate, browsers-shard] + if: ${{ !cancelled() && needs.gate.outputs.browsers == 'true' }} + strategy: + fail-fast: false + matrix: + browser: [webkit, firefox] + + steps: + - name: Checkout code + uses: actions/checkout@v7 + + - name: Set up the toolchain + uses: ./.github/actions/setup + with: + browsers: ${{ matrix.browser }} + + # Exactly once per engine, on its own runner, even after ordinary failures. + # sw-silent-update rewrites the served SW; budgets also need exclusive CPU. - name: Run the cases that cannot share the run - if: ${{ !cancelled() }} env: - E2E_REPORT_DIR: playwright-report-browsers/serial - E2E_RESULTS_DIR: test-results-browsers/serial + E2E_REPORT_DIR: playwright-report-browsers/${{ matrix.browser }}-serial + E2E_RESULTS_DIR: test-results-browsers/${{ matrix.browser }}-serial run: pnpm exec playwright test -c playwright.browsers.config.ts --project=${{ matrix.browser }} --grep @serial --workers=1 - name: Upload report if: always() uses: actions/upload-artifact@v7 with: - name: playwright-report-browsers-${{ matrix.browser }} + name: playwright-report-browsers-${{ matrix.browser }}-serial path: | playwright-report-browsers/ test-results-browsers/ @@ -215,13 +242,14 @@ jobs: name: Cross-browser (WebKit + Firefox) runs-on: ubuntu-latest timeout-minutes: 5 - needs: [gate, browsers-shard] + needs: [gate, browsers-shard, browsers-serial] if: always() && needs.gate.outputs.browsers == 'true' steps: - name: Report the browser matrix verdict env: RESULT: ${{ needs.browsers-shard.result }} - run: test "$RESULT" = "success" + SERIAL_RESULT: ${{ needs.browsers-serial.result }} + run: test "$RESULT" = "success" && test "$SERIAL_RESULT" = "success" budgets: name: Slow-network budgets diff --git a/.github/workflows/preview-smoke.yml b/.github/workflows/preview-smoke.yml index 6869cbcae..45d36d660 100644 --- a/.github/workflows/preview-smoke.yml +++ b/.github/workflows/preview-smoke.yml @@ -91,6 +91,12 @@ jobs: with: browsers: chromium + - name: Verify the deployed preview identity + env: + PREVIEW_URL: ${{ steps.preview.outputs.url }} + EXPECTED_REVISION: ${{ github.event.pull_request.head.sha }} + run: node bin/wait-for-deploy.mjs "$PREVIEW_URL" "$EXPECTED_REVISION" 120000 2000 + - name: Smoke the preview env: PROD_URL: ${{ steps.preview.outputs.url }} diff --git a/.github/workflows/prod-smoke.yml b/.github/workflows/prod-smoke.yml index c6d0ae6c5..466596bc8 100644 --- a/.github/workflows/prod-smoke.yml +++ b/.github/workflows/prod-smoke.yml @@ -37,39 +37,15 @@ jobs: with: browsers: chromium - # On push: build the same commit and wait until production serves its - # hashed entry asset (Vite hashes are content-derived, so an identical - # build here yields the same asset name as the Pages build). Skipped for - # schedule/dispatch runs, which smoke whatever is live. - # - # The asset to watch is the EDITOR's, taken from editor.html. `/` has - # shipped no bundle since the route split (2026-08-16), so the old - # `assets/index-*.js` grep against `dist/index.html` found nothing -- - # and an empty expectation matched an empty answer on the first poll, - # so this step reported "live after 20s" without waiting for anything. - # Every smoke since has run against whatever happened to be deployed; - # it only surfaced once a case could tell the builds apart. - # - # An empty ASSET is now a hard error rather than a vacuous pass: this - # gate is worthless if it cannot name what it is waiting for. + # The final build stamps its source revision and served-tree digest. This + # covers vendor, SW and static-only commits whose editor JS hash is stable. + # Missing/stale identities and transport errors exhaust a bounded wait and + # fail the job: smoking the previous deployment would be a false green. - name: Wait for the deploy of this commit if: github.event_name == 'push' env: PROD_URL: ${{ github.event.inputs.url || 'https://edit.chaxus.com' }} - run: | - sh ./bin/build.sh >/dev/null 2>&1 - ASSET=$(grep -o 'assets/editor-[A-Za-z0-9_-]*\.js' dist/editor.html | head -1) - if [ -z "$ASSET" ]; then - echo "::error::no editor entry asset in dist/editor.html; the deploy gate cannot tell builds apart" - exit 1 - fi - echo "expecting $ASSET" - for i in $(seq 1 60); do - LIVE=$(curl -s "$PROD_URL/editor" | grep -o 'assets/editor-[A-Za-z0-9_-]*\.js' | head -1) - if [ "$LIVE" = "$ASSET" ]; then echo "live after $((i*20))s"; exit 0; fi - sleep 20 - done - echo "::warning::production still serves $LIVE after 20 min; smoking what is live" + run: node bin/wait-for-deploy.mjs "$PROD_URL" "$GITHUB_SHA" - name: Smoke production env: diff --git a/CHANGELOG.md b/CHANGELOG.md index 193f649cb..46d748d08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,16 @@ notes. Entries describe what users experience, not internal refactors. ### Fixed +- Reloading after a completed save restores the saved document instead of an + older recovery snapshot or the original file. +- Opening a copied editor tab no longer removes the original tab's reload + source. +- Document links preserve encoded path and query characters, including signed + URLs with encoded delimiters. +- Gemini artifact revisions accept normally completed responses while retaining + incomplete-response protection. +- Using a downloaded model from a custom mirror preserves its source and runtime + settings; deleting another source of the same model keeps the active one loaded. - Editing a document after more than seven idle days keeps the new recovery bytes instead of leaving an empty history record. - Turning off recovery, or stopping it after a storage failure, keeps ordinary diff --git a/Dockerfile b/Dockerfile index 63ed619b0..058f4c238 100755 --- a/Dockerfile +++ b/Dockerfile @@ -24,9 +24,14 @@ RUN pnpm install --frozen-lockfile --ignore-scripts # Copy source code COPY . . +# .git is excluded from Docker contexts. CI/self-hosted callers can preserve +# exact source identity with --build-arg BUILD_REVISION=. Archives +# without a revision get an explicit content: marker instead. +ARG BUILD_REVISION + # Build the workspace packages, encode WASM for plain HTTP, then build the app. # Do this before build.sh hashes the vendor tree, so sw.js names the served bytes. -RUN pnpm -r run prepare && node bin/prepare-docker-wasm.mjs public && pnpm run build +RUN pnpm -r run prepare && node bin/prepare-docker-wasm.mjs public && BUILD_REVISION="$BUILD_REVISION" pnpm run build #FROM nginxinc/nginx-unprivileged:stable-alpine #COPY --from=builder /app/dist /usr/share/nginx/html diff --git a/bin/build-identity.mjs b/bin/build-identity.mjs new file mode 100644 index 000000000..ea4f8127e --- /dev/null +++ b/bin/build-identity.mjs @@ -0,0 +1,43 @@ +#!/usr/bin/env node +// Stamp the final served output, after every build transform (including sw.js). +// Git archives/Docker contexts omit .git: pass BUILD_REVISION to preserve source +// identity there. Without source metadata the content identity remains explicit. +import { createHash } from 'node:crypto'; +import { createReadStream } from 'node:fs'; +import { readdir, writeFile } from 'node:fs/promises'; +import { execFileSync } from 'node:child_process'; +import { resolve } from 'node:path'; + +const root = resolve(process.argv[2] || 'dist'); +const files = (await readdir(root, { recursive: true, withFileTypes: true })) + .filter((entry) => entry.isFile()) + .map((entry) => + resolve(entry.parentPath, entry.name) + .slice(root.length + 1) + .replaceAll('\\', '/'), + ) + .filter((name) => name !== 'build-identity.json') + .sort(); +if (!files.length) throw new Error('Cannot identify an empty build'); +const tree = createHash('sha256'); +for (const name of files) { + const hash = createHash('sha256'); + for await (const chunk of createReadStream(resolve(root, name))) hash.update(chunk); + tree.update(name + '\0' + hash.digest('hex') + '\n'); +} +const contentSha256 = tree.digest('hex'); +let revision = process.env.BUILD_REVISION; +if (!revision) { + try { + revision = execFileSync('git', ['rev-parse', 'HEAD'], { + cwd: resolve(import.meta.dirname, '..'), + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + } catch { + revision = process.env.CF_PAGES_COMMIT_SHA || `content:${contentSha256}`; + } +} +if (!/^(?:[a-f0-9]{40,64}|content:[a-f0-9]{64})$/.test(revision)) throw new Error('Invalid build revision'); +await writeFile(resolve(root, 'build-identity.json'), JSON.stringify({ schema: 1, revision, contentSha256 }) + '\n'); +console.log(`Build identity: ${revision} (${contentSha256})`); diff --git a/bin/build.sh b/bin/build.sh index cce7dc335..6681f6c86 100755 --- a/bin/build.sh +++ b/bin/build.sh @@ -173,4 +173,8 @@ else echo "Warning: $DIST_DIR/sw.js not found, skipping version injection." fi +# This must remain the final output transform: identity covers the exact served +# vendor, static pages and stamped worker, not only the editor entry asset. +node bin/build-identity.mjs "$DIST_DIR" + echo "Build completed successfully!" diff --git a/bin/ci-shard-timings.json b/bin/ci-shard-timings.json index db1f15fb5..39473b2e0 100644 --- a/bin/ci-shard-timings.json +++ b/bin/ci-shard-timings.json @@ -161,5 +161,171 @@ "webmcp.spec.ts": 23.391, "xlsx-features.spec.ts": 23.3, "xlsx-panes.spec.ts": 15.1 + }, + "browsersSource": "GitHub Actions run 39109056454, main 052cead0f9e1bcd7def270c85ded139f08c3f07c, 2026-10-18. Sum of passed ordinary test durations per file in seconds; excludes @serial pass, new/skipped specs use per-engine median.", + "webkit": { + "agent-artifact-revision.spec.ts": 25.4, + "agent-cloud-streaming.spec.ts": 45.4, + "agent-composer-ux.spec.ts": 170.5, + "agent-content-review.spec.ts": 74.0, + "agent-context-events.spec.ts": 37.3, + "agent-dictation.spec.ts": 130.8, + "agent-document-references.spec.ts": 23.5, + "agent-editor-focus.spec.ts": 31.4, + "agent-error-recovery.spec.ts": 29.1, + "agent-history-rendering.spec.ts": 61.1, + "agent-imported-formats.spec.ts": 115.6, + "agent-process-feedback.spec.ts": 38.5, + "agent-product-entry.spec.ts": 105.3, + "agent-request-queue.spec.ts": 77.8, + "agent-result-hierarchy.spec.ts": 18.6, + "agent-sidebar-layout.spec.ts": 27.5, + "agent-sources-artifacts.spec.ts": 153.7, + "agent-stop-resume.spec.ts": 59.2, + "agent-task-loop.spec.ts": 102.5, + "agent-workspace-consistency.spec.ts": 105.2, + "agent-workspace.spec.ts": 92.2, + "agent-write-tools.spec.ts": 216.0, + "app-smoke.spec.ts": 21.3, + "assistant-loading-locale.spec.ts": 107.6, + "assistant-offline-assets.spec.ts": 40.8, + "autosave-recovery.spec.ts": 87.3, + "bad-image-url-locale.spec.ts": 15.3, + "comment-bulk-actions.spec.ts": 16.1, + "comments.spec.ts": 43.4, + "csv-encoding.spec.ts": 14.9, + "docx-features.spec.ts": 31.0, + "docx-ruby.spec.ts": 16.0, + "editor-csp.spec.ts": 36.2, + "editor-locales.spec.ts": 72.1, + "embed-api.spec.ts": 21.4, + "embed-dirty-state.spec.ts": 54.5, + "embed-regression.spec.ts": 161.4, + "embed-save-default.spec.ts": 14.6, + "entry-paths.spec.ts": 61.5, + "filename-matrix.spec.ts": 176.8, + "font-cache.spec.ts": 21.7, + "font-picker-scroll.spec.ts": 13.8, + "font-substitution.spec.ts": 107.3, + "format-parity.spec.ts": 84.0, + "history-page.spec.ts": 41.1, + "history-quota.spec.ts": 34.1, + "html-as-xls.spec.ts": 15.1, + "image-insert.spec.ts": 35.9, + "landing-prefetch.spec.ts": 70.9, + "language-menu.spec.ts": 91.4, + "large-document.spec.ts": 25.2, + "main-site.spec.ts": 34.0, + "mobile-overflow.spec.ts": 58.5, + "network-fixture.spec.ts": 1.2, + "odf-formats.spec.ts": 39.0, + "offline-seam.spec.ts": 13.1, + "open-failure.spec.ts": 47.7, + "open-replacement.spec.ts": 21.9, + "open-retry.spec.ts": 28.0, + "open-url-failure.spec.ts": 2.8, + "pdf-agent-contract.spec.ts": 75.6, + "pdf-cjk-export.spec.ts": 25.4, + "pdf-roundtrip.spec.ts": 68.1, + "pdf-route.spec.ts": 6.6, + "plugin-availability.spec.ts": 14.6, + "remote-open.spec.ts": 5.3, + "resave-idempotence.spec.ts": 93.8, + "save-request-ownership.spec.ts": 15.5, + "site-design-consistency.spec.ts": 50.2, + "social-sharing.spec.ts": 1.838, + "sw-vendor-cache-first.spec.ts": 20.0, + "sw-warm.spec.ts": 28.4, + "text-formats.spec.ts": 49.3, + "theme-presentation.spec.ts": 56.3, + "vendor-branding.spec.ts": 75.6, + "visual-roundtrip.spec.ts": 100.0, + "wasm-memory.spec.ts": 47.1, + "webmcp.spec.ts": 32.4, + "xlsx-features.spec.ts": 49.0, + "xlsx-panes.spec.ts": 27.4 + }, + "firefox": { + "agent-artifact-revision.spec.ts": 18.6, + "agent-cloud-streaming.spec.ts": 32.6, + "agent-composer-ux.spec.ts": 136.2, + "agent-content-review.spec.ts": 58.3, + "agent-context-events.spec.ts": 29.8, + "agent-dictation.spec.ts": 104.8, + "agent-document-references.spec.ts": 20.1, + "agent-editor-focus.spec.ts": 23.4, + "agent-error-recovery.spec.ts": 21.7, + "agent-history-rendering.spec.ts": 46.1, + "agent-imported-formats.spec.ts": 80.1, + "agent-process-feedback.spec.ts": 28.5, + "agent-product-entry.spec.ts": 83.4, + "agent-request-queue.spec.ts": 37.0, + "agent-result-hierarchy.spec.ts": 13.9, + "agent-sidebar-layout.spec.ts": 24.1, + "agent-sources-artifacts.spec.ts": 121.6, + "agent-stop-resume.spec.ts": 48.1, + "agent-task-loop.spec.ts": 72.0, + "agent-workspace-consistency.spec.ts": 83.5, + "agent-workspace.spec.ts": 69.2, + "agent-write-tools.spec.ts": 160.2, + "app-smoke.spec.ts": 17.717, + "assistant-loading-locale.spec.ts": 81.9, + "assistant-offline-assets.spec.ts": 30.6, + "autosave-recovery.spec.ts": 67.8, + "bad-image-url-locale.spec.ts": 11.2, + "comment-bulk-actions.spec.ts": 14.3, + "comments.spec.ts": 35.4, + "csv-encoding.spec.ts": 12.0, + "docx-features.spec.ts": 24.3, + "docx-ruby.spec.ts": 12.2, + "editor-csp.spec.ts": 28.3, + "editor-locales.spec.ts": 56.2, + "embed-api.spec.ts": 19.6, + "embed-dirty-state.spec.ts": 44.1, + "embed-regression.spec.ts": 130.8, + "embed-save-default.spec.ts": 10.3, + "entry-paths.spec.ts": 45.6, + "filename-matrix.spec.ts": 141.7, + "font-cache.spec.ts": 19.5, + "font-picker-scroll.spec.ts": 12.4, + "font-substitution.spec.ts": 86.5, + "format-parity.spec.ts": 59.1, + "history-page.spec.ts": 31.4, + "history-quota.spec.ts": 25.9, + "html-as-xls.spec.ts": 12.5, + "image-insert.spec.ts": 30.5, + "landing-prefetch.spec.ts": 48.9, + "language-menu.spec.ts": 64.5, + "large-document.spec.ts": 19.3, + "main-site.spec.ts": 24.6, + "mobile-overflow.spec.ts": 46.9, + "network-fixture.spec.ts": 1.1, + "odf-formats.spec.ts": 37.6, + "offline-seam.spec.ts": 12.637, + "open-failure.spec.ts": 42.9, + "open-replacement.spec.ts": 20.1, + "open-retry.spec.ts": 21.9, + "open-url-failure.spec.ts": 2.5, + "pdf-agent-contract.spec.ts": 61.1, + "pdf-cjk-export.spec.ts": 22.1, + "pdf-roundtrip.spec.ts": 52.7, + "pdf-route.spec.ts": 3.5, + "plugin-availability.spec.ts": 12.4, + "remote-open.spec.ts": 4.9, + "resave-idempotence.spec.ts": 76.6, + "save-request-ownership.spec.ts": 11.8, + "save-to-file.spec.ts": 166.3, + "site-design-consistency.spec.ts": 42.0, + "social-sharing.spec.ts": 1.257, + "sw-vendor-cache-first.spec.ts": 20.2, + "sw-warm.spec.ts": 16.0, + "text-formats.spec.ts": 37.3, + "theme-presentation.spec.ts": 39.1, + "vendor-branding.spec.ts": 61.483, + "visual-roundtrip.spec.ts": 85.7, + "wasm-memory.spec.ts": 42.6, + "webmcp.spec.ts": 30.8, + "xlsx-features.spec.ts": 38.8, + "xlsx-panes.spec.ts": 23.3 } } diff --git a/bin/ci-shards.d.mts b/bin/ci-shards.d.mts index 3da5a2449..380255b9e 100644 --- a/bin/ci-shards.d.mts +++ b/bin/ci-shards.d.mts @@ -1,4 +1,4 @@ import type { PlaywrightTestConfig } from '@playwright/test'; export function balanceTestFiles(files: string[], timings: Record, total: number): string[][]; export function selectShard(shards: string[][], shard: string): string[]; -export function ciTestMatch(suite: 'preview' | 'pages'): Pick; +export function ciTestMatch(suite: 'preview' | 'pages' | 'browsers'): Pick; diff --git a/bin/ci-shards.mjs b/bin/ci-shards.mjs index bc7f17fca..37dd4480c 100644 --- a/bin/ci-shards.mjs +++ b/bin/ci-shards.mjs @@ -34,6 +34,10 @@ export function ciTestMatch(suite) { const files = readdirSync(new URL('../test/e2e/', import.meta.url), { recursive: true }) .filter((file) => file.endsWith('.spec.ts')) .map((file) => file.replaceAll('\\', '/')); + if (suite === 'browsers') { + suite = process.env.E2E_BROWSER; + if (!['webkit', 'firefox'].includes(suite)) throw new Error('Browser shards require E2E_BROWSER=webkit or firefox'); + } const timings = JSON.parse(readFileSync(new URL('./ci-shard-timings.json', import.meta.url), 'utf8')); return { testMatch: selectShard(balanceTestFiles(files, timings[suite], total), shard).map((file) => `**/test/e2e/${file}`), diff --git a/bin/test-e2e-docker.sh b/bin/test-e2e-docker.sh index 0d14c152d..c832bf9f2 100755 --- a/bin/test-e2e-docker.sh +++ b/bin/test-e2e-docker.sh @@ -7,7 +7,7 @@ # documented in CLAUDE.md for preview servers). set -e -docker build -t document:e2e . +docker build --build-arg BUILD_REVISION="$(git rev-parse HEAD)" -t document:e2e . docker rm -f document-e2e-docker >/dev/null 2>&1 || true # Extra arguments are forwarded to Playwright. CI sets E2E_SHARD=N/M; diff --git a/bin/wait-for-deploy.mjs b/bin/wait-for-deploy.mjs new file mode 100644 index 000000000..7d3d363e5 --- /dev/null +++ b/bin/wait-for-deploy.mjs @@ -0,0 +1,46 @@ +#!/usr/bin/env node +// Usage: node bin/wait-for-deploy.mjs SITE EXACT_REVISION [WAIT_MS] [INTERVAL_MS] +// Source SHA is compared exactly; the digest records that host's actual build +// bytes (SW timestamps and Docker's WASM encoding intentionally differ). +import { randomUUID } from 'node:crypto'; +import { setTimeout as delay } from 'node:timers/promises'; + +try { + const [site, revision, budget = '1200000', interval = '20000'] = process.argv.slice(2); + if (!/^[a-f0-9]{40,64}$/.test(revision || '')) throw new Error('Exact source revision is required'); + const waitMs = Number(budget); + const intervalMs = Number(interval); + if (!(waitMs > 0 && Number.isFinite(waitMs) && intervalMs > 0 && Number.isFinite(intervalMs))) + throw new Error('Invalid polling budget'); + const marker = new URL('build-identity.json', site.endsWith('/') ? site : site + '/'); + if (!['http:', 'https:'].includes(marker.protocol)) throw new Error('HTTP site is required'); + const deadline = Date.now() + waitMs; + let reason = 'identity missing'; + while (Date.now() < deadline) { + marker.searchParams.set('deploy', randomUUID()); + try { + const response = await fetch(marker, { + cache: 'no-store', + headers: { 'Cache-Control': 'no-cache, no-store', Pragma: 'no-cache' }, + signal: AbortSignal.timeout(Math.max(1, Math.min(15_000, deadline - Date.now()))), + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const live = await response.json(); + if (live.schema !== 1 || !/^[a-f0-9]{64}$/.test(live.contentSha256 || '')) + throw new Error('Invalid build identity'); + if (live.revision === revision) { + console.log(`Deployment live: ${revision} (${live.contentSha256})`); + process.exit(0); + } + reason = `stale revision ${live.revision}`; + } catch (error) { + reason = error.message; + } + const remaining = deadline - Date.now(); + if (remaining > 0) await delay(Math.min(intervalMs, remaining)); + } + throw new Error(`Deployment timed out: expected ${revision}; ${reason}`); +} catch (error) { + console.error(`::error::Deploy gate failed: ${error.message}`); + process.exit(1); +} diff --git a/docs/explorations/2026-10-18-recovery-source-and-nightly-shards.md b/docs/explorations/2026-10-18-recovery-source-and-nightly-shards.md new file mode 100644 index 000000000..da65bef4a --- /dev/null +++ b/docs/explorations/2026-10-18-recovery-source-and-nightly-shards.md @@ -0,0 +1,99 @@ +# Recovery Sources and Nightly Browser Shards + +## Scope + +Follow-up to PR #281: six confirmed defects plus ordinary WebKit/Firefox +execution sharding. Implementation follows +`docs/superpowers/plans/2026-10-18-recovery-source-and-nightly-shards.md`. + +## Root causes and regression evidence + +| Finding | Cause | Regression | +| ----------------------------------- | ------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Saved content rolls back on reload | Disk completion only changed metadata; recovery still preferred older snapshots/originals | Actual save-stream and IndexedDB recovery, with/no prior snapshot, delayed source persistence, later snapshots and old in-flight recovery exports | +| Copied page invalidates original | Session storage copies the source pointer; replacement/clear deleted the referenced shared record | Independent copied pointer reads and real browser copied-page recovery | +| Nested document URL changes | Query parser already decoded the parameter; startup decoded it again | Real HTTP server received `/folder/report` before repair instead of the encoded path/query for both file/src; six no-retry cases passed across Chromium/WebKit/Firefox after repair | +| Gemini artifact revision fails | Native normal STOP was missing from the artifact success whitelist | Actual Gemini JSON/SSE transport with normal, abnormal and tool-bearing responses | +| Cached mirror changes source | Preset matching, provider reuse, active state and deletion used model ID only | Real cache-settings/catalog and panel integration with same-ID sources, persisted URL/library, switching, active/idle deletion and canonical URLs | +| Production smoke verifies old build | Editor asset hash omitted vendor/SW/static-only changes; timeout continued anyway | Executable final-tree identity generation and real HTTP polling of current/stale/missing/malformed/failed/hanging markers | + +The browser URL regression intentionally serves a valid document for incorrect +paths too. Its request assertion detects the bug without relying on native +document parse failures. The fixed entry preserves the query parser's decoded +value exactly. + +Each loaded page owns a source slot. Repeated saves atomically update that slot, +so they do not accumulate a full file per save; copied/reopened pages detach +into their own slots. Inherited records stay under the existing 24-hour expiry +and explicit deletion policy. A dormant copied pointer can observe a newer +saved version of the same document before it opens; no frozen duplication-time +snapshot or reference-counting store is introduced. + +Recovery selection binds a saved revision boundary and the history row's +creation identity to the exact source bytes. This protects snapshots recreated +after quota eviction. Provenance follows the actual recovered File through a +WeakMap instead of trusting potentially stale copied sessionStorage metadata. +Missing/expired slots can be replaced on the next save; deletion during an +in-flight write still prevents resurrection. + +Disk completion waits for source persistence before clearing dirty, then checks +the SDK save point again. Later typing preserves the saved source while keeping +dirty set, and later snapshots outrank that source. Key-only existence checks +avoid deserializing the previous full payload twice per save. Their regression +rejects payload reads while requiring three successful replacements, latest +recovered bytes and exactly one retained record. + +## Deployment identity + +The final production build writes `build-identity.json` after all transforms, +including the service worker stamp. It records source revision and a digest of +the served tree. Docker passes the source revision explicitly because its +context excludes Git metadata; unidentified archives use an explicit content +identity rather than pretending to be a particular commit. + +Push smoke polls the exact source revision with a unique nonce, no-store request +and bounded transfer/deadline. It fails if the target deploy never arrives. +Pages, Docker and preview serve the marker with no-store; the worker bypasses +it. Different hosts may encode WASM differently and build timestamps differ, so +the digest records actual hosted bytes and is not compared across builds. + +## Nightly browser selection + +The previous green ordinary passes took 30m03s on Firefox and 37m15s on WebKit, +while installation was under one minute. File durations from that run now feed +three LPT shards per engine. Unknown specs receive the engine median. Workers +remain two, retry count remains one, and serial tests run once per engine on +separate runners even after an ordinary failure. Artifacts identify engine, +shard and pass. The aggregate requires both matrices to succeed; corpus cadence +and manual defaults are unchanged. + +Executable CLI discovery checks every shard against its unsharded engine suite, +requiring exact union, no overlaps and no serial leakage. Aggregate shell logic +is exercised across all 16 ordinary/serial verdict combinations. + +## Validation ledger + +- URL entry: both regressions failed on actual incorrect HTTP paths before the + fix; 2 Chromium and 4 WebKit/Firefox cases passed without retries afterward. +- AI/model scope: 319 focused tests, scoped TypeScript/lint/format passed. +- CI/deploy scope: 210 focused tests, scoped lint passed; independent review + approved source identity, host policy, partitioning and failure propagation. +- Storage scope: 192 focused tests passed after review fixes; the final key-only + optimization passed 68 tests across its three affected files. Independent + whole-branch review approved the final source and ownership contracts. +- Production build passed; its actual identity matched the current commit and + its HTTP response had `Cache-Control: no-store`. Preview smoke now verifies + the deployed PR head's identity before running browser tests. +- Chromium: all 6 new/strengthened save, reload, copied-page and URL cases passed + without retries against the production build. +- Full unit coverage: 253 files / 6813 tests passed with two local workers; + statements 87.63%, branches 83.72%, functions 88.52%, lines 90.18%; configured + thresholds passed. An earlier unbounded local run was interrupted after + unrelated timeouts during severe host contention; no test timeouts changed. +- WebKit/Firefox: 11 save/reload/copied-page/URL cases passed without retries; + WebKit retained the existing writable-OPFS capability skip for the held + recovery/manual-save case. Together with Chromium: 17 passed, 1 existing + capability skip. +- Production build, formatting including 1511 immutable evaluation artifacts, + TypeScript/lint and Compose validation passed. Final pre-push freshness and + latest-head remote acceptance are recorded in the PR's verification section. diff --git a/docs/superpowers/plans/2026-10-18-recovery-source-and-nightly-shards.md b/docs/superpowers/plans/2026-10-18-recovery-source-and-nightly-shards.md new file mode 100644 index 000000000..984545222 --- /dev/null +++ b/docs/superpowers/plans/2026-10-18-recovery-source-and-nightly-shards.md @@ -0,0 +1,80 @@ +# Recovery Sources and Nightly Shards Implementation Plan + +**Goal:** Fix the six confirmed follow-up defects and shorten nightly cross-browser feedback, with regressions, separate commits, a PR and green CI. + +**Architecture:** Keep recovery bytes in the existing history/source owners, preserve complete model-source identity and provider completion semantics, and make CI identify exact deployed builds. Split ordinary browser suites across isolated runners while keeping serial cases exclusive. + +**Tech stack:** TypeScript, IndexedDB, Vite, Vitest and Playwright. + +**Authorization:** The user explicitly requested all six findings, nightly sharding, regression tests, multiple commits, push, PR and CI follow-up. Execute within these boundaries without additional approval steps. + +## Assumptions and scope lock + +| Fact | Evidence | +| ------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | +| Disk save does not update restored bytes | Actual store/recovery probe returned older snapshot and original bytes; `lib/history/index.ts` only calls `markSavedToDisk` | +| A copied page can invalidate the original source | Actual Chromium tabs inherited the same pointer; `rememberActiveSource` deleted its record | +| Startup decodes nested document URLs twice | Actual HTTP probe changed encoded slash, ampersand and hash into separators | +| Gemini normal STOP is rejected by artifact revision | Actual Gemini JSON/SSE provider probes both ended with `incomplete` | +| Cached custom sources sharing preset IDs lose their URL | Actual model-cache settings UI probe cleared source controls | +| Production identity compares only editor JS | `prod-smoke.yml` compares content-derived editor asset and succeeds after timeout | +| Browser execution dominates nightly time | Latest green run: Firefox ordinary pass 30m03s, WebKit 37m15s; installation under one minute | + +Allowed: existing history/source/save owners and their tests; `index.ts` and startup regressions; artifact revision and model cache settings/identity consumers and tests; build/deploy identity scripts, host cache policy and CI workflows; browser shard selection/timing/artifact helpers and tests; exploration/changelog documentation. + +Untouched: vendor engine/fonts, model weights, provider destinations/defaults, embed message shapes, storage retention periods, corpus cadence, context limits, existing retry counts and L0 policy. No paid provider calls. No merge. + +## Tasks + +1. **Saved bytes and copied tabs** — update the existing source/history/save completion path so successful current saves can be restored ahead of older snapshots, while newer edits/snapshots remain authoritative. Preserve source records other pages can still reference. Test old/no snapshot, later unsaved revision, cross-document/cancellation/overlapping saves, denied storage and copied-page ownership. Add real-browser save/reload and copied-tab regressions. +2. **Exact URL opening** — remove the second decoding in `index.ts`; exercise startup with real nested URLs containing `%2F`, `%26`, `%23`, literal percent, Unicode and normal queries. Verify real server request paths rather than source regexes. +3. **Gemini artifact completion** — use compatible completion validation in `artifact-workflow.ts`; exercise actual Gemini JSON/SSE normal STOP and abnormal terminations, preserving rejection of tools and partial output. +4. **Model source identity** — select/use/delete caches by complete resolved source identity in `model-cache-settings.ts` and existing source helpers if necessary. Preserve URLs for custom sources sharing preset IDs; test current/mirror identity and deletion without unloading a different source. +5. **Exact production deployment** — generate a build-owned deployment identity covering the repository revision and relevant served content; production polling compares that identity, explicitly failing a bounded timeout. Serve the marker fresh on supported hosts. Execute scripts against local HTTP fixtures for matching/stale/missing/failed responses and verify marker generation on real builds. +6. **Nightly shards** — use measured per-engine file timings to split ordinary WebKit/Firefox cases, with deterministic median fallback. Run serial cases separately once per engine even after ordinary failures, unique artifacts per engine/shard/pass, and unchanged aggregate check/cadence/retry/worker policy. Test complete/disjoint CLI selection and failure propagation; dispatch the full browser/budget workflow on final head. + +For every task: write and observe a failing regression, implement the smallest fix, run focused checks, independently review, then commit separately. The root agent alone stages/commits/pushes; parallel workers own disjoint file sets and do not run builds or browser servers concurrently. + +## Acceptance and verification + +- All six reported triggers are protected by behavior regressions, including real persistence and provider parsing rather than only mocks. +- No stale async save or source cleanup overwrites a newer document/revision or another page's valid source; storage failure retains prior recovery. +- Production smoke cannot pass an old or missing deployment marker; deployment identity responds without stale worker/HTTP cache reuse. +- Ordinary shards are complete/disjoint for each engine; serial runs exactly once per engine, survives prior ordinary failure and cannot overwrite ordinary reports. +- Full formatting, lint/TypeScript, unit coverage and production build pass; focused Chromium/WebKit/Firefox regressions pass. +- Multiple commits pushed, PR attached, required main/preview checks and explicitly dispatched full nightly browser/budget checks pass on the latest head. + +## Simplest alternatives + +- Reusing original bytes or skipping old snapshots cannot restore the last disk save; persist the actual successful saved version through existing owners. +- Deleting a copied pointer's old record is unsafe; retain shared records under the existing expiry/deletion policy instead of introducing reference counting. +- Preserve the already-decoded query value rather than inventing another URL codec. +- Share normalized completion semantics instead of maintaining divergent provider-specific success lists. +- Compare full model source identity rather than model ID alone. +- Use a fresh build identity instead of a partial asset hash; fail deployment timeout rather than reporting an old site as verified. +- Split only ordinary browser execution; increasing workers/retries would change load or hide failures. + +## Execution ledger + +- Baseline: main `db4ddcd8`, clean checkout, 251 files / 6763 tests passed before work; previous PR fully green before merge. +- URL: RED observed for file/src HTTP paths; GREEN 2 Chromium + 4 WebKit/Firefox cases, no retries. Committed as `ae350c3d`. +- AI/model: 319 focused tests and scoped types/lint passed; independent scope review approved. +- CI/deployment: 210 focused tests and scoped lint passed; independent scope review approved. +- Recovery/source: 192 focused tests passed after review fixes; the final key-only change passed 68 tests in its three affected files. Independent whole-branch review approved. +- Full coverage: 253 files / 6813 tests passed with two local workers, all coverage thresholds met. An earlier unbounded run was interrupted after unrelated timeouts under severe host contention. +- Production browser regressions: 17 passed / 1 existing WebKit writable-OPFS capability skip across Chromium/WebKit/Firefox, no retries. Production identity and no-store HTTP response verified. +- Ruling: reuse the current clean checkout on a feature branch, with disjoint worker file ownership and root-only Git mutations/builds. +- Ruling: exact source SHA identifies deployment; served-tree digest records each host's actual bytes, since SW timestamps and Docker encoding differ across builds. +- Ruling: source-save completion must await browser persistence before clearing dirty, while storage denial keeps existing successful-disk-save semantics. +- Ruling: a page's saves reuse its runtime-owned slot; copies/reloads receive their own slot and inherited records retain the existing expiry policy. Dormant inherited pointers may see later saved bytes of the same document before opening. +- Ruling: saved precedence includes row creation identity and actual File provenance; key-only guards avoid cloning prior payloads and retain atomic deletion checks. + +### PR delivery phases + +1. Branch and separate commits: complete through implementation. +2. Full preflight quality/build/browser verification: passed; repeat freshness checks before push. +3. Independent final review: approved; English report accompanies the PR. +4. Resolve actionable findings: complete. +5. Conventional English PR title: set at PR creation. +6. Push/create PR/attach and follow latest-head CI: subsequent delivery; exact runs recorded in the PR. +7. Final checks, temporary report cleanup and result: recorded in the final delivery response. diff --git a/index.ts b/index.ts index 18bca1c1a..4f2771c6a 100644 --- a/index.ts +++ b/index.ts @@ -178,15 +178,9 @@ void (async () => { } if (documentUrl) { - let decodedUrl = documentUrl; - try { - decodedUrl = decodeURIComponent(documentUrl); - } catch (error) { - console.warn('Failed to decode URL, using original:', error); - } - // The open path already reports failures to the reader. Only decoding - // errors use the original URL; a failed download must not be retried here. - await openDocumentFromUrl(decodedUrl, undefined, { + // Query parsing already decoded the outer parameter. Encoded delimiters + // inside the remote URL belong to that server's path/query and must survive. + await openDocumentFromUrl(documentUrl, undefined, { readonly: isReadonly, docId: savedParam || undefined, }).catch(() => {}); diff --git a/lib/active-document-source.ts b/lib/active-document-source.ts index 50722f878..2dc67a103 100644 --- a/lib/active-document-source.ts +++ b/lib/active-document-source.ts @@ -1,10 +1,17 @@ -/** Original local bytes for this tab's reload, separate from document history. */ +/** Reload bytes for this tab, separate from document history. */ const DB = 'document-active-sources'; const STORE = 'sources'; const POINTER = 'document-active-source'; const LIFETIME = 24 * 60 * 60 * 1000; let revision = 0; -type Pointer = { docId: string; key: string }; +type Pointer = { docId: string; key: string; savedThroughRevision?: number; historyCreatedAt?: number }; +// Heap ownership is never inherited by a duplicate tab. Each open takes a new +// slot; only subsequent disk saves in this runtime may replace that slot. +let activeSlot: Pointer | null = null; +type SourceProvenance = Pick; +// Recovery hands this exact File to the ordinary open path. Its provenance +// comes from the stored bytes, never from a copied tab's stale pointer fields. +const recoveredFiles = new WeakMap(); type Source = Pointer & { expiresAt: number; name: string; @@ -86,6 +93,11 @@ const get = (key: string) => const request = store.get(key); request.onsuccess = () => done(request.result ?? null); }); +const hasSource = (key: string) => + transaction('readonly', (store, done) => { + const request = store.getKey(key); + request.onsuccess = () => done(request.result !== undefined); + }); const put = (source: Source) => transaction('readwrite', (store, done) => { store.put(source); @@ -95,11 +107,11 @@ const put = (source: Source) => // Candidates are registered before reading bytes, then updated in place. // Deletion by another tab removes that candidate and prevents late writes // from recreating it. -const replace = (source: Source) => +const replace = (source: Source, accepts: () => boolean = () => true) => transaction('readwrite', (store, done) => { - const request = store.get(source.key); + const request = store.getKey(source.key); request.onsuccess = () => { - if (!request.result) { + if (request.result === undefined || !accepts()) { done(false); return; } @@ -136,51 +148,88 @@ function fileOf(source: Source): File | null { return new File([bytes], source.name, { type: source.type, lastModified: source.lastModified }); } -export async function rememberActiveSource(docId: string, file: File): Promise { +export async function rememberActiveSource( + docId: string, + file: File, + options: { savedThroughRevision?: number; historyCreatedAt?: number; isCurrent?: () => boolean } = {}, +): Promise { const attempt = ++revision; - if (!docId) return false; + if (!docId || options.isCurrent?.() === false) return false; let candidateKey: string | undefined; try { // Storage denial must be discovered before persisting orphaned bytes. const previous = pointer(); const before = sessionStorage.getItem(POINTER); sessionStorage.setItem(POINTER, before || 'null'); + const isCurrent = () => attempt === revision && options.isCurrent?.() !== false; + const ownedSlot = + options.savedThroughRevision !== undefined && activeSlot?.docId === docId && activeSlot.key === previous?.key; + // Expiry or explicit deletion by another page can retire an idle slot. + // Only absence observed before this write starts permits a fresh candidate; + // deletion while preparing the replacement must still cancel the write. + const existingSlot = ownedSlot ? await hasSource(activeSlot!.key) : false; + if (!isCurrent()) return false; + const reuseSlot = existingSlot === true; + const provenance = options.savedThroughRevision !== undefined ? options : recoveredFiles.get(file); const source: Source = { docId, - key: crypto.randomUUID(), + key: reuseSlot ? activeSlot!.key : crypto.randomUUID(), expiresAt: Date.now() + LIFETIME, name: file.name, type: file.type, lastModified: file.lastModified, file, + savedThroughRevision: provenance?.savedThroughRevision, + historyCreatedAt: provenance?.historyCreatedAt, }; - candidateKey = source.key; - const candidate = { ...source }; - delete candidate.file; - if (!(await put(candidate))) return false; - // Register before cleanup yields: a deletion during that cleanup must - // remove our candidate too, rather than precede its later creation. - await prune(); - const reference = (await replace(source)) ? await get(source.key) : null; - if (!(reference?.file instanceof File)) { + if (reuseSlot) { + // Prepare the complete replacement before opening a write transaction. + // A failed file read or transaction leaves the previous usable bytes + // intact, and deleting the slot while reading still prevents resurrection. delete source.file; source.bytes = new Uint8Array(await file.arrayBuffer()); - if (!(await replace(source))) { - await remove(source.key); - return false; + if (!isCurrent() || !(await replace(source, isCurrent))) return false; + await prune(); + } else { + candidateKey = source.key; + const candidate = { ...source }; + delete candidate.file; + if (!(await put(candidate))) return false; + // Register before cleanup yields: a deletion during that cleanup must + // remove our candidate too, rather than precede its later creation. + await prune(); + const reference = (await replace(source)) ? await get(source.key) : null; + if (!(reference?.file instanceof File)) { + delete source.file; + source.bytes = new Uint8Array(await file.arrayBuffer()); + if (!(await replace(source, isCurrent))) { + await remove(source.key); + return false; + } } } - if (attempt !== revision) { - await remove(source.key); + if (!isCurrent()) { + if (candidateKey) await remove(candidateKey); return false; } try { - sessionStorage.setItem(POINTER, JSON.stringify({ docId, key: source.key })); + sessionStorage.setItem( + POINTER, + JSON.stringify({ + docId, + key: source.key, + savedThroughRevision: source.savedThroughRevision, + historyCreatedAt: source.historyCreatedAt, + }), + ); } catch { - await remove(source.key); + if (candidateKey) await remove(candidateKey); return false; } - if (previous && previous.key !== source.key) await remove(previous.key); + activeSlot = { docId, key: source.key }; + // Duplicate tabs inherit sessionStorage, including the previous key. + // Detach this pointer only; expiry and explicit history deletion collect + // records after every tab has had the chance to keep using its own copy. return true; } catch { if (candidateKey) await remove(candidateKey); @@ -188,7 +237,9 @@ export async function rememberActiveSource(docId: string, file: File): Promise { +export async function readActiveSourceRecord( + docId: string, +): Promise<{ file: File; savedThroughRevision?: number; historyCreatedAt?: number } | null> { const attempt = revision; try { await prune(); @@ -218,27 +269,34 @@ export async function readActiveSource(docId: string): Promise { }; }); if (alive === false || revision !== attempt || pointer()?.key !== owned.key) return null; - return file; + const provenance = { savedThroughRevision: source.savedThroughRevision, historyCreatedAt: source.historyCreatedAt }; + recoveredFiles.set(file, provenance); + return { file, ...provenance }; } catch { return null; } } +export async function readActiveSource(docId: string): Promise { + return (await readActiveSourceRecord(docId))?.file ?? null; +} + export async function clearActiveSource(): Promise { revision++; - const owned = pointer(); + activeSlot = null; try { sessionStorage.removeItem(POINTER); } catch { /* best effort */ } - if (owned) await remove(owned.key); + // Another tab may still own the inherited pointer. await prune(); } /** Explicit history deletion also forgets temporary originals in other tabs. */ export async function deleteDocumentSources(docId?: string): Promise { revision++; + if (docId === undefined || activeSlot?.docId === docId) activeSlot = null; const owned = pointer(); if (docId === undefined || owned?.docId === docId) { try { diff --git a/lib/agent-plugin/ui/artifact-workflow.ts b/lib/agent-plugin/ui/artifact-workflow.ts index 3368100d3..0c82ff034 100644 --- a/lib/agent-plugin/ui/artifact-workflow.ts +++ b/lib/agent-plugin/ui/artifact-workflow.ts @@ -1,5 +1,6 @@ import { validatePayload, payloadText } from '../artifacts/payload'; import type { LLMProvider } from '@ranuts/agent-core/llm/types'; +import { isCompletedResponse } from '@ranuts/agent-core/llm/completion'; import { replaceArtifactRange, type ArtifactDraftBinding } from '../artifacts/edit-proposal'; import { textDifference } from './change-review'; export interface ArtifactWorkflowLabels { @@ -97,7 +98,7 @@ export async function runArtifactRevision(options: { if ( result.toolCalls.length || (Array.isArray(result.assistant.content) && result.assistant.content.some((block) => block.type === 'tool_use')) || - !['end_turn', 'stop', 'eos', 'completed'].includes(result.stopReason) + !isCompletedResponse(result) ) throw new ArtifactRevisionError('incomplete'); if (!result.text.trim()) throw new ArtifactRevisionError('empty'); diff --git a/lib/agent-plugin/ui/model-cache-settings.ts b/lib/agent-plugin/ui/model-cache-settings.ts index 167ec624f..21f0db459 100644 --- a/lib/agent-plugin/ui/model-cache-settings.ts +++ b/lib/agent-plugin/ui/model-cache-settings.ts @@ -2,6 +2,7 @@ import { Div, Span, View } from 'ranui/builder'; import { t } from '@ranuts/shared/i18n'; import { LocalInferenceProvider } from '@ranuts/agent-core/llm/local'; import type { LocalLLMProvider } from '@ranuts/agent-core/llm/types'; +import { modelSourceIdentity, type ModelSelection } from '@ranuts/agent-core/llm/model-source'; import { isModelCached, WEBLLM_MODELS, RETIRED_WEBLLM_MODELS } from '@ranuts/agent-core/llm/webllm'; import { deleteCachedModel, @@ -24,7 +25,7 @@ export function createModelCacheSettings(options: { localModelLib: ValueEl; ggufUrl: ValueEl; note: HTMLElement; - selectedLocalModel: () => string; + getLoadedModel: () => ModelSelection | undefined; currentProvider: () => string; getProvider: () => LocalLLMProvider | null; isBusy: () => boolean; @@ -43,7 +44,7 @@ export function createModelCacheSettings(options: { localModelLib, ggufUrl, note, - selectedLocalModel, + getLoadedModel, currentProvider, getProvider, isBusy, @@ -63,16 +64,22 @@ export function createModelCacheSettings(options: { const cacheStatus = Span().attr('role', 'status').build(); cacheList.append(cacheSummary, cacheRows, cacheStatus); let cacheRevision = 0; + const identity = (model: ModelSelection) => modelSourceIdentity(model.id, model); + const isLoadedSource = (model: ModelSelection) => { + const loaded = getLoadedModel(); + const provider = getProvider(); + return ( + !!loaded && + identity(loaded) === identity(model) && + !(provider instanceof LocalInferenceProvider && provider.backend === 'wllama') + ); + }; + const isActive = (model: ModelSelection) => !!getProvider()?.isReady() && isLoadedSource(model); const refreshCache = async () => { const revision = ++cacheRevision; const sources = [...WEBLLM_MODELS, ...RETIRED_WEBLLM_MODELS, ...rememberedModelSources()]; const unique = sources.filter( - (model, index) => - sources.findIndex( - (item) => - item.id === model.id && - ('modelUrl' in item ? item.modelUrl : undefined) === ('modelUrl' in model ? model.modelUrl : undefined), - ) === index, + (model, index) => sources.findIndex((item) => identity(item) === identity(model)) === index, ); const cached = await Promise.all( unique.map(async (model) => ({ model, cached: await isModelCached(model.id, 'modelUrl' in model ? model : {}) })), @@ -93,21 +100,17 @@ export function createModelCacheSettings(options: { for (const { model, cached: exists } of cached) { if (!exists) continue; const row = Div().class('agent-model-cache-row').attr('role', 'group').build(); - const provider = getProvider(); - const active = - !!provider?.isReady() && - selectedLocalModel() === model.id && - !(provider instanceof LocalInferenceProvider && provider.backend === 'wllama'); + const active = isActive(model); const name = Span() .text(model.label + (active ? ' · ' + t('agentModelInUse') : '')) .build(); const use = ranButton(t(active ? 'agentModelInUse' : 'agentUseModel'), 'agent-cache-use'); use.toggleAttribute('disabled', active); use.addEventListener('click', () => { - if (isBusy() || active) return; + if (isBusy() || isActive(model)) return; providerSelect.value = 'webllm'; providerSelect.dispatchEvent(new Event('change')); - if (WEBLLM_MODELS.some((item) => item.id === model.id)) { + if (WEBLLM_MODELS.some((item) => identity(item) === identity(model))) { localModelId.value = ''; localModelUrl.value = ''; localModelLib.value = ''; @@ -118,7 +121,7 @@ export function createModelCacheSettings(options: { localModelId.value = model.id; localModelUrl.value = 'modelUrl' in model ? String(model.modelUrl ?? '') : ''; localModelLib.value = 'modelLibUrl' in model ? String(model.modelLibUrl ?? '') : ''; - localModelId.dispatchEvent(new Event('change')); + for (const input of [localModelId, localModelUrl, localModelLib]) input.dispatchEvent(new Event('change')); } void loadModel(); }); @@ -146,7 +149,7 @@ export function createModelCacheSettings(options: { setBusy(true); chat.refreshSendAvailability(); try { - if (selectedLocalModel() === model.id) { + if (isLoadedSource(model)) { resetController(); await cleanup(); if (cleanupFailed()) throw new Error(t('agentModelCleanupFailed')); @@ -166,7 +169,7 @@ export function createModelCacheSettings(options: { }); row.setAttribute('aria-label', name.textContent ?? ''); row.append(name); - if (!RETIRED_WEBLLM_MODELS.some((item) => item.id === model.id)) row.append(use); + if (!RETIRED_WEBLLM_MODELS.some((item) => identity(item) === identity(model))) row.append(use); row.append(remove, cancel); cacheRows.append(row); } diff --git a/lib/agent-plugin/ui/panel.ts b/lib/agent-plugin/ui/panel.ts index 08eeb09fa..c4605b468 100644 --- a/lib/agent-plugin/ui/panel.ts +++ b/lib/agent-plugin/ui/panel.ts @@ -50,6 +50,7 @@ import type { LLMMessage } from '@ranuts/agent-core/llm/types'; import { DEFAULT_CPU_MODEL_URL, LocalInferenceProvider } from '@ranuts/agent-core/llm/local'; import { isModelCached, WEBLLM_MODELS } from '@ranuts/agent-core/llm/webllm'; import type { LocalLLMProvider } from '@ranuts/agent-core/llm/types'; +import { modelSourceIdentity, type ModelSelection } from '@ranuts/agent-core/llm/model-source'; import { resolveTaskModel, type ModelTask } from '@ranuts/agent-core/llm/task-model'; import type { WritingRoute } from '@ranuts/agent-core/llm/writing-route'; import { DEFAULT_LOOPBACK_ENDPOINT, createEndpointProvider } from '@ranuts/agent-core/llm/endpoint'; @@ -108,7 +109,7 @@ import { createRequestRouting } from './request-routing'; import { createPanelWorkspace, type PanelWorkspaceView } from './panel-workspace'; import { createPanelSettingsView } from './settings-view'; import { generateDocumentDraft } from './document-draft'; -import { listCachedGGUF, rememberModelSource } from '@ranuts/agent-core/llm/model-cache'; +import { listCachedGGUF, rememberModelSource, rememberedModelSources } from '@ranuts/agent-core/llm/model-cache'; function compactSelect( className: string, @@ -992,6 +993,7 @@ export function createAgentPanel( let controller: AgentChatController | null = null; let controllerKind = ''; let localProviderKey = ''; + let localProviderModel: ModelSelection | undefined; let controllerGeneration = 0; let conversationRevision = 0; let loadAttempt = 0; @@ -1293,6 +1295,7 @@ export function createAgentPanel( const previous = webllmProvider; webllmProvider = null; localProviderKey = ''; + localProviderModel = undefined; if (previous) { const cleanup = previous.dispose(); runtimeCleanup = cleanup; @@ -1486,19 +1489,22 @@ export function createAgentPanel( return controller; } if (id === 'webllm') { - const kind = `webllm:${selectedLocalModel()}`; + const model = { id: selectedLocalModel(), ...localSource() }; + const kind = `webllm:${modelSourceIdentity(model.id, model)}`; if (!controller || controllerKind !== kind) { // Local writing candidates use text generation; the app owns any future // preview/apply workflow instead of exposing native model tools. controller?.dispose(); if (!webllmProvider || localProviderKey !== kind) { localProviderKey = kind; + localProviderModel = model; webllmProvider = new LocalInferenceProvider({ prepareRuntimeAssets: prepareAssistantOffline, generation: generationOptions, webllm: { - model: selectedLocalModel(), - ...localSource(), + model: model.id, + modelUrl: model.modelUrl, + modelLibUrl: model.modelLibUrl, chatOnly: true, onUnavailable: () => { if (generation !== controllerGeneration || modelLoading) return; @@ -1589,12 +1595,16 @@ export function createAgentPanel( hasConfiguration = true; if (currentProvider() === 'webllm' && !localModelId.value.trim()) localStorageSetItem('agent-local-preset', modelSelect.value); - if (currentProvider() === 'webllm') + if (currentProvider() === 'webllm' && localProviderModel) { + const model = localProviderModel; + const remembered = rememberedModelSources().find( + (item) => modelSourceIdentity(item.id, item) === modelSourceIdentity(model.id, model), + ); rememberModelSource({ - id: selectedLocalModel(), - label: WEBLLM_MODELS.find((item) => item.id === selectedLocalModel())?.label ?? selectedLocalModel(), - ...localSource(), + ...model, + label: remembered?.label ?? WEBLLM_MODELS.find((item) => item.id === model.id)?.label ?? model.id, }); + } syncSidebar(); if (currentProvider() === 'webllm' || cacheList.open) void refreshCache(); } @@ -2979,7 +2989,7 @@ export function createAgentPanel( localModelLib, ggufUrl, note, - selectedLocalModel, + getLoadedModel: () => localProviderModel, currentProvider, getProvider: () => webllmProvider, isBusy: () => cacheBusy, diff --git a/lib/history/autosave.ts b/lib/history/autosave.ts index 9326838fe..82df4acee 100644 --- a/lib/history/autosave.ts +++ b/lib/history/autosave.ts @@ -211,11 +211,12 @@ export async function takeSnapshot(): Promise { ) return null; + const storageAbort = active.storageAbort; active.exporting = true; const startedAt = Date.now(); try { const file = await requestSaveDocument(active.ext.toUpperCase()); - if (session !== active || active.stopped) return null; + if (session !== active || active.stopped || storageAbort.signal.aborted) return null; // Let storage check the exported file's size before copying its bytes. // The owner signal also guards budget checks and asynchronous file reads. const doc = await putSnapshot( @@ -225,9 +226,9 @@ export async function takeSnapshot(): Promise { origin: active.origin, bytes: file, }, - active.storageAbort.signal, + storageAbort.signal, ); - if (session !== active || active.stopped) return null; + if (session !== active || active.stopped || storageAbort.signal.aborted) return null; if (!doc) { // The store gave up (no room, and eviction did not help). Stopping and // saying so beats a silent autosave, which is the most dangerous kind: @@ -248,7 +249,7 @@ export async function takeSnapshot(): Promise { active.failures = 0; return doc; } catch (error) { - if (session !== active || active.stopped) return null; + if (session !== active || active.stopped || storageAbort.signal.aborted) return null; const message = error instanceof Error ? error.message : String(error); // A save the user asked for owns the channel; this tick simply waits. if (!message.includes('already in progress')) { @@ -382,6 +383,13 @@ export async function beginAutosaveSession(input: AutosaveSessionInput): Promise void markOpened(input.docId); } +/** A completed disk save supersedes any older recovery export still pending. */ +export function cancelPendingSnapshot(): void { + if (!session) return; + session.storageAbort.abort(); + session.storageAbort = new AbortController(); +} + export function stopAutosaveSession(): void { sessionGeneration += 1; const active = session; diff --git a/lib/history/index.ts b/lib/history/index.ts index dc6a7d277..7a7a7b301 100644 --- a/lib/history/index.ts +++ b/lib/history/index.ts @@ -5,9 +5,10 @@ */ import { setDiskWriter, setSavedToDiskListener } from '../onlyoffice/save-stream'; import { saveToDiskFile } from '../save-target'; -import { getActiveDocumentId } from './autosave'; -import { extensionOf, markSavedToDisk, pruneExpired } from './store'; +import { cancelPendingSnapshot, getActiveDocumentId } from './autosave'; +import { extensionOf, markSavedToDisk, pruneExpired, readDocumentRecord } from './store'; import { getDocmentObj } from '@ranuts/shared/store'; +import { rememberActiveSource } from '../active-document-source'; export function initDocumentHistory(): void { // Expiry has to happen on its own, not when someone thinks to visit the @@ -27,12 +28,26 @@ export function initDocumentHistory(): void { }); }); - setSavedToDiskListener(() => { - // The document now exists on disk, so its history row has nothing left to - // offer back. Without this the recovery bar would keep flagging a document - // the user already saved. + setSavedToDiskListener(async (file, ownsExport) => { const id = getActiveDocumentId(); - if (id) void markSavedToDisk(id); + if (!id) return; + const isCurrent = () => getActiveDocumentId() === id && ownsExport(); + // An old autosave may still be exporting or reading bytes. It must not + // finish after this save and acquire a newer recovery revision. + cancelPendingSnapshot(); + const record = await readDocumentRecord(id); + if (!record || !isCurrent()) return; + const nextRev = record.doc?.nextRev ?? 0; + const historyCreatedAt = record.doc?.createdAt; + const retained = await rememberActiveSource(id, file, { + savedThroughRevision: nextRev, + historyCreatedAt, + isCurrent, + }); + if (!retained || !isCurrent()) return; + // A snapshot made while retaining the source belongs to later edits; + // mark only the history revision actually covered by these saved bytes. + await markSavedToDisk(id, { nextRev, createdAt: historyCreatedAt, isCurrent }); }); } diff --git a/lib/history/recovery.ts b/lib/history/recovery.ts index 8af0b5722..f889bd6ad 100644 --- a/lib/history/recovery.ts +++ b/lib/history/recovery.ts @@ -13,7 +13,7 @@ import { openLocalFile } from '../document'; import { getLatestSnapshot, readDocumentRecord } from './store'; import type { HistoryDoc } from './types'; -import { readActiveSource } from '../active-document-source'; +import { readActiveSourceRecord } from '../active-document-source'; /** A short "5 minutes ago", for the history list's timestamps. */ export function formatRelativeTime(timestamp: number, now = Date.now()): string { @@ -64,9 +64,20 @@ export async function restoreDocument(doc: HistoryDoc, options?: { readonly?: bo export async function restoreSavedDocument(id: string, options?: { readonly?: boolean }): Promise { const record = await readDocumentRecord(id); if (!record) return false; - if (record.doc) return restoreDocument(record.doc, options); - const source = await readActiveSource(id); + const source = await readActiveSourceRecord(id); + // A disk save can be newer than the last autosave. Its revision boundary + // is local to these exact source bytes: another tab's save metadata cannot + // accidentally promote this tab's original over an unsaved snapshot. + if ( + record.doc && + !( + source?.savedThroughRevision !== undefined && + source.savedThroughRevision >= record.doc.nextRev && + source.historyCreatedAt === record.doc.createdAt + ) + ) + return restoreDocument(record.doc, options); if (!source) return false; - await openLocalFile(source, { historyId: id, ...options }); + await openLocalFile(source.file, { historyId: id, ...options }); return true; } diff --git a/lib/history/store.ts b/lib/history/store.ts index 08b15225c..13144dd57 100644 --- a/lib/history/store.ts +++ b/lib/history/store.ts @@ -462,13 +462,17 @@ export async function clearAllHistory(): Promise { } } -async function patchDoc(id: string, patch: Partial): Promise { +async function patchDoc( + id: string, + patch: Partial, + accepts: (doc: HistoryDoc) => boolean = () => true, +): Promise { try { return ( (await withStores([DOCS_STORE], 'readwrite', async (tx) => { const store = tx.objectStore(DOCS_STORE); const doc = (await requestToPromise(store.get(id))) as HistoryDoc | undefined; - if (!doc) return null; + if (!doc || !accepts(doc)) return null; const next = { ...doc, ...patch }; store.put(next); return next; @@ -485,8 +489,18 @@ export function markOpened(id: string): Promise { } /** Bytes reached the user's disk, so this document has nothing left to recover. */ -export function markSavedToDisk(id: string): Promise { - return patchDoc(id, { savedToDiskAt: stamp() }); +export function markSavedToDisk( + id: string, + options: { nextRev?: number; createdAt?: number; isCurrent?: () => boolean } = {}, +): Promise { + return patchDoc( + id, + { savedToDiskAt: stamp() }, + (doc) => + (options.nextRev === undefined || doc.nextRev === options.nextRev) && + (options.createdAt === undefined || doc.createdAt === options.createdAt) && + options.isCurrent?.() !== false, + ); } /** diff --git a/lib/onlyoffice/save-point.ts b/lib/onlyoffice/save-point.ts index 3698f43bc..b9f8690dd 100644 --- a/lib/onlyoffice/save-point.ts +++ b/lib/onlyoffice/save-point.ts @@ -60,11 +60,8 @@ export function captureSavePoint(): SavePoint | null { return { frame, index: history.Index, head, items: Array.isArray(items) ? [...items] : null }; } -/** - * Marks the document saved at `point` if the history is still there. - * Returns true when the SDK now considers the document unmodified. - */ -export function commitSavePoint(point: SavePoint | null): boolean { +/** Whether these serialized bytes still match the current editor history. */ +export function isSavePointCurrent(point: SavePoint | null): boolean { if (!point) return false; try { const history = point.frame.AscCommon?.History; @@ -85,11 +82,21 @@ export function commitSavePoint(point: SavePoint | null): boolean { ) return false; - history.Reset_SavedIndex(true); - api.CheckChangedDocument(); return true; } catch { // The frame was torn down (another document took over) -- nothing to mark. return false; } } + +/** Move the SDK save point only while the serialized document is still current. */ +export function commitSavePoint(point: SavePoint | null): boolean { + if (!isSavePointCurrent(point)) return false; + try { + point!.frame.AscCommon!.History!.Reset_SavedIndex!(true); + point!.frame.Asc!.editor!.CheckChangedDocument!(); + return true; + } catch { + return false; + } +} diff --git a/lib/onlyoffice/save-stream.ts b/lib/onlyoffice/save-stream.ts index 3ba3937b3..fe3d66cd4 100644 --- a/lib/onlyoffice/save-stream.ts +++ b/lib/onlyoffice/save-stream.ts @@ -8,7 +8,7 @@ import { getReadonlyMode } from './readonly'; import { isEmbedMode } from '../embed-mode'; import { markDocumentSaved } from '../unsaved-guard'; import type { SaveOutcome } from '../save-target'; -import { captureSavePoint, commitSavePoint } from './save-point'; +import { captureSavePoint, commitSavePoint, isSavePointCurrent } from './save-point'; /** * Notified when bytes actually reach the user's disk. @@ -17,9 +17,10 @@ import { captureSavePoint, commitSavePoint } from './save-point'; * module, so importing it back would close a cycle. Same shape as * setConverterCallbacks elsewhere in the app. */ -let savedToDiskListener: (() => void) | null = null; +type SavedToDiskListener = (file: File, isCurrent: () => boolean) => void | Promise; +let savedToDiskListener: SavedToDiskListener | null = null; -export function setSavedToDiskListener(listener: (() => void) | null): void { +export function setSavedToDiskListener(listener: SavedToDiskListener | null): void { savedToDiskListener = listener; } @@ -226,17 +227,21 @@ function routeSavedFile(file: File, state: ReturnType)[sourceExt]; + const regularSave = savedExt === sourceExt || savedExt === normalizedExt; - const reachedDisk = (): void => { + const reachedDisk = async (): Promise => { // Reaching the user's disk is the only thing that clears the unsaved-changes // warning: an autosave snapshot lives in this browser, a saved file does not. if (regularSave) writtenSequences.set(state.source, Math.max(writtenSequences.get(state.source) ?? 0, state.sequence)); - if (state.isCurrent() && commitSavePoint(state.point)) { - markDocumentSaved(); - savedToDiskListener?.(); - } + const isCurrent = () => state.isCurrent() && writtenSequences.get(state.source) === state.sequence; + if (regularSave && isCurrent() && isSavePointCurrent(state.point)) await savedToDiskListener?.(file, isCurrent); + // Clearing dirty also makes reload look safe. Retain the saved bytes before + // publishing completion, then recheck for edits made while storage waited. + if (state.isCurrent() && commitSavePoint(state.point)) markDocumentSaved(); }; // Write back to the document's own file where that is possible, and download @@ -252,10 +257,10 @@ function routeSavedFile(file: File, state: ReturnType): boolean { + return ['end_turn', 'stop', 'STOP', 'eos', 'completed'].includes(response.stopReason); +} + /** Normalise provider termination without exposing backend error prose to the host UI. */ export function incompleteResponseReason( response: Pick, diff --git a/packages/agent-core/src/llm/model-cache.ts b/packages/agent-core/src/llm/model-cache.ts index bc3bdffb5..d63e6748d 100644 --- a/packages/agent-core/src/llm/model-cache.ts +++ b/packages/agent-core/src/llm/model-cache.ts @@ -1,4 +1,4 @@ -import { resolveModelArtifactUrl, resolveModelConfig, type ModelSource } from './model-source'; +import { modelSourceIdentity, resolveModelArtifactUrl, resolveModelConfig, type ModelSource } from './model-source'; /** Delete model weights/config only. WASM may be shared by other cached models. */ export async function deleteCachedModel(modelId: string, source: ModelSource = {}): Promise { const { deleteModelInCache, deleteChatConfigInCache, prebuiltAppConfig } = await import('@mlc-ai/web-llm'); @@ -90,7 +90,8 @@ export function rememberedModelSources(): CachedModelSource[] { } export function rememberModelSource(entry: CachedModelSource): void { try { - const entries = rememberedModelSources().filter((item) => item.id !== entry.id || item.modelUrl !== entry.modelUrl); + const identity = modelSourceIdentity(entry.id, entry); + const entries = rememberedModelSources().filter((item) => modelSourceIdentity(item.id, item) !== identity); localStorage.setItem(CATALOG_KEY, JSON.stringify([...entries, entry].slice(-32))); } catch { /* Cache remains usable when preference storage is unavailable. */ diff --git a/packages/agent-core/src/llm/model-source.ts b/packages/agent-core/src/llm/model-source.ts index b9ac1051c..9fa87de1f 100644 --- a/packages/agent-core/src/llm/model-source.ts +++ b/packages/agent-core/src/llm/model-source.ts @@ -5,6 +5,20 @@ export interface ModelSource { modelLibUrl?: string; } +/** The requested source captured when a provider is created, independent of editable settings. */ +export interface ModelSelection extends ModelSource { + id: string; +} + +/** Include runtime overrides: identical weights can require different compatible libraries. */ +export function modelSourceIdentity(modelId: string, source: ModelSource = {}): string { + return JSON.stringify([ + modelId, + source.modelUrl ? resolveModelArtifactUrl(source.modelUrl).replace(/\/$/, '') : '', + source.modelLibUrl ? resolveModelArtifactUrl(source.modelLibUrl) : '', + ]); +} + export class ModelSourceError extends Error { constructor() { super('Model artifacts require HTTP or HTTPS'); diff --git a/playwright.browsers.config.ts b/playwright.browsers.config.ts index e379c738e..1b9d0bcad 100644 --- a/playwright.browsers.config.ts +++ b/playwright.browsers.config.ts @@ -1,3 +1,4 @@ +import { ciTestMatch } from './bin/ci-shards.mjs'; import { playwrightArtifacts } from './bin/ci-artifacts.mjs'; import { defineConfig, devices } from '@playwright/test'; import base from './playwright.config'; @@ -10,6 +11,7 @@ import base from './playwright.config'; */ export default defineConfig({ ...base, + ...ciTestMatch('browsers'), ...playwrightArtifacts('playwright-report-browsers', base.outputDir || 'test-results'), // Nightly signal, not a gate: one retry turns load-dependent one-offs // (Firefox EditingError -25 on a 20k-row sheet under parallel load) into diff --git a/public/_headers b/public/_headers index 0d6135a51..8ee2c0685 100644 --- a/public/_headers +++ b/public/_headers @@ -136,3 +136,7 @@ # PDF extraction module worker must remain executable under strict MIME checks. /assets/pdf.worker.min-*.mjs Content-Type: text/javascript; charset=utf-8 + +# Final build identity is a deploy probe, never an offline asset. +/build-identity.json + Cache-Control: no-store diff --git a/public/sw.js b/public/sw.js index a05b77205..4301696a6 100644 --- a/public/sw.js +++ b/public/sw.js @@ -545,6 +545,9 @@ self.addEventListener('fetch', (event) => { // 2. Only handle same-origin requests to avoid caching external APIs/documents if (url.origin !== self.location.origin) return; + // Deployment probes must never receive an offline or stale cached marker. + if (url.pathname === '/build-identity.json') return; + // API responses are never offline assets. if (url.pathname.startsWith('/api/')) return; diff --git a/sws.toml b/sws.toml index 1c9c49486..71607c698 100644 --- a/sws.toml +++ b/sws.toml @@ -94,3 +94,9 @@ Content-Security-Policy = "default-src 'none'; script-src 'self' 'wasm-unsafe-ev source = "/assets/pdf.worker.min-*.mjs" [advanced.headers.headers] Content-Type = "text/javascript; charset=utf-8" + +# Final served-tree deployment probe (same rule as Pages). +[[advanced.headers]] +source = "/build-identity.json" +[advanced.headers.headers] +Cache-Control = "no-store" diff --git a/test/e2e/autosave-recovery.spec.ts b/test/e2e/autosave-recovery.spec.ts index 48a7fef89..065befe79 100644 --- a/test/e2e/autosave-recovery.spec.ts +++ b/test/e2e/autosave-recovery.spec.ts @@ -3,7 +3,7 @@ import { join } from 'node:path'; import type { Page } from '@playwright/test'; import { readHistoryDocs } from './lib/history-db'; import { buildDocx, ooxmlText, zipEntryText } from './lib/ooxml'; -import { expect, test } from './lib/l0'; +import { expect, test, L0Collector } from './lib/l0'; declare const XLSX: any; /** @@ -186,3 +186,111 @@ test.describe('autosave and recovery (real editor)', () => { expect(await readHistoryDocs(page)).toHaveLength(0); }); }); + +test.describe('saved bytes and copied tab source recovery (real editor)', () => { + test.describe.configure({ timeout: 240_000 }); + + const dirty = (page: Page) => + page.evaluate(() => !window.dispatchEvent(new Event('beforeunload', { cancelable: true }))); + + async function openOriginal(page: Page, autosave: boolean): Promise { + await page.addInitScript(() => { + Object.defineProperty(window, 'showSaveFilePicker', { value: undefined, configurable: true }); + }); + await page.goto('/'); + await page.evaluate((enabled) => localStorage.setItem('document-autosave-enabled', String(enabled)), autosave); + const chooser = page.waitForEvent('filechooser'); + await page.locator('#hero-open').click(); + await ( + await chooser + ).setFiles({ + name: 'SavedSource.docx', + mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + buffer: Buffer.from(buildDocx('original local paragraph')), + }); + await waitForEditorReady(page); + await page + .frameLocator('iframe[name="frameEditor"]') + .locator('#editor_sdk') + .click({ position: { x: 300, y: 200 } }); + } + + async function exportCurrentText(page: Page): Promise { + await page + .frameLocator('iframe[name="frameEditor"]') + .locator('#editor_sdk') + .click({ position: { x: 300, y: 200 } }); + const downloading = page.waitForEvent('download', { timeout: 120_000 }); + await page.keyboard.press('Control+s'); + const bytes = new Uint8Array(readFileSync(await (await downloading).path())); + // This is the ordinary completion signal: reload as soon as the app clears + // its unsaved warning, without polling the source database for hidden work. + await expect.poll(() => dirty(page), { timeout: 30_000 }).toBe(false); + return ooxmlText((await zipEntryText(bytes, 'word/document.xml')) || ''); + } + + for (const previousSnapshot of [false, true]) { + test(`disk save survives immediate reload ${previousSnapshot ? 'after an older snapshot' : 'without any snapshot'}`, async ({ + page, + }) => { + await openOriginal(page, previousSnapshot); + if (previousSnapshot) { + await page.keyboard.press('End'); + await page.keyboard.type(' autosaved A', { delay: 30 }); + await hidePage(page); + await expect.poll(async () => (await readHistoryDocs(page)).length, { timeout: 25_000 }).toBe(1); + await page.evaluate(() => + Object.defineProperty(document, 'visibilityState', { value: 'visible', configurable: true }), + ); + } + await page.keyboard.press('End'); + await page.keyboard.type(' latest disk version B', { delay: 30 }); + expect(await exportCurrentText(page)).toContain('latest disk version B'); + const savedUrl = page.url(); + // The second reload also exercises the ordinary open path remembering + // the recovered File again, which must retain its saved precedence. + for (let reload = 0; reload < 2; reload++) { + await page.reload(); + await waitForEditorReady(page); + expect(page.url()).toBe(savedUrl); + const text = await exportCurrentText(page); + expect(text).toContain('original local paragraph'); + expect(text).toContain('latest disk version B'); + } + expect(await readHistoryDocs(page)).toHaveLength(previousSnapshot ? 1 : 0); + }); + } + + test('a copied tab can reopen and close without deleting the original tab reload source', async ({ + page, + }, testInfo) => { + await openOriginal(page, false); + const savedUrl = page.url(); + const inheritedPointer = await page.evaluate(() => sessionStorage.getItem('document-active-source')); + expect(inheritedPointer).toBeTruthy(); + // A same-origin opener gives the popup a real browser copy of this tab's + // sessionStorage. An independent context.newPage would miss the defect. + const opening = page.waitForEvent('popup'); + await page.evaluate(() => { + window.open('about:blank'); + }); + const copy = await opening; + const copyL0 = new L0Collector(copy); + await copyL0.attach(); + try { + expect(await copy.evaluate(() => sessionStorage.getItem('document-active-source'))).toBe(inheritedPointer); + await copy.goto(savedUrl); + await waitForEditorReady(copy); + expect(await copy.evaluate(() => sessionStorage.getItem('document-active-source'))).not.toBe(inheritedPointer); + expect(await page.evaluate(() => sessionStorage.getItem('document-active-source'))).toBe(inheritedPointer); + await copyL0.assertClean(testInfo); + } finally { + await copy.close(); + } + await page.reload(); + await waitForEditorReady(page); + expect(page.url()).toBe(savedUrl); + expect(await exportCurrentText(page)).toContain('original local paragraph'); + expect(await readHistoryDocs(page)).toHaveLength(0); + }); +}); diff --git a/test/e2e/open-url-encoding.spec.ts b/test/e2e/open-url-encoding.spec.ts new file mode 100644 index 000000000..d09cfa406 --- /dev/null +++ b/test/e2e/open-url-encoding.spec.ts @@ -0,0 +1,37 @@ +import { createServer } from 'node:http'; +import { expect, test } from './lib/l0'; +import { buildDocx } from './lib/ooxml'; +import { waitForEditorReady } from './actions/editor'; + +for (const parameter of ['file', 'src']) { + test(`startup ${parameter} preserves the remote document URL's encoded delimiters`, async ({ page }) => { + test.setTimeout(120_000); + const received: string[] = []; + const bytes = Buffer.from(buildDocx('The exact encoded document URL opened successfully.')); + // Return a valid document even for a wrong URL, so only the request assertion + // catches double decoding; native parse errors cannot accidentally catch it. + const server = createServer((request, response) => { + received.push(request.url!); + response.writeHead(200, { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Expose-Headers': 'Content-Disposition', + 'Content-Type': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'Content-Disposition': 'attachment; filename="Encoded.docx"', + }); + response.end(bytes); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const port = (server.address() as { port: number }).port; + const path = '/folder%2Freport%23part%25%E6%96%87.docx?token=one%26two%3Dthree&literal=%252F&space=one%20two'; + try { + const remote = `http://127.0.0.1:${port}${path}`; + await page.goto(`/editor?${parameter}=${encodeURIComponent(remote)}`); + await expect.poll(() => received.length).toBeGreaterThan(0); + expect(received).toEqual([path]); + expect((await waitForEditorReady(page)).kind).toBe('word'); + } finally { + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + } + }); +} diff --git a/test/e2e/save-to-file.spec.ts b/test/e2e/save-to-file.spec.ts index d3fb81b72..2d06e833b 100644 --- a/test/e2e/save-to-file.spec.ts +++ b/test/e2e/save-to-file.spec.ts @@ -318,11 +318,27 @@ test.describe('saving into the document own file (real editor)', () => { await waitForWrites(page, 1); expect(await savedText(page)).toContain('Recovery version manual version'); } finally { + const returned = page.waitForEvent('console', { + predicate: (message) => message.text().includes('[OO] file stream received:'), + }); await page.evaluate(() => (window as any).__releaseRecovery()); + await returned; } - await expect.poll(async () => (await readHistoryDocs(page)).length).toBe(1); + // Its serialized bytes predate the manual save. Letting that older export + // become the newest revision would undo the disk save on reload. + expect(await readHistoryDocs(page)).toHaveLength(0); + await expect + .poll(() => page.evaluate(() => !window.dispatchEvent(new Event('beforeunload', { cancelable: true })))) + .toBe(false); + await page.reload(); + await waitForEditorReady(page); + const reopened = page.frameLocator('iframe[name="frameEditor"]').locator('#editor_sdk'); + await reopened.click({ position: { x: 300, y: 200 } }); + await page.keyboard.press('Control+s'); + await waitForWrites(page, 1); + expect(await savedText(page)).toContain('Recovery version manual version'); expect(await writesCommitted(page)).toBe(1); - expect(await pickerCalls(page)).toBe(1); + expect(await pickerCalls(page)).toBe(0); }); for (const extension of ['xlsx', 'pptx']) { diff --git a/test/unit/active-document-source.test.ts b/test/unit/active-document-source.test.ts index adb7671c9..43b87a3a0 100644 --- a/test/unit/active-document-source.test.ts +++ b/test/unit/active-document-source.test.ts @@ -14,6 +14,27 @@ afterEach(() => { const sample = () => new File(['original bytes'], '测试 résumé.docx', { type: 'application/test', lastModified: 12345 }); const api = () => import('../../lib/active-document-source'); + +it('updates repeated saved bytes without deserializing the previous source payload', async () => { + const source = await api(); + await source.rememberActiveSource('doc-a', sample()); + const read = IDBObjectStore.prototype.get; + const guard = vi.spyOn(IDBObjectStore.prototype, 'get').mockImplementation(function (this: IDBObjectStore, key) { + if (this.name === 'sources') throw new Error('Previous document bytes must not be cloned during a save'); + return read.call(this, key); + }); + for (let save = 1; save <= 3; save++) { + expect( + await source.rememberActiveSource('doc-a', new File([`version ${save}`], 'Report.docx'), { + savedThroughRevision: 0, + }), + ).toBe(true); + } + guard.mockRestore(); + expect(await (await source.readActiveSource('doc-a'))?.text()).toBe('version 3'); + expect(await retainedCount()).toBe(1); +}); + const retainedCount = () => new Promise((done, fail) => { const req = indexedDB.open('document-active-sources', 1); @@ -57,7 +78,7 @@ describe('active tab document source', () => { await source.rememberActiveSource('doc-b', new File(['new'], 'B.docx')); expect(await source.readActiveSource('doc-a')).toBeNull(); expect(await (await source.readActiveSource('doc-b'))?.text()).toBe('new'); - expect(await retainedCount()).toBe(1); + expect(await retainedCount()).toBe(2); }); it('expires a source after 24 hours without access', async () => { @@ -305,3 +326,100 @@ describe('active tab document source', () => { expect(await retainedCount()).toBe(1); }); }); + +it.each(['replacement', 'clear'])('preserves the original tab source after a copied tab %s', async (action) => { + const source = await api(); + await source.rememberActiveSource('doc-a', sample()); + const inheritedPointer = sessionStorage.getItem('document-active-source')!; + // A duplicate tab receives the same session pointer, then reopens or leaves + // that document. Restore the first tab's independent sessionStorage below. + if (action === 'replacement') await source.rememberActiveSource('doc-a', new File(['copy edits'], 'Copy.docx')); + else await source.clearActiveSource(); + sessionStorage.setItem('document-active-source', inheritedPointer); + expect(await (await source.readActiveSource('doc-a'))?.text()).toBe('original bytes'); +}); + +it('reuses this runtime source slot across disk saves without growing retained full-file copies', async () => { + const source = await api(); + await source.rememberActiveSource('doc-a', sample()); + const initial = sessionStorage.getItem('document-active-source'); + for (let save = 0; save < 10; save++) { + expect( + await source.rememberActiveSource('doc-a', new File([`saved ${save}`], 'Report.docx'), { + savedThroughRevision: 0, + }), + ).toBe(true); + } + expect(await retainedCount()).toBe(1); + expect(JSON.parse(sessionStorage.getItem('document-active-source')!).key).toBe(JSON.parse(initial!).key); + expect(await (await source.readActiveSource('doc-a'))?.text()).toBe('saved 9'); +}); + +it.each(['file read', 'quota', 'transaction abort'])( + 'preserves the previous runtime slot when a disk-save replacement fails at %s', + async (failure) => { + const source = await api(); + await source.rememberActiveSource('doc-a', sample()); + const saved = new File(['saved bytes'], 'Report.docx'); + if (failure === 'file read') vi.spyOn(saved, 'arrayBuffer').mockRejectedValue(new Error('read failed')); + else { + const originalPut = IDBObjectStore.prototype.put; + vi.spyOn(IDBObjectStore.prototype, 'put').mockImplementation(function (this: IDBObjectStore, value, key) { + if (failure === 'quota') throw new DOMException('full', 'QuotaExceededError'); + const request = originalPut.call(this, value, key); + queueMicrotask(() => this.transaction.abort()); + return request; + }); + } + expect(await source.rememberActiveSource('doc-a', saved, { savedThroughRevision: 0 })).toBe(false); + vi.restoreAllMocks(); + expect(await (await source.readActiveSource('doc-a'))?.text()).toBe('original bytes'); + expect(await retainedCount()).toBe(1); + }, +); + +it('gives an inherited copied page a separate slot before its own saves', async () => { + const parent = await api(); + await parent.rememberActiveSource('doc-a', sample()); + const inheritedPointer = sessionStorage.getItem('document-active-source')!; + vi.resetModules(); + const copy = await api(); + await copy.rememberActiveSource('doc-a', (await copy.readActiveSource('doc-a'))!); + const copyPointer = sessionStorage.getItem('document-active-source')!; + expect(copyPointer).not.toBe(inheritedPointer); + await copy.rememberActiveSource('doc-a', new File(['copy save C'], 'Report.docx'), { savedThroughRevision: 0 }); + expect(await retainedCount()).toBe(2); + sessionStorage.setItem('document-active-source', inheritedPointer); + expect(await (await parent.readActiveSource('doc-a'))?.text()).toBe('original bytes'); + sessionStorage.setItem('document-active-source', copyPointer); + expect(await (await copy.readActiveSource('doc-a'))?.text()).toBe('copy save C'); +}); + +it.each(['expiry', 'explicit deletion'])( + 'starts a fresh disk-save slot when another runtime removed its previous slot by %s', + async (reason) => { + const source = await api(); + const now = vi.spyOn(Date, 'now').mockReturnValue(1000); + await source.rememberActiveSource('doc-a', sample()); + const stalePointer = sessionStorage.getItem('document-active-source')!; + vi.resetModules(); + const otherPage = await api(); + if (reason === 'expiry') { + now.mockReturnValue(1000 + 25 * 60 * 60 * 1000); + expect(await otherPage.readActiveSource('doc-a')).toBeNull(); + } else { + await otherPage.deleteDocumentSources('doc-a'); + // Explicit deletion in another real page cannot modify our sessionStorage. + sessionStorage.setItem('document-active-source', stalePointer); + } + for (let save = 0; save < 2; save++) { + expect( + await source.rememberActiveSource('doc-a', new File([`new save ${save}`], 'Report.docx'), { + savedThroughRevision: 0, + }), + ).toBe(true); + } + expect(await retainedCount()).toBe(1); + expect(await (await source.readActiveSource('doc-a'))?.text()).toBe('new save 1'); + }, +); diff --git a/test/unit/agent-artifact-workflow.test.ts b/test/unit/agent-artifact-workflow.test.ts index edd404e81..043eae994 100644 --- a/test/unit/agent-artifact-workflow.test.ts +++ b/test/unit/agent-artifact-workflow.test.ts @@ -1,6 +1,7 @@ import { expect, it, vi } from 'vitest'; import { captureArtifactDraft } from '../../lib/agent-plugin/artifacts/edit-proposal'; import { createArtifactWorkflow, runArtifactRevision } from '../../lib/agent-plugin/ui/artifact-workflow'; +import { GeminiProvider } from '@ranuts/agent-core/llm/gemini'; const draft = captureArtifactDraft({ artifactId: 'a', expectedVersion: 1, @@ -24,6 +25,55 @@ const labels = { stopped: 'Stopped', accepted: 'Updated draft. Save a version to keep it.', }; +it.each([false, true])('accepts Gemini STOP through real %s streaming transport', async (streaming) => { + const body = { candidates: [{ content: { parts: [{ text: 'after' }] }, finishReason: 'STOP' }] }; + const fetchImpl = vi.fn(async () => + streaming + ? new Response(`data: ${JSON.stringify(body)}\n\n`, { headers: { 'Content-Type': 'text/event-stream' } }) + : Response.json(body), + ); + const gemini = new GeminiProvider({ apiKey: 'test', fetchImpl }); + const provider = streaming + ? gemini + : { name: gemini.name, isReady: () => gemini.isReady(), chat: gemini.chat.bind(gemini) }; + const delta = vi.fn(); + await expect( + runArtifactRevision({ + provider, + draft, + instruction: 'Shorten', + signal: new AbortController().signal, + current: () => true, + delta, + }), + ).resolves.toBe('after'); + expect(fetchImpl).toHaveBeenCalledOnce(); + if (streaming) expect(delta).toHaveBeenCalledWith('after'); +}); +it.each(['MAX_TOKENS', 'SAFETY', 'RECITATION', 'OTHER', 'incomplete', 'unknown'])( + 'rejects abnormal artifact completion %s', + async (stopReason) => { + await expect( + runArtifactRevision({ + provider: { + name: 'test', + isReady: () => true, + chat: async () => ({ + text: 'after', + toolCalls: [], + stopReason, + assistant: { role: 'assistant', content: 'after' }, + }), + }, + draft, + instruction: 'Shorten', + signal: new AbortController().signal, + current: () => true, + delta: vi.fn(), + }), + ).rejects.toThrow('incomplete'); + }, +); it('discloses a single instruction and accepts only current completed proposals explicitly', () => { let request: any; let current = true; diff --git a/test/unit/agent-model-cache-settings.test.ts b/test/unit/agent-model-cache-settings.test.ts new file mode 100644 index 000000000..9f40d3045 --- /dev/null +++ b/test/unit/agent-model-cache-settings.test.ts @@ -0,0 +1,164 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { createModelCacheSettings } from '../../lib/agent-plugin/ui/model-cache-settings'; +import { rememberModelSource, type CachedModelSource } from '@ranuts/agent-core/llm/model-cache'; +import { WEBLLM_MODELS, RETIRED_WEBLLM_MODELS } from '@ranuts/agent-core/llm/webllm'; +import { t } from '@ranuts/shared/i18n'; + +const storage = vi.hoisted(() => ({ deleted: vi.fn().mockResolvedValue(undefined) })); +vi.mock('@ranuts/agent-core/llm/webllm', async (original) => ({ + ...(await original()), + isModelCached: async () => true, +})); +vi.mock('@ranuts/agent-core/llm/model-cache', async (original) => ({ + ...(await original()), + deleteCachedModel: storage.deleted, + listCachedGGUF: async () => [], +})); +const official = WEBLLM_MODELS[0]; +const mirror = { + id: official.id, + label: 'Remembered mirror', + modelUrl: '/models/mirror/', + modelLibUrl: '/models/mirror.wasm', +}; +const control = (value = '') => Object.assign(document.createElement('input'), { value }); +function settings(loaded?: CachedModelSource) { + let active = loaded; + let busy = false; + const provider = { isReady: () => !!active }; + const options = { + providerSelect: control('webllm'), + modelSelect: control(official.id), + localModelId: control(), + localModelUrl: control(), + localModelLib: control(), + ggufUrl: control(), + note: document.createElement('p'), + getLoadedModel: () => active, + currentProvider: () => options.providerSelect.value, + getProvider: () => provider as any, + isBusy: () => busy, + setBusy: (value: boolean) => { + busy = value; + }, + resetController: vi.fn(() => { + active = undefined; + }), + cleanup: vi.fn(async () => {}), + cleanupFailed: () => false, + loadModel: vi.fn(async () => { + active = { + id: options.localModelId.value || options.modelSelect.value, + label: 'Loaded', + modelUrl: options.localModelUrl.value || undefined, + modelLibUrl: options.localModelLib.value || undefined, + }; + }), + chat: { refreshSendAvailability: vi.fn() }, + }; + const view = createModelCacheSettings(options); + document.body.append(view.el); + const row = (label: string) => + [...view.el.querySelectorAll('.agent-model-cache-row')].find((item) => + item.textContent?.includes(label), + )!; + const click = (item: HTMLElement, selector: string) => item.querySelector(selector)!.click(); + return { view, options, row, click, loaded: () => active }; +} +beforeEach(() => { + localStorage.clear(); + storage.deleted.mockClear(); + rememberModelSource(mirror); +}); +afterEach(() => { + document.body.replaceChildren(); +}); + +it('uses a remembered mirror with a preset id and preserves both download URLs through loading', async () => { + const ui = settings(); + await ui.view.refresh(); + ui.click(ui.row(mirror.label), '.agent-cache-use'); + expect(ui.loaded()).toEqual( + expect.objectContaining({ id: mirror.id, modelUrl: mirror.modelUrl, modelLibUrl: mirror.modelLibUrl }), + ); + expect(ui.options.loadModel).toHaveBeenCalledOnce(); + await ui.view.refresh(); + expect(ui.row(mirror.label).querySelector('.agent-cache-use')?.hasAttribute('disabled')).toBe(true); + expect(ui.row(official.label).querySelector('.agent-cache-use')?.hasAttribute('disabled')).toBe(false); +}); +it('marks only the loaded source in use and deletes an idle same-id source without releasing the active engine', async () => { + const ui = settings({ ...official }); + await ui.view.refresh(); + expect(ui.row(official.label).textContent).toContain(t('agentModelInUse')); + expect(ui.row(mirror.label).textContent).not.toContain(t('agentModelInUse')); + ui.click(ui.row(mirror.label), '.agent-cache-delete'); + ui.click(ui.row(mirror.label), '.agent-cache-delete'); + await vi.waitFor(() => expect(storage.deleted).toHaveBeenCalledWith(mirror.id, expect.objectContaining(mirror))); + expect(ui.options.resetController).not.toHaveBeenCalled(); + expect(ui.options.cleanup).not.toHaveBeenCalled(); + expect(ui.loaded()?.id).toBe(official.id); +}); +it('releases the loaded source before deleting it even if the form currently names another source', async () => { + const ui = settings(mirror); + ui.options.localModelId.value = 'different-form-selection'; + await ui.view.refresh(); + ui.click(ui.row(mirror.label), '.agent-cache-delete'); + ui.click(ui.row(mirror.label), '.agent-cache-delete'); + await vi.waitFor(() => expect(storage.deleted).toHaveBeenCalled()); + expect(ui.options.resetController).toHaveBeenCalledOnce(); + expect(ui.options.cleanup).toHaveBeenCalledOnce(); + expect(ui.loaded()).toBeUndefined(); +}); +it('keeps library variants distinct in remembered rows, active guards and deletion', async () => { + const alternate = { ...mirror, label: 'Other runtime', modelLibUrl: '/models/other.wasm' }; + rememberModelSource(alternate); + const ui = settings(mirror); + await ui.view.refresh(); + expect(ui.row(mirror.label)).toBeDefined(); + expect(ui.row(alternate.label)).toBeDefined(); + expect(ui.row(alternate.label).querySelector('.agent-cache-use')?.hasAttribute('disabled')).toBe(false); + ui.click(ui.row(alternate.label), '.agent-cache-delete'); + ui.click(ui.row(alternate.label), '.agent-cache-delete'); + await vi.waitFor(() => expect(storage.deleted).toHaveBeenCalled()); + expect(ui.options.cleanup).not.toHaveBeenCalled(); +}); +it('deduplicates canonical relative and absolute sources while keeping official presets selectable', async () => { + rememberModelSource({ + ...mirror, + label: 'Canonical mirror', + modelUrl: new URL(mirror.modelUrl, location.href).href, + modelLibUrl: new URL(mirror.modelLibUrl, location.href).href, + }); + const ui = settings(mirror); + await ui.view.refresh(); + expect( + [...ui.view.el.querySelectorAll('.agent-model-cache-row')].filter((row) => row.textContent?.includes('mirror')), + ).toHaveLength(1); + ui.click(ui.row(official.label), '.agent-cache-use'); + expect(ui.options.localModelId.value).toBe(''); + expect(ui.options.localModelUrl.value).toBe(''); + expect(ui.options.localModelLib.value).toBe(''); + expect(ui.loaded()?.id).toBe(official.id); +}); +it('keeps retired preset downloads removal-only while allowing a distinct remembered mirror', async () => { + const retired = RETIRED_WEBLLM_MODELS[0]; + rememberModelSource({ ...mirror, id: retired.id, label: 'Retired id mirror' }); + const ui = settings(); + await ui.view.refresh(); + expect(ui.row(retired.label).querySelector('.agent-cache-use')).toBeNull(); + expect(ui.row('Retired id mirror').querySelector('.agent-cache-use')).not.toBeNull(); +}); +it('honors busy state and cancel on remembered source actions', async () => { + const ui = settings(); + await ui.view.refresh(); + ui.options.setBusy(true); + ui.click(ui.row(mirror.label), '.agent-cache-use'); + ui.click(ui.row(mirror.label), '.agent-cache-delete'); + expect(ui.options.loadModel).not.toHaveBeenCalled(); + expect(storage.deleted).not.toHaveBeenCalled(); + ui.options.setBusy(false); + ui.click(ui.row(mirror.label), '.agent-cache-delete'); + ui.click(ui.row(mirror.label), '.agent-cache-cancel'); + ui.click(ui.row(mirror.label), '.agent-cache-delete'); + expect(storage.deleted).not.toHaveBeenCalled(); +}); diff --git a/test/unit/agent-panel-loading.test.ts b/test/unit/agent-panel-loading.test.ts index d17d12c26..91eb94251 100644 --- a/test/unit/agent-panel-loading.test.ts +++ b/test/unit/agent-panel-loading.test.ts @@ -8,6 +8,7 @@ import { AgentChatController } from '../../lib/agent-plugin/ui/controller'; import { createHistoryStorage } from '../../lib/agent-plugin/ui/storage'; import { ChatView } from '@ranuts/chat-ui'; import { RequestQueue } from '../../lib/agent-plugin/ui/request-queue'; +import { rememberModelSource } from '@ranuts/agent-core/llm/model-cache'; HTMLElement.prototype.scrollTo = vi.fn(); // Runtime tests explicitly prepare, as a user must now do from settings. function createPreparedPanel() { @@ -29,6 +30,7 @@ const state = vi.hoisted(() => ({ progress: [] as Array<(value: { text: string; progress?: number }) => void>, unavailable: undefined as undefined | (() => void), ready: false, + loadedSources: [] as Array<{ model?: string; modelUrl?: string; modelLibUrl?: string }>, nativeEndpoint: false, cached: false, cacheChecks: vi.fn(), @@ -118,15 +120,14 @@ vi.mock('@ranuts/agent-core/llm/webllm', async (original) => ({ }, })); vi.mock('../../lib/onlyoffice/readonly', () => ({ getReadonlyMode: () => state.readonly })); -vi.mock('@ranuts/agent-core/llm/model-cache', () => ({ +vi.mock('@ranuts/agent-core/llm/model-cache', async (original) => ({ + ...(await original()), deleteCachedModel: state.deleteCache, listCachedGGUF: async () => state.cpuCached ? [{ name: 'cpu-model.gguf', url: 'https://models.example/cpu-model.gguf', size: 100, complete: true }] : [], deleteCachedGGUF: state.deleteCache, - rememberedModelSources: () => [], - rememberModelSource: () => {}, })); vi.mock('../../lib/agent-plugin/editor-bridge', () => ({ getEditorApi: () => @@ -185,10 +186,17 @@ vi.mock('@ranuts/agent-core/llm/local', async (original) => ({ return state.activeBackend; } constructor(options: { - webllm: { onProgress: (value: { text: string }) => void; onUnavailable?: () => void }; + webllm: { + model?: string; + modelUrl?: string; + modelLibUrl?: string; + onProgress: (value: { text: string }) => void; + onUnavailable?: () => void; + }; onBackend?: (backend: 'webllm' | 'wllama') => void; wllama?: { onProgress: (value: { loaded: number; total: number }) => void }; }) { + state.loadedSources.push(options.webllm); state.progress.push(options.webllm.onProgress); state.unavailable = options.webllm.onUnavailable; state.backend = options.onBackend; @@ -389,6 +397,9 @@ vi.mock('../../lib/agent-plugin/document-tool-action', () => { afterEach(() => { localStorage.removeItem('agent-panel-provider'); localStorage.removeItem('agent-local-preset'); + localStorage.removeItem('agent-local-model-id'); + localStorage.removeItem('agent-local-model-url'); + localStorage.removeItem('agent-local-model-lib'); localStorage.removeItem('agent-panel-gguf-url'); localStorage.removeItem('agent-writing-endpoint'); localStorage.removeItem('agent_conversation_saving_v2'); @@ -400,6 +411,8 @@ afterEach(() => { state.actionDispose.mockClear(); state.progress.length = 0; state.ready = false; + state.loadedSources.length = 0; + localStorage.removeItem('agent-model-cache-catalog-v1'); state.conversations = undefined; state.conversationRepository = undefined; state.nativeEndpoint = false; @@ -2176,6 +2189,52 @@ it.each(['word', 'cell', 'slide'] as const)( await vi.waitFor(() => expect(state.apply).toHaveBeenCalledOnce()); }, ); +it('loads a remembered mirror from real settings and keeps same-id official cache deletion independent', async () => { + const mirror = { + id: state.cachedModel, + label: 'Saved mirror source', + modelUrl: '/models/mirror/', + modelLibUrl: '/models/mirror.wasm', + }; + rememberModelSource(mirror); + state.cached = true; + state.ready = true; + const panel = createAgentPanel(); + const inventory = panel.querySelector('.agent-model-cache')!; + inventory.open = true; + inventory.dispatchEvent(new Event('toggle')); + const row = (label: string) => + [...inventory.querySelectorAll('.agent-model-cache-row')].find((item) => + item.textContent?.startsWith(label), + ); + await vi.waitFor(() => expect(row(mirror.label)).toBeDefined()); + row(mirror.label)!.querySelector('.agent-cache-use')!.click(); + await vi.waitFor(() => expect(state.preload).toHaveBeenCalledOnce()); + expect(state.loadedSources.at(-1)).toMatchObject({ + model: mirror.id, + modelUrl: mirror.modelUrl, + modelLibUrl: mirror.modelLibUrl, + }); + expect(localStorage.getItem('agent-local-model-url')).toBe(mirror.modelUrl); + expect(localStorage.getItem('agent-local-model-lib')).toBe(mirror.modelLibUrl); + await vi.waitFor(() => + expect(row(mirror.label)?.querySelector('.agent-cache-use')?.hasAttribute('disabled')).toBe(true), + ); + const official = row('Qwen3 · 4B')!; + expect(official.querySelector('.agent-cache-use')?.hasAttribute('disabled')).toBe(false); + const disposals = state.dispose.mock.calls.length; + const remove = official.querySelector('.agent-cache-delete')!; + remove.click(); + remove.click(); + await vi.waitFor(() => expect(state.deleteCache).toHaveBeenCalledWith(mirror.id, {})); + expect(state.dispose.mock.calls).toHaveLength(disposals); + expect(panel.querySelector('.agent-panel-note')?.textContent).toBe(t('agentModelLoaded')); + await vi.waitFor(() => expect(inventory.contains(official)).toBe(false)); + row('Qwen3 · 4B')!.querySelector('.agent-cache-use')!.click(); + await vi.waitFor(() => expect(state.preload).toHaveBeenCalledTimes(2)); + expect(state.loadedSources.at(-1)).toMatchObject({ model: mirror.id, modelUrl: undefined, modelLibUrl: undefined }); + expect(state.dispose.mock.calls.length).toBeGreaterThan(disposals); +}); it('requires confirmation and completed unload before deleting the running cached model', async () => { state.ready = true; state.cached = true; diff --git a/test/unit/build-identity.test.ts b/test/unit/build-identity.test.ts new file mode 100644 index 000000000..2eb5318e2 --- /dev/null +++ b/test/unit/build-identity.test.ts @@ -0,0 +1,148 @@ +import { spawn } from 'node:child_process'; +import { createServer } from 'node:http'; +import { mkdtemp, mkdir, readFile, rm, writeFile, copyFile } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; +import { tmpdir } from 'node:os'; +import { expect, it } from 'vitest'; + +const revision = 'a'.repeat(40); +function run(script: string, args: string[], env = {}) { + return new Promise<{ code: number | null; output: string }>((done) => { + const child = spawn(process.execPath, [resolve('bin', script), ...args], { + env: { ...process.env, ...env }, + }); + let output = ''; + child.stdout.on('data', (part) => (output += part)); + child.stderr.on('data', (part) => (output += part)); + child.on('close', (code) => done({ code, output })); + }); +} +it('records the actual served tree, including vendor, SW and static-only changes without output-path drift', async () => { + const root = await mkdtemp(join(tmpdir(), 'build-identity-')); + try { + const generate = async (dir: string) => { + const result = await run('build-identity.mjs', [dir], { BUILD_REVISION: revision }); + expect(result.code, result.output).toBe(0); + return JSON.parse(await readFile(join(dir, 'build-identity.json'), 'utf8')); + }; + const dirs = [join(root, 'first'), join(root, 'second')]; + for (const dir of dirs) { + await mkdir(join(dir, 'sdkjs'), { recursive: true }); + await writeFile(join(dir, 'sdkjs/vendor.js'), 'vendor'); + await writeFile(join(dir, 'sw.js'), 'worker'); + await writeFile(join(dir, 'help.html'), 'static'); + } + const first = await generate(dirs[0]); + expect(first).toEqual({ schema: 1, revision, contentSha256: expect.stringMatching(/^[a-f0-9]{64}$/) }); + expect(await generate(dirs[1])).toEqual(first); + expect(await generate(dirs[0])).toEqual(first); // marker never hashes itself + let previous = first.contentSha256; + for (const file of ['sdkjs/vendor.js', 'sw.js', 'help.html']) { + await writeFile(join(dirs[0], file), 'changed'); + const current = await generate(dirs[0]); + expect(current.revision).toBe(revision); + expect(current.contentSha256).not.toBe(previous); + previous = current.contentSha256; + } + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +it('polls the actual HTTP marker with a unique cache bypass and fails stale, absent, malformed, error and hanging deployments', async () => { + let mode = 'eventual'; + let polls = 0; + const urls: string[] = []; + const headers: string[] = []; + const sockets = new Set(); + const server = createServer((req, res) => { + polls++; + urls.push(req.url!); + headers.push(String(req.headers['cache-control'])); + if (mode === 'hang') return; + if (mode === 'error' || mode === 'missing') res.statusCode = mode === 'error' ? 503 : 404; + const liveRevision = mode === 'stale' || (mode === 'eventual' && polls === 1) ? 'b'.repeat(40) : revision; + res.end( + mode === 'malformed' + ? 'fallback' + : JSON.stringify({ + schema: mode === 'unsupported' ? 2 : 1, + revision: liveRevision, + contentSha256: mode === 'digestless' ? undefined : 'c'.repeat(64), + }), + ); + }); + server.on('connection', (socket) => { + sockets.add(socket); + socket.on('close', () => sockets.delete(socket)); + }); + await new Promise((done) => server.listen(0, '127.0.0.1', done)); + const port = (server.address() as import('node:net').AddressInfo).port; + try { + const url = `http://127.0.0.1:${port}/`; + const good = await run('wait-for-deploy.mjs', [url, revision, '600', '10']); + expect(good.code, good.output).toBe(0); + expect(polls).toBeGreaterThan(1); + expect(urls.every((url) => url.startsWith('/build-identity.json?deploy='))).toBe(true); + expect(new Set(urls).size).toBe(urls.length); + expect(headers.every((header) => /no-cache|no-store/.test(header))).toBe(true); + for (mode of ['stale', 'missing', 'malformed', 'unsupported', 'digestless', 'error', 'hang']) { + const bad = await run('wait-for-deploy.mjs', [url, revision, '160', '10']); + expect(bad.code, mode + bad.output).toBe(1); + expect(bad.output).toMatch(/deploy.*(failed|timed out)|deployment.*(failed|timed out)/i); + } + expect((await run('wait-for-deploy.mjs', [url, '', '100', '10'])).code).toBe(1); + } finally { + for (const socket of sockets) socket.destroy(); + await new Promise((done) => server.close(() => done())); + } +}, 15_000); + +it('bypasses the real service worker handler for the identity route, including cache-busting queries', async () => { + const { runInNewContext } = await import('node:vm'); + const handlers: Record void> = {}; + runInNewContext(await readFile(resolve('public/sw.js'), 'utf8'), { + self: { + location: { origin: 'https://example.com' }, + addEventListener: (type: string, handler: (event: unknown) => void) => { + handlers[type] = handler; + }, + }, + URL, + Date, + }); + for (const query of ['', '?deploy=nonce']) { + let intercepted = false; + handlers.fetch({ + request: { method: 'GET', url: 'https://example.com/build-identity.json' + query }, + respondWith: () => { + intercepted = true; + }, + }); + expect(intercepted).toBe(false); + } +}); + +it('uses actual checkout HEAD over a synthetic GitHub event SHA and identifies metadata-free archives by content', async () => { + const { execFileSync } = await import('node:child_process'); + const root = await mkdtemp(join(tmpdir(), 'build-revision-')); + try { + const output = join(root, 'dist'); + await mkdir(output); + await writeFile(join(output, 'index.html'), 'built'); + const env = { BUILD_REVISION: '', GITHUB_SHA: 'b'.repeat(40), CF_PAGES_COMMIT_SHA: '' }; + const local = await run('build-identity.mjs', [output], env); + expect(local.code, local.output).toBe(0); + const identity = JSON.parse(await readFile(join(output, 'build-identity.json'), 'utf8')); + expect(identity.revision).toBe(execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim()); + const script = join(root, 'build-identity.mjs'); + await copyFile(resolve('bin/build-identity.mjs'), script); + const archive = await run(script, [output], env); + expect(archive.code, archive.output).toBe(0); + expect(JSON.parse(await readFile(join(output, 'build-identity.json'), 'utf8')).revision).toBe( + 'content:' + identity.contentSha256, + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/test/unit/history-session-lifecycle.test.ts b/test/unit/history-session-lifecycle.test.ts index 405e8ca39..14e591376 100644 --- a/test/unit/history-session-lifecycle.test.ts +++ b/test/unit/history-session-lifecycle.test.ts @@ -5,17 +5,19 @@ import { afterEach, beforeEach, expect, it, vi } from 'vitest'; const stream = vi.hoisted(() => ({ requestSaveDocument: vi.fn(), diskWriter: null as null | ((file: File) => Promise), - saved: null as null | (() => void), + saved: null as null | ((file: File, isCurrent: () => boolean) => void), })); vi.mock('../../lib/onlyoffice/save-stream', () => ({ requestSaveDocument: stream.requestSaveDocument, setDiskWriter: (writer: (file: File) => Promise) => { stream.diskWriter = writer; }, - setSavedToDiskListener: (listener: () => void) => { + setSavedToDiskListener: (listener: (file: File, isCurrent: () => boolean) => void) => { stream.saved = listener; }, })); +const { opened } = vi.hoisted(() => ({ opened: vi.fn(async (_file: File, _options?: unknown) => {}) })); +vi.mock('../../lib/document', () => ({ openLocalFile: opened })); vi.mock('../../lib/onlyoffice/readonly', () => ({ getReadonlyMode: () => false })); import { @@ -29,13 +31,18 @@ import { } from '../../lib/history/autosave'; import { initDocumentHistory } from '../../lib/history'; import { resetHistoryDbForTests } from '../../lib/history/db'; -import { getDoc, putSnapshot, resetHistoryClockForTests } from '../../lib/history/store'; +import { getDoc, getLatestSnapshot, putSnapshot, resetHistoryClockForTests } from '../../lib/history/store'; import { resetSaveTargetsForTests, saveToDiskFile } from '../../lib/save-target'; import { startDocumentSession } from '../../lib/history/session'; +import { rememberActiveSource, readActiveSource } from '../../lib/active-document-source'; +import { restoreSavedDocument } from '../../lib/history/recovery'; + const file = () => new File([new Uint8Array([1, 2, 3])], 'Report.docx'); beforeEach(() => { + sessionStorage.clear(); + opened.mockClear(); clearDocumentSession(); resetHistoryDbForTests(); resetHistoryClockForTests(); @@ -107,7 +114,7 @@ it('marks the active recovery row saved after autosave is stopped', async () => await beginAutosaveSession({ docId: 'active', title: 'Report.docx', origin: 'local' }); await putSnapshot({ id: 'active', title: 'Report.docx', origin: 'local', bytes: new Uint8Array([1]) }); setAutosaveEnabled(false); - stream.saved!(); + stream.saved!(file(), () => true); await vi.waitFor(async () => expect((await getDoc('active'))?.savedToDiskAt).toEqual(expect.any(Number))); }); @@ -132,3 +139,87 @@ it('does not authorize disk routing from an unvalidated saved URL', async () => expect(await stream.diskWriter!(file())).toBe('unavailable'); expect(writes).toHaveLength(1); }); + +it.each(['export', 'storage'])('discards an older autosave held in %s after disk save B commits', async (phase) => { + await beginAutosaveSession({ docId: 'active', title: 'Report.docx', origin: 'local' }); + await rememberActiveSource('active', new File(['original O'], 'Report.docx')); + let finish!: (value: ArrayBuffer | File) => void; + const older = new File(['old autosave A'], 'Report.docx'); + const waiting = vi.fn( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + if (phase === 'storage') { + vi.spyOn(older, 'arrayBuffer').mockImplementation(waiting as () => Promise); + stream.requestSaveDocument.mockResolvedValue(older); + } else stream.requestSaveDocument.mockImplementation(waiting); + const pending = takeSnapshot(); + await vi.waitFor(() => expect(waiting).toHaveBeenCalled()); + const saved = new File(['disk save B'], 'Report.docx'); + stream.saved!(saved, () => true); + await vi.waitFor(async () => expect(await (await readActiveSource('active'))?.text()).toBe('disk save B')); + finish(phase === 'storage' ? new Uint8Array([65]).buffer : older); + expect(await pending).toBeNull(); + expect(await getLatestSnapshot('active')).toBeNull(); + expect(await restoreSavedDocument('active')).toBe(true); + expect(await opened.mock.calls[0][0].text()).toBe('disk save B'); + // Cancelling the older export does not stop future recovery of new edits. + stream.requestSaveDocument.mockResolvedValue(new File(['new unsaved C'], 'Report.docx')); + await takeSnapshot(); + opened.mockClear(); + expect(await restoreSavedDocument('active')).toBe(true); + expect(await opened.mock.calls[0][0].text()).toBe('new unsaved C'); +}); + +it('does not promote an unsaved snapshot made while retaining saved source bytes', async () => { + await beginAutosaveSession({ docId: 'active', title: 'Report.docx', origin: 'local' }); + await rememberActiveSource('active', new File(['original O'], 'Report.docx')); + await putSnapshot({ id: 'active', title: 'Report.docx', origin: 'local', bytes: new Uint8Array([65]) }); + const saved = new File(['disk save B'], 'Report.docx'); + let finish!: (bytes: ArrayBuffer) => void; + const reading = vi.spyOn(saved, 'arrayBuffer').mockImplementation( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + stream.saved!(saved, () => true); + await vi.waitFor(() => expect(reading).toHaveBeenCalled()); + await putSnapshot({ id: 'active', title: 'Report.docx', origin: 'local', bytes: new Uint8Array([67]) }); + finish(new Uint8Array([66]).buffer); + await vi.waitFor(async () => expect(await (await readActiveSource('active'))?.text()).toBe('B')); + expect((await getDoc('active'))?.savedToDiskAt).toBeUndefined(); + expect(await restoreSavedDocument('active')).toBe(true); + expect(await opened.mock.calls[0][0].text()).toBe('C'); +}); + +it.each(['document switch', 'newer save'])('does not publish a late source write after %s', async (mode) => { + await beginAutosaveSession({ docId: 'active', title: 'Report.docx', origin: 'local' }); + await rememberActiveSource('active', new File(['original O'], 'Report.docx')); + const saved = new File(['old save B'], 'Report.docx'); + let finish!: (bytes: ArrayBuffer) => void; + const reading = vi.spyOn(saved, 'arrayBuffer').mockImplementation( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + let current = true; + stream.saved!(saved, () => current); + await vi.waitFor(() => expect(reading).toHaveBeenCalled()); + current = false; + const docId = mode === 'document switch' ? 'replacement' : 'active'; + if (mode === 'document switch') { + await beginAutosaveSession({ docId, title: 'New.docx', origin: 'local' }); + await rememberActiveSource(docId, new File(['new source C'], 'New.docx')); + } else { + stream.saved!(new File(['new source C'], 'Report.docx'), () => true); + await vi.waitFor(async () => expect(await (await readActiveSource(docId))?.text()).toBe('new source C')); + } + finish(new Uint8Array([66]).buffer); + await new Promise((resolve) => setTimeout(resolve, 30)); + expect(await (await readActiveSource(docId))?.text()).toBe('new source C'); + expect(await getDoc('active')).toBeNull(); +}); diff --git a/test/unit/hosting-contract.test.ts b/test/unit/hosting-contract.test.ts index a7d21dd10..17f8d8459 100644 --- a/test/unit/hosting-contract.test.ts +++ b/test/unit/hosting-contract.test.ts @@ -69,6 +69,12 @@ describe('public/_headers', () => { } }); + it('never caches the deployment identity on Pages or the local production preview', () => { + expect(cc('/build-identity.json')).toBe('no-store'); + expect(read('vite.config.ts')).toContain("'/build-identity.json'"); + expect(read('vite.config.ts')).toContain("res.setHeader('Cache-Control', 'no-store')"); + }); + it('declares the x2t wasm pre-encoded, which is the only thing that makes it loadable', () => { // The file under that name holds brotli bytes (6.6 MB, against 42.1 MB // raw and 9.0 MB for the best gzip). The browser decoding it at the @@ -160,6 +166,10 @@ describe('sws.toml (self-hosted Docker)', () => { expect(cc('**')).toBe('no-cache'); }); + it('never caches the deployment identity in Docker', () => { + expect(cc('/build-identity.json')).toBe('no-store'); + }); + it('declares gzip for x2t so ordinary HTTP self-hosting can decode it', () => { // Chrome only decodes Brotli on trustworthy origins. Docker must serve // gzip for http://, while HTTPS Pages keeps its Brotli payload. @@ -191,7 +201,9 @@ describe('sws.toml (self-hosted Docker)', () => { expect(dockerfile).toMatch(/COPY sws\.toml \/sws\.toml/); expect(dockerfile).toMatch(/ENV SERVER_CONFIG_FILE=\/sws\.toml/); // Re-encode before the build hashes the served vendor tree for sw.js. - expect(dockerfile).toMatch(/node bin\/prepare-docker-wasm\.mjs public\s*&&\s*pnpm run build/); + expect(dockerfile).toMatch( + /node bin\/prepare-docker-wasm\.mjs public\s*&&\s*BUILD_REVISION="\$BUILD_REVISION" pnpm run build/, + ); }); }); diff --git a/test/unit/save-stream-lifecycle.test.ts b/test/unit/save-stream-lifecycle.test.ts index b7c50b92c..e0597f782 100644 --- a/test/unit/save-stream-lifecycle.test.ts +++ b/test/unit/save-stream-lifecycle.test.ts @@ -1,6 +1,8 @@ import 'fake-indexeddb/auto'; -import { IDBFactory } from 'fake-indexeddb'; +import { IDBFactory, IDBObjectStore } from 'fake-indexeddb'; import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +const { opened } = vi.hoisted(() => ({ opened: vi.fn(async (_file: File, _options?: unknown) => {}) })); +vi.mock('../../lib/document', () => ({ openLocalFile: opened })); vi.mock('../../lib/history/autosave', () => ({ getActiveDocumentId: () => 'save-document', clearDocumentSession: vi.fn(), @@ -8,9 +10,12 @@ vi.mock('../../lib/history/autosave', () => ({ isAutosaveEnabled: () => true, setAutosaveEnabled: vi.fn(), stopAutosaveSession: vi.fn(), + cancelPendingSnapshot: vi.fn(), })); +import { rememberActiveSource, readActiveSource } from '../../lib/active-document-source'; +import { restoreSavedDocument } from '../../lib/history/recovery'; import { initDocumentHistory } from '../../lib/history/index'; -import { getDoc, putSnapshot } from '../../lib/history/store'; +import { getDoc, putSnapshot, deleteDoc } from '../../lib/history/store'; import { resetHistoryDbForTests } from '../../lib/history/db'; import { resetSaveTargetsForTests } from '../../lib/save-target'; import { markDocumentDirty, hasUnsavedChanges, resetUnsavedGuardForTests } from '../../lib/unsaved-guard'; @@ -21,6 +26,8 @@ let frame: HTMLIFrameElement; let history: { Index: number; Points: Array<{ Items: object[] }>; Reset_SavedIndex: ReturnType }; let conversions: Array>; beforeEach(async () => { + sessionStorage.clear(); + opened.mockClear(); resetHistoryDbForTests(); resetSaveTargetsForTests(); resetUnsavedGuardForTests(); @@ -158,7 +165,7 @@ it('a cancelled or failed export does not prevent a later successful save', asyn startExport(); sendFile(false); await new Promise((resolve) => setTimeout(resolve, 10)); - expect(hasUnsavedChanges()).toBe(false); + await vi.waitFor(() => expect(hasUnsavedChanges()).toBe(false)); expect(history.Reset_SavedIndex).toHaveBeenCalledOnce(); }); @@ -169,7 +176,7 @@ it('a busy SDK ignoring another request preserves the accepted export save point startExport(); sendFile(false); await new Promise((resolve) => setTimeout(resolve, 10)); - expect(hasUnsavedChanges()).toBe(false); + await vi.waitFor(() => expect(hasUnsavedChanges()).toBe(false)); expect(history.Reset_SavedIndex).toHaveBeenCalledOnce(); }); @@ -250,3 +257,182 @@ it.each([ await sendFile(false, 0, earlier); await vi.waitFor(() => expect(names).toEqual([`Report.${later}`, `Report.${earlier}`])); }); + +// The bytes cross the real stream, source database and history database before +// recovery opens them. An assertion on savedToDiskAt alone missed this loss. +it.each(['older snapshot', 'no snapshot'])('reloads the actual disk save with %s', async (prior) => { + if (prior === 'no snapshot') await deleteDoc('save-document'); + await rememberActiveSource('save-document', new File(['original O'], 'Report.docx')); + setDiskWriter(async () => 'written'); + await sendFile(); + await vi.waitFor(() => expect(hasUnsavedChanges()).toBe(false)); + await vi.waitFor(async () => { + opened.mockClear(); + expect(await restoreSavedDocument('save-document')).toBe(true); + expect(new Uint8Array(await opened.mock.calls[0][0].arrayBuffer())).toEqual(new Uint8Array([1, 2, 3])); + }); + // Reopening calls rememberActiveSource again, so repeated reloads retain the + // saved source's precedence over the older recovery revision as well. + await rememberActiveSource('save-document', opened.mock.calls[0][0]); + opened.mockClear(); + expect(await restoreSavedDocument('save-document')).toBe(true); + expect(new Uint8Array(await opened.mock.calls[0][0].arrayBuffer())).toEqual(new Uint8Array([1, 2, 3])); +}); + +it('restores a later unsaved snapshot ahead of the disk save', async () => { + await rememberActiveSource('save-document', new File(['original O'], 'Report.docx')); + setDiskWriter(async () => 'written'); + await sendFile(); + await vi.waitFor(async () => expect((await getDoc('save-document'))?.savedToDiskAt).toBeTypeOf('number')); + await putSnapshot({ id: 'save-document', title: 'Report.docx', origin: 'local', bytes: new Uint8Array([67]) }); + expect(await restoreSavedDocument('save-document')).toBe(true); + expect(await opened.mock.calls[0][0].text()).toBe('C'); +}); + +it('keeps a prior recovery point when saving the reload source fails', async () => { + await rememberActiveSource('save-document', new File(['original O'], 'Report.docx')); + const put = IDBObjectStore.prototype.put; + vi.spyOn(IDBObjectStore.prototype, 'put').mockImplementation(function (this: IDBObjectStore, value, key) { + if (this.name === 'sources') throw new DOMException('full', 'QuotaExceededError'); + return put.call(this, value, key); + }); + setDiskWriter(async () => 'written'); + await sendFile(); + await vi.waitFor(() => expect(hasUnsavedChanges()).toBe(false)); + await new Promise((resolve) => setTimeout(resolve, 30)); + expect((await getDoc('save-document'))?.savedToDiskAt).toBeUndefined(); + expect(await restoreSavedDocument('save-document')).toBe(true); + expect(new Uint8Array(await opened.mock.calls[0][0].arrayBuffer())).toEqual(new Uint8Array([1])); +}); + +it('keeps the editable source after saving an export in another format', async () => { + await rememberActiveSource('save-document', new File(['editable source'], 'Report.docx')); + setDiskWriter(async () => 'written'); + await sendFile(true, 0, 'pdf'); + await vi.waitFor(() => expect(hasUnsavedChanges()).toBe(false)); + await new Promise((resolve) => setTimeout(resolve, 30)); + expect(await (await readActiveSource('save-document'))?.text()).toBe('editable source'); + expect((await getDoc('save-document'))?.savedToDiskAt).toBeUndefined(); +}); + +it('publishes save completion only after the saved reload bytes are durable', async () => { + await rememberActiveSource('save-document', new File(['original O'], 'Report.docx')); + const read = File.prototype.arrayBuffer; + let finish!: (value: ArrayBuffer) => void; + const reading = vi.fn(); + vi.spyOn(File.prototype, 'arrayBuffer').mockImplementation(function (this: File) { + if (this.size !== 3 || reading.mock.calls.length) return read.call(this); + reading(); + return new Promise((resolve) => { + finish = resolve; + }); + }); + setDiskWriter(async () => 'written'); + await sendFile(); + await vi.waitFor(() => expect(reading).toHaveBeenCalled()); + expect(hasUnsavedChanges()).toBe(true); + expect(history.Reset_SavedIndex).not.toHaveBeenCalled(); + finish(new Uint8Array([1, 2, 3]).buffer); + await vi.waitFor(() => expect(hasUnsavedChanges()).toBe(false)); + expect(await restoreSavedDocument('save-document')).toBe(true); + expect(new Uint8Array(await opened.mock.calls[0][0].arrayBuffer())).toEqual(new Uint8Array([1, 2, 3])); +}); + +it.each([ + ['doc', 'docx'], + ['xls', 'xlsx'], + ['ppt', 'pptx'], +])('retains the editable %s save normalized by the editor as %s', async (originalExt, savedExt) => { + await deleteDoc('save-document'); + setDocmentObj({ fileName: `Report.${originalExt}` }); + await rememberActiveSource('save-document', new File(['legacy original O'], `Report.${originalExt}`)); + setDiskWriter(async () => 'written'); + await sendFile(true, 0, savedExt); + await vi.waitFor(() => expect(hasUnsavedChanges()).toBe(false)); + expect(await restoreSavedDocument('save-document')).toBe(true); + expect(opened.mock.calls[0][0].name).toBe(`Report.${savedExt}`); + expect(new Uint8Array(await opened.mock.calls[0][0].arrayBuffer())).toEqual(new Uint8Array([1, 2, 3])); +}); + +it('preserves newer C recovery when older disk B finishes after further editing', async () => { + let finish!: (outcome: 'written') => void; + setDiskWriter( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + await sendFile(); + await vi.waitFor(() => expect(finish).toBeTypeOf('function')); + history.Points.push({ Items: [{}] }); + history.Index++; + markDocumentDirty(); + await putSnapshot({ id: 'save-document', title: 'Report.docx', origin: 'local', bytes: new Uint8Array([67]) }); + finish('written'); + await new Promise((resolve) => setTimeout(resolve, 30)); + expect(hasUnsavedChanges()).toBe(true); + expect((await getDoc('save-document'))?.savedToDiskAt).toBeUndefined(); + expect(await restoreSavedDocument('save-document')).toBe(true); + expect(await opened.mock.calls[0][0].text()).toBe('C'); +}); + +it('restores a newer snapshot after quota eviction recreated its history row', async () => { + await rememberActiveSource('save-document', new File(['original O'], 'Report.docx')); + setDiskWriter(async () => 'written'); + await sendFile(); + await vi.waitFor(() => expect(hasUnsavedChanges()).toBe(false)); + vi.stubGlobal('navigator', { storage: { estimate: async () => ({ quota: 6 }) } }); + await putSnapshot({ id: 'other', title: 'Other.docx', origin: 'local', bytes: new Uint8Array([8, 8, 8]) }); + expect(await getDoc('save-document')).toBeNull(); + await putSnapshot({ id: 'save-document', title: 'Report.docx', origin: 'local', bytes: new Uint8Array([67]) }); + expect(await restoreSavedDocument('save-document')).toBe(true); + expect(await opened.mock.calls[0][0].text()).toBe('C'); +}); + +it('keeps saved B bytes when C is typed while the reload source is still being stored', async () => { + await rememberActiveSource('save-document', new File(['original O'], 'Report.docx')); + const originalRead = File.prototype.arrayBuffer; + let finish!: (value: ArrayBuffer) => void; + const reading = vi.fn(); + vi.spyOn(File.prototype, 'arrayBuffer').mockImplementation(function (this: File) { + if (this.size !== 3 || reading.mock.calls.length) return originalRead.call(this); + reading(); + return new Promise((resolve) => { + finish = resolve; + }); + }); + setDiskWriter(async () => 'written'); + await sendFile(); + await vi.waitFor(() => expect(reading).toHaveBeenCalled()); + history.Points.push({ Items: [{}] }); + history.Index++; + markDocumentDirty(); + finish(new Uint8Array([1, 2, 3]).buffer); + await vi.waitFor(async () => { + expect(new Uint8Array(await (await readActiveSource('save-document'))!.arrayBuffer())).toEqual( + new Uint8Array([1, 2, 3]), + ); + }); + expect(hasUnsavedChanges()).toBe(true); + expect(history.Reset_SavedIndex).not.toHaveBeenCalled(); + expect(await restoreSavedDocument('save-document')).toBe(true); + expect(new Uint8Array(await opened.mock.calls[0][0].arrayBuffer())).toEqual(new Uint8Array([1, 2, 3])); +}); + +it('keeps saved source provenance across two reloads from a stale inherited pointer', async () => { + await rememberActiveSource('save-document', new File(['original O'], 'Report.docx')); + const inheritedPointer = sessionStorage.getItem('document-active-source')!; + setDiskWriter(async () => 'written'); + await sendFile(); + await vi.waitFor(() => expect(hasUnsavedChanges()).toBe(false)); + // A dormant duplicate still has the old pointer metadata after the parent + // updates the shared source slot in place with saved B. + sessionStorage.setItem('document-active-source', inheritedPointer); + for (let reload = 0; reload < 2; reload++) { + opened.mockClear(); + expect(await restoreSavedDocument('save-document')).toBe(true); + const recovered = opened.mock.calls[0][0]; + expect(new Uint8Array(await recovered.arrayBuffer())).toEqual(new Uint8Array([1, 2, 3])); + await rememberActiveSource('save-document', recovered); + } +}); diff --git a/test/unit/workflow-contract.test.ts b/test/unit/workflow-contract.test.ts index e35b07e02..1de44abc3 100644 --- a/test/unit/workflow-contract.test.ts +++ b/test/unit/workflow-contract.test.ts @@ -293,34 +293,17 @@ describe('.github/workflows/ci.yml', () => { describe('.github/workflows/prod-smoke.yml', () => { const src = readFileSync(resolve(WORKFLOW_DIR, 'prod-smoke.yml'), 'utf8'); - const editorHtml = readFileSync(resolve(ROOT, 'editor.html'), 'utf8'); - const landingHtml = readFileSync(resolve(ROOT, 'index.html'), 'utf8'); - - /** - * The deploy gate has to watch a file that CARRIES the build. - * - * It used to grep `assets/index-*.js` out of `dist/index.html` and poll `/` - * for the same name. `/` has shipped no bundle since the route split - * (2026-08-16), so the expectation was the empty string -- which matched the - * equally empty answer on the first poll. The step printed "live after 20s" - * and every production smoke since ran against whatever was already - * deployed. Nothing noticed until a case could tell two builds apart, and - * then it read as "the new code is broken in production". - */ - it('waits on the entry asset of the page that actually has one', () => { - expect(src).toContain("grep -o 'assets/editor-[A-Za-z0-9_-]*\\.js' dist/editor.html"); - expect(src).toContain('curl -s "$PROD_URL/editor"'); - // The premise of the two lines above: the editor page is the one that - // loads a bundle, and the landing page is the one that does not. - expect(editorHtml).toMatch(/]+type="module"/); - expect(landingHtml).not.toMatch(/]+type="module"/); - }); - - it('fails loudly when it cannot name what it is waiting for', () => { - // An empty expectation is what made the gate vacuous; it must never again - // be allowed to pass for free. - expect(src).toMatch(/if \[ -z "\$ASSET" \]; then/); - expect(src).toMatch(/::error::[^\n]*deploy gate/); + it('waits for the exact checkout revision through the executable identity gate and never smokes stale bytes', () => { + const job = parseJobs(src)[0]; + expect(src).toContain('node bin/wait-for-deploy.mjs "$PROD_URL" "$GITHUB_SHA"'); + expect(src).not.toMatch(/assets\/editor-|::warning::production/); + expect(job.body.indexOf('bin/wait-for-deploy.mjs')).toBeLessThan(job.body.indexOf('name: Smoke production')); + expect(src).toContain("if: github.event_name == 'push'"); + expect(readFileSync(resolve(ROOT, 'bin/build.sh'), 'utf8')).toContain('node bin/build-identity.mjs "$DIST_DIR"'); + expect(readFileSync(resolve(ROOT, 'Dockerfile'), 'utf8')).toContain('ARG BUILD_REVISION'); + expect(readFileSync(resolve(ROOT, '.github/workflows/docker.yml'), 'utf8')).toContain( + 'BUILD_REVISION=${{ github.sha }}', + ); }); }); @@ -388,81 +371,96 @@ describe('.github/workflows/nightly-corpus.yml', () => { const jobs = parseJobs(src); const exclude = new RegExp(/CORPUS_EXCLUDE: "(.*)"/.exec(src)![1].replace(/\\\\/g, '\\'), 'i'); - it('gives each browser its own runner without increasing per-runner concurrency', () => { - const shard = jobs.find(({ name }) => name === 'browsers-shard'); - expect(shard).toBeDefined(); - const body = shard!.body; - expect( - body - .match(/^ {8}browser: \[([^\]]+)\]$/m)?.[1] - .split(',') - .map((name) => name.trim()), - ).toEqual(['webkit', 'firefox']); - expect(body).toMatch(/^ {6}fail-fast: false$/m); - expect(body).toMatch(/^ {10}browsers: \$\{\{ matrix\.browser \}\}$/m); - const commands = parseSteps(body).filter(({ run }) => run?.includes('playwright test')); - expect(commands).toHaveLength(2); - for (const { run } of commands) expect(run).toContain('--project=${{ matrix.browser }}'); - expect(commands[0].run).toContain('--workers=2'); - expect(commands[1].run).toContain('--workers=1'); - expect(body).toMatch(/name: playwright-report-browsers-\$\{\{ matrix\.browser \}\}/); - expect(body).toContain('E2E_REPORT_DIR: playwright-report-browsers/normal'); - expect(body).toContain('E2E_REPORT_DIR: playwright-report-browsers/serial'); - expect(body).toContain('E2E_RESULTS_DIR: test-results-browsers/normal'); - expect(body).toContain('E2E_RESULTS_DIR: test-results-browsers/serial'); - expect(body).toMatch(/name: Run the cases that cannot share the run\n {8}if: \$\{\{ !cancelled\(\) \}\}/); - }); - - it('keeps the original browser check and propagates every unsuccessful matrix verdict', () => { + it('runs three ordinary shards per engine with exclusive serial runners and distinct artifacts', () => { + const normal = jobs.find(({ name }) => name === 'browsers-shard')!.body; + const serial = jobs.find(({ name }) => name === 'browsers-serial')!.body; + expect(normal).toMatch(/shard: \[1, 2, 3\]/); + expect(normal).toContain('E2E_SHARD: ${{ matrix.shard }}/3'); + expect(normal).toContain('E2E_BROWSER: ${{ matrix.browser }}'); + expect(normal).toContain('browser: [webkit, firefox]'); + expect(normal).toContain('fail-fast: false'); + expect(serial).toContain('browser: [webkit, firefox]'); + expect(serial).not.toMatch(/E2E_SHARD|shard: \[/); + expect(serial).toContain('needs: [gate, browsers-shard]'); + expect(serial).toContain("if: ${{ !cancelled() && needs.gate.outputs.browsers == 'true' }}"); + expect(normal).toContain('name: playwright-report-browsers-${{ matrix.browser }}-shard-${{ matrix.shard }}-normal'); + expect(serial).toContain('name: playwright-report-browsers-${{ matrix.browser }}-serial'); + for (const [body, workers] of [ + [normal, 2], + [serial, 1], + ] as const) { + const commands = parseSteps(body).filter(({ run }) => run?.includes('playwright test')); + expect(commands).toHaveLength(1); + expect(commands[0].run).toContain('--project=${{ matrix.browser }}'); + expect(commands[0].run).toContain('--workers=' + workers); + } + expect(serial).toContain('--grep @serial'); + }); + + it('keeps the existing browser check and rejects every unsuccessful ordinary or serial verdict', () => { const summary = jobs.find(({ name }) => name === 'browsers')!; expect(summary.body).toMatch(/^ {4}name: Cross-browser \(WebKit \+ Firefox\)$/m); - expect(summary.body).toMatch(/^ {4}needs: \[gate, browsers-shard\]$/m); - expect(summary.body).toMatch(/^ {4}if: always\(\) && needs\.gate\.outputs\.browsers == 'true'$/m); + expect(summary.body).toContain('needs: [gate, browsers-shard, browsers-serial]'); + expect(summary.body).toContain("if: always() && needs.gate.outputs.browsers == 'true'"); const step = parseSteps(summary.body).find(({ env }) => env.RESULT)!; - expect(step?.env.RESULT).toBe('${{ needs.browsers-shard.result }}'); - expect(step?.run).toBeTruthy(); + expect(step.env.RESULT).toBe('${{ needs.browsers-shard.result }}'); + expect(step.env.SERIAL_RESULT).toBe('${{ needs.browsers-serial.result }}'); for (const result of ['success', 'failure', 'cancelled', 'skipped']) { - const verdict = spawnSync('sh', ['-c', step.run!], { env: { ...process.env, RESULT: result } }); - expect(verdict.status, result).toBe(result === 'success' ? 0 : 1); + for (const serial of ['success', 'failure', 'cancelled', 'skipped']) { + const verdict = spawnSync('sh', ['-c', step.run!], { + env: { ...process.env, RESULT: result, SERIAL_RESULT: serial }, + }); + expect(verdict.status, result + '/' + serial).toBe(result === 'success' && serial === 'success' ? 0 : 1); + } } }); - it('selects exactly the matrix engine through the real Playwright CLI for both passes', () => { + it('selects all ordinary tests once per engine through the actual workflow CLI; serial runs once outside shards', () => { const require = createRequire(resolve(ROOT, 'package.json')); - const shard = jobs.find(({ name }) => name === 'browsers-shard'); - expect(shard).toBeDefined(); - const commands = parseSteps(shard!.body).filter(({ run }) => run?.includes('playwright test')); + const normal = parseSteps(jobs.find(({ name }) => name === 'browsers-shard')!.body).find(({ run }) => + run?.includes('playwright test'), + )!; + const serial = parseSteps(jobs.find(({ name }) => name === 'browsers-serial')!.body).find(({ run }) => + run?.includes('playwright test'), + )!; + const collect = (suite: { + specs?: { id: string; tests: { projectName: string }[] }[]; + suites?: unknown[]; + }): string[] => [ + ...(suite.specs || []).flatMap((spec) => spec.tests.map((test) => test.projectName + ':' + spec.id)), + ...(suite.suites || []).flatMap((child) => collect(child as typeof suite)), + ]; for (const browser of ['webkit', 'firefox']) { - for (const [index, command] of commands.entries()) { - const tokens = command + const list = (step: typeof normal, shard = '') => { + const tokens = step .run!.replaceAll('${{ matrix.browser }}', browser) .match(/"[^"]*"|'[^']*'|\S+/g)! .map((token) => token.replace(/^(["'])(.*)\1$/, '$2')); - expect(tokens.slice(0, 4)).toEqual(['pnpm', 'exec', 'playwright', 'test']); - expect(tokens).toContain('--project=' + browser); const result = spawnSync( process.execPath, - [ - require.resolve('@playwright/test/cli'), - 'test', - ...tokens.slice(4), - index === 0 ? 'test/e2e/network-fixture.spec.ts' : 'test/e2e/sw-silent-update.spec.ts', - '--list', - '--reporter=json', - ], - { cwd: ROOT, env: { ...process.env, E2E_SHARD: '' }, encoding: 'utf8' }, + [require.resolve('@playwright/test/cli'), 'test', ...tokens.slice(4), '--list', '--reporter=json'], + { + cwd: ROOT, + env: { ...process.env, E2E_SHARD: shard, E2E_BROWSER: browser }, + encoding: 'utf8', + maxBuffer: 20 * 1024 * 1024, + }, ); expect(result.status, result.stderr + result.stdout).toBe(0); - const collect = (suite: { specs?: { tests: { projectName: string }[] }[]; suites?: unknown[] }): string[] => [ - ...(suite.specs || []).flatMap((spec) => spec.tests.map((test) => test.projectName)), - ...(suite.suites || []).flatMap((child) => collect(child as typeof suite)), - ]; - const projects = collect(JSON.parse(result.stdout)); - expect(projects.length).toBeGreaterThan(0); - expect([...new Set(projects)]).toEqual([browser]); - } + return collect(JSON.parse(result.stdout)); + }; + const expected = list(normal); + const selected = ['1/3', '2/3', '3/3'].flatMap((shard) => list(normal, shard)); + expect(selected.length).toBeGreaterThan(100); + expect(selected.every((id) => id.startsWith(browser + ':'))).toBe(true); + expect(new Set(selected).size).toBe(selected.length); + expect(selected.sort()).toEqual(expected.sort()); + const serialTests = list(serial); + expect(serialTests.length).toBeGreaterThan(0); + expect(serialTests.every((id) => id.startsWith(browser + ':'))).toBe(true); + expect(serialTests.some((id) => selected.includes(id))).toBe(false); } - }, 20_000); + }, 40_000); it('skips a device-emulating spec by project, not by browser name', () => { // `devices['Pixel 5']` carries `defaultBrowserType: 'chromium'`, which wins @@ -494,7 +492,7 @@ describe('.github/workflows/nightly-corpus.yml', () => { // on the origin -- sw-warm read a vendor stamp that belonged to it, and // the cache-first probe lost the entry it had just planted. expect(src).toMatch(/--grep-invert "api surface\|corpus\|@serial"/); - const commands = parseSteps(jobs.find(({ name }) => name === 'browsers-shard')?.body || '').map(({ run }) => run); + const commands = parseSteps(jobs.find(({ name }) => name === 'browsers-serial')?.body || '').map(({ run }) => run); expect(commands.some((run) => run?.includes('--grep @serial') && run.includes('--workers=1'))).toBe(true); }); diff --git a/vite.config.ts b/vite.config.ts index 1b144d92c..8a71df65d 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -57,10 +57,11 @@ const cleanUrls = (publicDirName: string): Plugin => { // retain the same policy as their initial 200 response (required by WebKit). const localIsolationHeaders = (): Plugin => { const middleware = ( - _req: import('node:http').IncomingMessage, + req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse, next: () => void, ): void => { + if ((req.url ?? '').split('?')[0] === '/build-identity.json') res.setHeader('Cache-Control', 'no-store'); res.setHeader('Cross-Origin-Opener-Policy', 'same-origin'); res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp'); next();