diff --git a/.github/scripts/install-playwright.sh b/.github/scripts/install-playwright.sh index 0e8d59945..27db54007 100755 --- a/.github/scripts/install-playwright.sh +++ b/.github/scripts/install-playwright.sh @@ -17,43 +17,34 @@ if [ ${#browsers[@]} -eq 0 ]; then browsers=(chromium) fi -# On a cache hit, skip apt entirely. -# -# What `install-deps` actually does on a hosted runner, read off a healthy run: -# every library Chromium needs -- libnss3, libgbm1, libasound2t64, libcairo2, -# the lot -- reports "is already the newest version". The runner image ships -# them. The only thing apt installs is 21.1 MB of fonts (fonts-wqy-zenhei, -# fonts-ipafont-gothic, fonts-unifont, fonts-freefont-ttf, fonts-tlwg-loma-otf, -# xfonts-encodings), for rendering scripts a screenshot might contain. -# -# This suite does not render through them. The editor draws with its own -# vendored XOR font catalog (public/fonts/), PDF export injects -# PDF_FONT_MANIFEST, and the landing pages load vendored Geist woff2 -- system -# fonts reach nothing but DOM fallback text. The visual specs compare two -# renders from the same browser (original against saved-and-reopened), so a -# missing glyph would be missing identically on both sides. -# -# So each job was making a network call to Ubuntu's mirrors, on a fresh VM, -# for fonts nothing reads. Eleven jobs, eleven rolls of the dice per run -- and -# the dice are loaded: the runner's preferred azure.archive.ubuntu.com comes -# back `Ign:`, apt falls through to the public archive, and that stalls dead -# with no bytes moving. Four jobs were killed at GitHub's six-hour limit this -# week before the attempts were bounded; two more burned 17 minutes each on a -# single pull request after that. -# -# A cold cache still installs, deps and all: there are no browsers to run -# without it, and that path downloads from Playwright's CDN anyway. +# Browser binaries are cached; system libraries live only on the current runner. +# Chromium's libraries ship on the hosted image, so retain its font-only fast +# path. WebKit/Firefox need additional libraries (for example libevent for WPE) +# and must install them even when their browser binaries came from the cache. +needs_system_deps=false +for browser in "${browsers[@]}"; do + if [ "$browser" != "chromium" ]; then + needs_system_deps=true + fi +done + if [ "${BROWSERS_CACHED:-}" = "true" ]; then - if [ "${PLAYWRIGHT_INSTALL_DEPS:-}" != "true" ]; then - echo "Browsers restored from the cache; skipping the apt font install." - exit 0 + if [ "$needs_system_deps" = "true" ]; then + command=(install-deps "${browsers[@]}") + deps_are_advisory=false + default_attempts=3 + default_timeout=300 + else + if [ "${PLAYWRIGHT_INSTALL_DEPS:-}" != "true" ]; then + echo "Browsers restored from the cache; skipping the apt font install." + exit 0 + fi + # Preserve the opt-in Chromium refresh without stalling the hot path. + command=(install-deps "${browsers[@]}") + deps_are_advisory=true + default_attempts=1 + default_timeout=120 fi - # Escape hatch: PLAYWRIGHT_INSTALL_DEPS=true puts apt back, bounded and - # advisory, for the day a runner image stops shipping one of the libraries. - command=(install-deps "${browsers[@]}") - deps_are_advisory=true - default_attempts=1 - default_timeout=120 else command=(install --with-deps "${browsers[@]}") deps_are_advisory=false @@ -98,18 +89,9 @@ for attempt in $(seq 1 "$attempts"); do sleep 10 done -# With the binaries already restored from the cache, the only thing apt could -# still be adding is system libraries -- and the hosted runner image ships -# chromium's. Failing the job here means a stalled Ubuntu mirror is a red pull -# request, which is what actually happened: `noble-security InRelease` followed -# by four and a half minutes of total silence, three attempts in a row, twice on -# the same run. Sharding multiplied the number of jobs exposed to it. -# -# So on a cache hit the suite runs anyway. If a library really is missing, -# Playwright says so in as many words when it launches the browser, and the -# test step fails with that message instead of this one -- the information is -# not lost, it just no longer costs a run. A cold cache still has to succeed: -# there are no browsers to run without it. +# Only Chromium's explicit dependency refresh is advisory. Missing WebKit or +# Firefox libraries must fail here instead of producing hundreds of launch +# failures during the suite. A cold browser installation must also succeed. if [ "$deps_are_advisory" = "true" ]; then echo "::warning::playwright ${command[*]} failed after ${attempts} attempts; continuing on the cached browsers" exit 0 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3dc919398..e1e54f7f9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,8 +27,8 @@ permissions: # (playwright.pages.config.ts explains why workerd dies under concurrent # aborts). Shards keep every suite's own concurrency semantics untouched. # -# Changing the shard count means editing both the matrix and the /N in the -# --shard argument of that job. +# Partitions use measured suite durations (bin/ci-shard-timings.json). Change +# the shard count in both the matrix and E2E_SHARD for that job. jobs: lint: @@ -91,14 +91,18 @@ jobs: # assert wall-clock budgets, and four WASM editors sharing four cores # push them past their bound (the font-system wait measured 3.4 s # against a 2 s budget). See the e2e-serial job and CLAUDE.md. - run: pnpm exec playwright test --shard=${{ matrix.shard }}/3 --grep-invert @serial + env: + E2E_SHARD: ${{ matrix.shard }}/3 + run: pnpm exec playwright test --grep-invert @serial - name: Upload Playwright report if: failure() uses: actions/upload-artifact@v7 with: name: playwright-report-${{ matrix.shard }} - path: playwright-report/ + path: | + playwright-report/ + test-results/ retention-days: 7 # Timing budgets need an idle VM, but do not need to run once per shard. @@ -123,7 +127,9 @@ jobs: uses: actions/upload-artifact@v7 with: name: playwright-report-serial - path: playwright-report/ + path: | + playwright-report/ + test-results/ retention-days: 7 # Summary jobs. Branch protection matches required checks by name, so the @@ -152,7 +158,7 @@ jobs: e2e-pages-shard: name: E2E (Cloudflare Pages semantics) shard ${{ matrix.shard }} runs-on: ubuntu-latest - # Five shards: distribute individual tests while serving wrangler with a + # Five shards: balance measured suite durations while serving wrangler with a # single worker on purpose (see playwright.pages.config.ts). Keep the # runner count unchanged while improving the distribution; installing and # starting a server in more jobs also adds overhead. @@ -174,14 +180,18 @@ jobs: - name: Run E2E against wrangler pages dev # `@serial` is excluded by playwright.pages.config.ts itself, so a local # run of that config measures exactly what this job does. - run: pnpm exec playwright test -c playwright.pages.config.ts --shard=${{ matrix.shard }}/5 + env: + E2E_SHARD: ${{ matrix.shard }}/5 + run: pnpm exec playwright test -c playwright.pages.config.ts - name: Upload Playwright report if: failure() uses: actions/upload-artifact@v7 with: name: playwright-report-pages-${{ matrix.shard }} - path: playwright-report-pages/ + path: | + playwright-report-pages/ + test-results-pages/ retention-days: 7 e2e-pages: @@ -223,18 +233,21 @@ jobs: browsers: chromium - name: Run E2E tests against the Docker image - # Called directly, not through `pnpm run`: pnpm swallows the extra - # arguments, which would silently run the whole suite in every shard. + # The explicit E2E_SHARD selects the duration-balanced partition. # `@serial` is excluded by playwright.docker.config.ts itself, so a # local `pnpm run test:e2e:docker` measures the same set. - run: sh ./bin/test-e2e-docker.sh --shard=${{ matrix.shard }}/3 + env: + E2E_SHARD: ${{ matrix.shard }}/3 + run: sh ./bin/test-e2e-docker.sh - name: Upload Playwright report if: failure() uses: actions/upload-artifact@v7 with: name: playwright-report-docker-${{ matrix.shard }} - path: playwright-report-docker/ + path: | + playwright-report-docker/ + test-results-docker/ retention-days: 7 e2e-docker: diff --git a/.github/workflows/nightly-corpus.yml b/.github/workflows/nightly-corpus.yml index bde65dc7e..f36683d81 100644 --- a/.github/workflows/nightly-corpus.yml +++ b/.github/workflows/nightly-corpus.yml @@ -21,6 +21,10 @@ on: - cron: "17 19 * * *" # 03:17 Asia/Shanghai workflow_dispatch: inputs: + skip_corpus: + description: "Run cross-browser and budgets without the corpus matrix" + type: boolean + default: false limit: description: "Max files to run (CORPUS_LIMIT)" default: "300" @@ -52,6 +56,7 @@ jobs: id: decide env: MANUAL: ${{ github.event_name == 'workflow_dispatch' }} + SKIP_CORPUS: ${{ github.event_name == 'workflow_dispatch' && inputs.skip_corpus }} run: | age_hours=$(( ( $(date -u +%s) - $(git log -1 --format=%ct) ) / 3600 )) # 1..7 with 7 = Sunday. The cron fires at 19:17 UTC, so this is the @@ -72,6 +77,10 @@ jobs: if [ "$MANUAL" = "true" ] || { [ "$weekday" -eq 7 ] && [ "$age_hours" -lt 193 ]; }; then corpus=true fi + # A focused manual browser validation does not need the weekly corpus. + if [ "$SKIP_CORPUS" = "true" ]; then + corpus=false + fi { echo "HEAD is ${age_hours}h old, UTC weekday ${weekday}, manual=${MANUAL}" @@ -134,7 +143,7 @@ jobs: # are narrow on purpose: a bare `crash` would also drop # 51921-Word-Crash067.doc, which is a real document from a bug report. CORPUS_EXCLUDE: "password|protect|encrypt|corrupt|truncat|broken|invalid|damaged|clusterfuzz|fuzzer|fuzzed|poi-fuzz|crash-[0-9a-f]{6}|\\.xlsm$|\\.xlsb$|\\.docm$|\\.pptm$" - run: pnpm exec playwright test test/e2e/corpus.spec.ts --workers=2 --reporter=list + run: pnpm exec playwright test test/e2e/corpus.spec.ts --workers=2 - name: Summarize if: always() @@ -150,12 +159,16 @@ jobs: playwright-report/ retention-days: 14 - browsers: - name: Cross-browser (WebKit + Firefox) + browsers-shard: + name: Cross-browser (${{ matrix.browser }}) runs-on: ubuntu-latest timeout-minutes: 90 needs: gate if: needs.gate.outputs.browsers == 'true' + strategy: + fail-fast: false + matrix: + browser: [webkit, firefox] steps: - name: Checkout code @@ -164,12 +177,15 @@ jobs: - name: Set up the toolchain uses: ./.github/actions/setup with: - browsers: webkit firefox + 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 WebKit and Firefox - run: pnpm exec playwright test -c playwright.browsers.config.ts --grep-invert "api surface|corpus|@serial" --reporter=list + - name: Run E2E on ${{ matrix.browser }} + env: + E2E_REPORT_DIR: playwright-report-browsers/normal + E2E_RESULTS_DIR: test-results-browsers/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 @@ -179,16 +195,34 @@ jobs: # missing sentinel came from on 2026-08-25. Without the second pass # nothing runs them at all and the job stays green regardless. - name: Run the cases that cannot share the run - run: pnpm exec playwright test -c playwright.browsers.config.ts --grep @serial --workers=1 --reporter=list + if: ${{ !cancelled() }} + env: + E2E_REPORT_DIR: playwright-report-browsers/serial + E2E_RESULTS_DIR: test-results-browsers/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 - path: playwright-report-browsers/ + name: playwright-report-browsers-${{ matrix.browser }} + path: | + playwright-report-browsers/ + test-results-browsers/ retention-days: 14 + browsers: + name: Cross-browser (WebKit + Firefox) + runs-on: ubuntu-latest + timeout-minutes: 5 + needs: [gate, browsers-shard] + if: always() && needs.gate.outputs.browsers == 'true' + steps: + - name: Report the browser matrix verdict + env: + RESULT: ${{ needs.browsers-shard.result }} + run: test "$RESULT" = "success" + budgets: name: Slow-network budgets runs-on: ubuntu-latest @@ -226,7 +260,7 @@ jobs: - name: UI crawl env: UI_CRAWL: "1" - run: pnpm exec playwright test test/e2e/ui-crawl.spec.ts --reporter=list + run: pnpm exec playwright test test/e2e/ui-crawl.spec.ts - name: Upload UI crawl reports if: always() @@ -241,4 +275,4 @@ jobs: - name: Throttled-network budget env: SLOW_NET: "1" - run: pnpm exec playwright test test/e2e/slow-network.spec.ts --reporter=list + run: pnpm exec playwright test test/e2e/slow-network.spec.ts diff --git a/bin/ci-artifacts.d.mts b/bin/ci-artifacts.d.mts new file mode 100644 index 000000000..a1fa031d8 --- /dev/null +++ b/bin/ci-artifacts.d.mts @@ -0,0 +1,6 @@ +import type { PlaywrightTestConfig } from '@playwright/test'; +export function playwrightArtifacts( + report: string, + results: string, + env?: Record, +): Pick; diff --git a/bin/ci-artifacts.mjs b/bin/ci-artifacts.mjs new file mode 100644 index 000000000..f1abc4381 --- /dev/null +++ b/bin/ci-artifacts.mjs @@ -0,0 +1,7 @@ +/** Separate passes must never clear each other's HTML reports or trace output. */ +export function playwrightArtifacts(report, results, env = process.env) { + return { + outputDir: env.E2E_RESULTS_DIR || results, + reporter: [['list'], ['html', { outputFolder: env.E2E_REPORT_DIR || report, open: 'never' }]], + }; +} diff --git a/bin/ci-shard-timings.json b/bin/ci-shard-timings.json new file mode 100644 index 000000000..db1f15fb5 --- /dev/null +++ b/bin/ci-shard-timings.json @@ -0,0 +1,165 @@ +{ + "source": "GitHub Actions CI run 39037704558, main 283ca850, 2026-10-17. Sum of passed test durations in seconds; skipped specs use median fallback.", + "preview": { + "agent-artifact-revision.spec.ts": 21.3, + "agent-cloud-streaming.spec.ts": 35.4, + "agent-composer-ux.spec.ts": 142.2, + "agent-content-review.spec.ts": 58.0, + "agent-context-events.spec.ts": 30.7, + "agent-dictation.spec.ts": 108.3, + "agent-document-references.spec.ts": 20.5, + "agent-error-recovery.spec.ts": 22.2, + "agent-history-rendering.spec.ts": 63.3, + "agent-imported-formats.spec.ts": 86.1, + "agent-process-feedback.spec.ts": 30.5, + "agent-product-entry.spec.ts": 83.9, + "agent-request-queue.spec.ts": 51.2, + "agent-result-hierarchy.spec.ts": 15.5, + "agent-sidebar-layout.spec.ts": 24.6, + "agent-sources-artifacts.spec.ts": 125.0, + "agent-stop-resume.spec.ts": 54.0, + "agent-task-loop.spec.ts": 80.4, + "agent-workspace-consistency.spec.ts": 96.8, + "agent-workspace.spec.ts": 65.4, + "agent-write-tools.spec.ts": 172.6, + "app-smoke.spec.ts": 13.079, + "assistant-loading-locale.spec.ts": 81.8, + "assistant-offline-assets.spec.ts": 35.4, + "autosave-recovery.spec.ts": 60.4, + "bad-image-url-locale.spec.ts": 16.1, + "comment-bulk-actions.spec.ts": 14.0, + "comments.spec.ts": 38.8, + "csv-encoding.spec.ts": 12.6, + "docx-features.spec.ts": 24.1, + "docx-ruby.spec.ts": 12.0, + "editor-csp.spec.ts": 28.9, + "editor-locales.spec.ts": 64.9, + "embed-api.spec.ts": 7.914, + "embed-dirty-state.spec.ts": 44.1, + "embed-regression.spec.ts": 125.6, + "embed-save-default.spec.ts": 9.4, + "entry-paths.spec.ts": 38.8, + "filename-matrix.spec.ts": 139.2, + "font-cache.spec.ts": 17.4, + "font-picker-scroll.spec.ts": 12.5, + "font-substitution.spec.ts": 90.2, + "format-parity.spec.ts": 56.2, + "history-page.spec.ts": 17.31, + "history-quota.spec.ts": 24.7, + "html-as-xls.spec.ts": 12.1, + "image-insert.spec.ts": 29.4, + "landing-prefetch.spec.ts": 54.2, + "language-menu.spec.ts": 32.733, + "large-document.spec.ts": 19.3, + "main-site.spec.ts": 27.2, + "mobile-overflow.spec.ts": 31.6, + "mobile-slide.spec.ts": 89.6, + "odf-formats.spec.ts": 34.489, + "offline-seam.spec.ts": 9.972, + "open-failure.spec.ts": 34.3, + "open-replacement.spec.ts": 16.7, + "open-retry.spec.ts": 20.2, + "open-url-failure.spec.ts": 1.0, + "pdf-agent-contract.spec.ts": 58.5, + "pdf-cjk-export.spec.ts": 21.9, + "pdf-roundtrip.spec.ts": 51.2, + "pdf-route.spec.ts": 4.4, + "plugin-availability.spec.ts": 10.6, + "remote-open.spec.ts": 1.852, + "resave-idempotence.spec.ts": 72.2, + "save-to-file.spec.ts": 143.7, + "site-design-consistency.spec.ts": 34.9, + "social-sharing.spec.ts": 1.187, + "sw-vendor-cache-first.spec.ts": 20.5, + "sw-warm.spec.ts": 18.5, + "text-formats.spec.ts": 33.8, + "theme-presentation.spec.ts": 33.162, + "vendor-branding.spec.ts": 57.304, + "visual-roundtrip.spec.ts": 79.2, + "wasm-memory.spec.ts": 44.4, + "webmcp.spec.ts": 21.212, + "xlsx-features.spec.ts": 38.4, + "xlsx-panes.spec.ts": 22.7 + }, + "pages": { + "agent-artifact-revision.spec.ts": 12.8, + "agent-cloud-streaming.spec.ts": 25.7, + "agent-composer-ux.spec.ts": 108.4, + "agent-content-review.spec.ts": 39.8, + "agent-context-events.spec.ts": 24.1, + "agent-dictation.spec.ts": 96.6, + "agent-document-references.spec.ts": 14.3, + "agent-error-recovery.spec.ts": 17.7, + "agent-history-rendering.spec.ts": 45.0, + "agent-imported-formats.spec.ts": 58.3, + "agent-process-feedback.spec.ts": 22.2, + "agent-product-entry.spec.ts": 58.7, + "agent-request-queue.spec.ts": 31.3, + "agent-result-hierarchy.spec.ts": 10.8, + "agent-sidebar-layout.spec.ts": 18.0, + "agent-sources-artifacts.spec.ts": 91.0, + "agent-stop-resume.spec.ts": 39.9, + "agent-task-loop.spec.ts": 59.8, + "agent-workspace-consistency.spec.ts": 73.4, + "agent-workspace.spec.ts": 54.0, + "agent-write-tools.spec.ts": 142.9, + "app-smoke.spec.ts": 10.978, + "assistant-loading-locale.spec.ts": 58.1, + "assistant-offline-assets.spec.ts": 25.1, + "autosave-recovery.spec.ts": 47.4, + "bad-image-url-locale.spec.ts": 10.8, + "comment-bulk-actions.spec.ts": 9.5, + "comments.spec.ts": 24.8, + "csv-encoding.spec.ts": 8.2, + "docx-features.spec.ts": 16.0, + "docx-ruby.spec.ts": 7.9, + "editor-csp.spec.ts": 19.9, + "editor-locales.spec.ts": 43.9, + "embed-api.spec.ts": 4.76, + "embed-dirty-state.spec.ts": 34.8, + "embed-regression.spec.ts": 93.7, + "embed-save-default.spec.ts": 6.2, + "entry-paths.spec.ts": 29.5, + "filename-matrix.spec.ts": 88.0, + "font-cache.spec.ts": 12.7, + "font-picker-scroll.spec.ts": 9.4, + "font-substitution.spec.ts": 84.7, + "format-parity.spec.ts": 44.5, + "history-page.spec.ts": 12.787, + "history-quota.spec.ts": 19.2, + "html-as-xls.spec.ts": 8.0, + "image-insert.spec.ts": 26.3, + "landing-prefetch.spec.ts": 38.0, + "language-menu.spec.ts": 21.213, + "large-document.spec.ts": 10.3, + "main-site.spec.ts": 15.8, + "mobile-overflow.spec.ts": 18.8, + "mobile-slide.spec.ts": 68.7, + "odf-formats.spec.ts": 21.965, + "offline-seam.spec.ts": 6.64, + "open-failure.spec.ts": 24.1, + "open-replacement.spec.ts": 12.3, + "open-retry.spec.ts": 14.7, + "open-url-failure.spec.ts": 0.447, + "pdf-agent-contract.spec.ts": 37.9, + "pdf-cjk-export.spec.ts": 16.4, + "pdf-roundtrip.spec.ts": 35.9, + "pdf-route.spec.ts": 3.3, + "plugin-availability.spec.ts": 7.6, + "remote-open.spec.ts": 1.384, + "resave-idempotence.spec.ts": 45.8, + "save-to-file.spec.ts": 100.2, + "site-design-consistency.spec.ts": 19.4, + "social-sharing.spec.ts": 0.924, + "sw-vendor-cache-first.spec.ts": 14.7, + "sw-warm.spec.ts": 13.7, + "text-formats.spec.ts": 20.0, + "theme-presentation.spec.ts": 16.529, + "vendor-branding.spec.ts": 36.139, + "visual-roundtrip.spec.ts": 57.0, + "wasm-memory.spec.ts": 38.9, + "webmcp.spec.ts": 23.391, + "xlsx-features.spec.ts": 23.3, + "xlsx-panes.spec.ts": 15.1 + } +} diff --git a/bin/ci-shards.d.mts b/bin/ci-shards.d.mts new file mode 100644 index 000000000..3da5a2449 --- /dev/null +++ b/bin/ci-shards.d.mts @@ -0,0 +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; diff --git a/bin/ci-shards.mjs b/bin/ci-shards.mjs new file mode 100644 index 000000000..bc7f17fca --- /dev/null +++ b/bin/ci-shards.mjs @@ -0,0 +1,41 @@ +import { readdirSync, readFileSync } from 'node:fs'; + +// Longest-processing-time assignment uses measured per-file execution time, +// not file order or equal test counts. New specs receive the median weight. +export function balanceTestFiles(files, timings, total) { + if (!Number.isInteger(total) || total < 1) throw new Error('Shard count must be a positive integer'); + const measured = Object.values(timings) + .filter((value) => Number.isFinite(value) && value > 0) + .sort((a, b) => a - b); + const fallback = measured[Math.floor(measured.length / 2)] || 1; + const weight = (file) => (timings[file] > 0 ? timings[file] : fallback); + const shards = Array.from({ length: total }, () => []); + const loads = Array(total).fill(0); + for (const file of [...new Set(files)].sort((a, b) => weight(b) - weight(a) || a.localeCompare(b))) { + const index = loads.indexOf(Math.min(...loads)); + shards[index].push(file); + loads[index] += weight(file); + } + return shards.map((files) => files.sort()); +} + +export function selectShard(shards, shard) { + const match = /^(\d+)\/(\d+)$/.exec(shard); + if (!match || Number(match[2]) !== shards.length) throw new Error(`Invalid CI shard: ${shard}`); + const selected = shards[Number(match[1]) - 1]; + if (!selected?.length) throw new Error(`Empty or invalid CI shard: ${shard}`); + return selected; +} + +export function ciTestMatch(suite) { + const shard = process.env.E2E_SHARD; + if (!shard) return {}; + const total = Number(shard.split('/')[1]); + const files = readdirSync(new URL('../test/e2e/', import.meta.url), { recursive: true }) + .filter((file) => file.endsWith('.spec.ts')) + .map((file) => file.replaceAll('\\', '/')); + 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 39d12bdfc..0d14c152d 100755 --- a/bin/test-e2e-docker.sh +++ b/bin/test-e2e-docker.sh @@ -10,8 +10,8 @@ set -e docker build -t document:e2e . docker rm -f document-e2e-docker >/dev/null 2>&1 || true -# Extra arguments are forwarded to Playwright, which is how CI passes -# --shard=N/M so the suite can be split across runners. +# Extra arguments are forwarded to Playwright. CI sets E2E_SHARD=N/M; +# playwright.docker.config.ts selects its duration-balanced partition. set +e E2E_DOCKER=1 ./node_modules/.bin/playwright test --config playwright.docker.config.ts "$@" status=$? diff --git a/docs/explorations/2026-10-18-save-integrity-and-browser-ci.md b/docs/explorations/2026-10-18-save-integrity-and-browser-ci.md new file mode 100644 index 000000000..c2c129a7c --- /dev/null +++ b/docs/explorations/2026-10-18-save-integrity-and-browser-ci.md @@ -0,0 +1,139 @@ +# Save integrity, agent completion and browser CI implementation plan + +**Goal:** Resolve the seven findings from the main-branch audit, balance CI shards, establish the WebKit networking failure's cause, and deliver a multi-commit PR with green CI. + +**Architecture:** Keep ownership in the existing document-open and save-stream modules. Bind programmatic exports to their originating document and individual conversion. Preserve partial model output with an explicit incomplete outcome; fit document reads within the existing UTF-8 context budget. Correct browser fixtures and workflow artifact handling without changing production networking to accommodate mocks. + +**Tech stack:** TypeScript, Vitest/jsdom, Playwright, GitHub Actions. + +## Scope lock + +The user approved all seven audit findings, CI balancing, WebKit investigation, regression tests, multiple commits, push and PR creation. No additional feature or merge is included. + +| Assumption | Evidence | Status | +| ------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | ---------------- | +| Pending A exports can consume B; concurrent Ctrl+S is captured by an autosave request | Source probes of `save-stream.ts` and `open-state.ts` on `283ca850` | Runtime verified | +| Failed preprocessing leaves B metadata while A remains mounted | Actual document/converter probe with `NotReadableError` | Runtime verified | +| Token/filter termination is treated as success | Actual runtime/controller/queue probe, chat and streaming | Runtime verified | +| Default document read exceeds the default byte budget | Actual tools/runtime probe: 8000 ASCII and 2200 Chinese characters | Runtime verified | +| Clipboard permissions and report upload fail on nightly | Run 39071219706 logs | Runtime verified | +| Service-worker control bypasses WebKit route mocks | Reproduced with Playwright 1.64, WebKit 2370 and the production worker | Runtime verified | + +- Allowed: `lib/onlyoffice/{save-stream,open-state}.ts`, document-open/mount lifecycle consumers, `lib/document.ts`, corresponding unit/E2E tests; `packages/agent-core/src/{runtime,context-budget,types}.ts`, agent controller/tool/budget consumers and localized messages; browser fixtures/configs, the assistant entry/focus owners and progress-row box sizing for directly reproduced cross-browser acceptance failures, `.github/workflows/{ci,nightly-corpus}.yml`, CI shard selection utilities/tests and this record. +- Untouched: vendor/archive assets, generated output, dependency versions, storage schema, credentials, production deployment configuration. +- Compatibility: preserve public embed request/response shape, original-file fallback opt-in semantics, dirty/save-point protection, read-only behavior, local/offline functionality, mandatory CI check names and per-runner worker counts. Preserve multiple commits as requested; do not squash. +- Simplest choices: existing ownership checks rather than a new event bus; result-level incomplete state rather than discarding partial output; bounded UTF-8 reads rather than raising the global context limit; engine-compatible test network boundaries rather than disabling production SW behavior; stable duration-informed sharding with complete/disjoint selection rather than adding workers. + +## Review focus + +- A pending export must reject on replacement/close and must never return the replacement's original bytes. +- An old failed open must not roll back a newer successful open's metadata. +- Manual saves during programmatic export must reach disk in both conversion completion orders. +- Incomplete model tool calls must not execute; partial text must remain visible and queued successors must pause. +- Browser mocks must work with a real controlling SW, and a failed first test pass must retain artifacts when the serial pass runs. + +## Tasks and acceptance + +- [x] Save ownership: add failing real-module regressions in `save-stream-lifecycle.test.ts`/save request tests; bind requests to document/editor/frame, reject retired requests and claim only the conversion initiated by that request; verify replacement, close, timeout fallback, overlap and conversion ordering. Commit separately. +- [x] Open metadata: add failing tests in `document-open.test.ts` with preprocessing rejection and competing opens; stage metadata until successful mount or restore safely without undoing a newer owner. Verify existing local/URL/new/readonly flows. Commit separately. +- [x] Agent completion: add runtime/controller/queue regressions for `length`, `max_tokens`, `content_filter` and streaming, including tool calls; propagate incomplete outcome/history and pause queue. Verify normal completion unaffected. Commit separately. +- [x] Agent read budget: add real tool/runtime tests for ASCII/Unicode document reads and pagination/truncation behavior; coordinate tool result budget without breaking latest-turn protocol or increasing the global limit. Commit separately. +- [x] Browser fixtures and WebKit: reproduce network mock bypass using the installed CI Playwright version and a real SW; fix fixture ownership, unsupported clipboard permissions and any directly exposed engine-specific failures. Keep expected error fixture self-check meaningful. Verify targeted WebKit/Firefox/Chromium suites. Commit separately. +- [x] CI artifacts and balancing: retain separate normal/serial reports and traces, execute serial after normal failure, and ensure uploads find actual output. Measure representative shard distribution, ensure selected sets are complete/disjoint and preserve mandatory check names/worker semantics. Add behavior regressions for selection/artifact paths. Commit separately. +- [ ] Integration: run formatting, lint/types, full unit coverage, production build and focused real-browser regressions sequentially. Obtain a fresh whole-branch review, resolve findings, push/create PR, attach it to this task and follow every CI check until green. Run the corrected cross-browser lane against the PR head as part of acceptance. + +## Verification record + +Temporary probes and downloaded CI logs stay outside Git. Integration delivery remains open until the PR and the explicitly dispatched cross-browser workflow pass on the pushed head. + +### Implementation and review + +- Programmatic exports capture document/editor/lifetime identity. Replacement and close abort pending requests; manual saves retain their own disk route. Cold tracking is installed before export. Native deferred PDF pagination preserves conversion ownership through the actual SDK download options. +- Open metadata is published only after mounting succeeds. Preprocessing rejection leaves the mounted document's exact metadata object intact; stale failures cannot undo a newer winner. +- Token, filter and abnormal provider terminations retain partial output, prevent tool execution, mark the result incomplete and pause queued successors. Direct generation consumers share the same completion guard. Gemini explicit abnormal termination defaults to incomplete, including future unknown reasons. +- Only document read results opt into structured UTF-8 fitting. Earlier reads can shrink as later tool exchanges consume space; serialized escaping and Unicode count toward the existing limit. A terminal large write still executes once without a needless further inference. +- Browser API fixtures use a real local HTTP boundary while retaining the production controlling worker. Clipboard tests inspect the application's exact write payload without unsupported permission grants. +- Nightly normal and serial passes have separate reports/results. Serial cases still run after normal failure. A manual-only option can validate browsers and budgets without the weekly corpus matrix; schedules and defaults retain their previous behavior. +- Three independent review partitions found deferred native export ownership, sequential read refitting, and same-basename shard collisions. All were corrected with failing-then-passing regressions and focused re-review. + +### WebKit characterization + +The production worker causes controlled WebKit requests to bypass Playwright page route mocks; the same characterization passes in Chromium. Missing fixture responses were consequently sent to the network. Real local HTTP fixtures resolve this at the browser network boundary, without disabling worker control or changing production API routing. + +A separate offline-worker defect returned `undefined` when both network and cache missed a hashed asset. Returning `Response.error()` preserves a valid response and the expected network rejection. WebKit additionally emits loader page errors for caught optional offline module imports; the offline regression only permits the precise failed optional URLs and still verifies prepared CPU assets load. + +WebKit CacheStorage can retain cache names but lose page-written bodies across renderer navigation. Restore setup now seeds outgoing markers immediately before real worker registration in the same document. The application restore path remains under test. + +For native keyboard scrolling, a standalone overflowing focused `pre` reproduced the zero-duration key press failure. A 100 ms physical key hold passes without changing the product or weakening the scroll assertion. The original hierarchy case failed three times, then passed three times; the composer code/table case also passed. + +### CI partition measurements + +Deterministic duration-based selection uses measured per-file main-CI durations, median fallback for new files and the existing worker counts. Estimated work is approximately 1185 seconds per preview/Docker shard and 516–518 seconds per Pages shard. These are estimated execution costs, not wall-clock guarantees. Actual Playwright CLI selection is tested for complete, disjoint coverage, including root/nested files sharing a basename. Existing required summary check names remain stable. + +### Local verification + +After the implementation freeze, full coverage passed: 249 files and 6712 tests; statements 87.45%, branches 83.51%, functions 88.26%, lines 90.01%. All configured thresholds passed. Formatting (including 1511 immutable archive artifacts), oxlint/TypeScript, production build and Compose configuration all passed. Real deferred PDF export has passed Chromium, WebKit and Firefox; the concurrent manual disk-save regression has passed Chromium. Full browser and deployment matrices remain part of remote CI acceptance. + +### Cross-browser acceptance follow-ups + +The first main CI matrix exposed two fixture migration defects: an expected Chromium same-origin security diagnostic was missing from its test-local allowance, and request/response observers still compared raw paths after the HTTP transport encoded the original URL. A guarded logical-URL decoder now serves both positive and negative assertions, with real browser network-event regressions. The subsequent main CI run `39094583253` passed every job. + +Additional current-build reproductions established two small product defects: Firefox can reject initial cross-frame focus while docking, and the editor rail's mousedown handler can queue a later focus that steals the WebKit composer. A guarded one-frame fallback and isolated assistant-entry mousedown preserve explicit external focus, closure and view-switch intent. Four panel intent cases and an actual ancestor-handler sidebar regression were added. Firefox's native progress border also exceeded its grid track by two pixels; border-box sizing fixes the existing strict geometry regression. + +The main-site save test now independently measures the browser's native filename policy while asserting the exact application download attribute and edited document bytes. This retains the application contract across platform filename formatting. + +Observed successful matrix job durations in run `39094583253`: preview shards 8m32s / 10m26s / 10m30s; Docker 10m33s / 10m51s / 10m23s; Pages 8m00s / 9m37s / 10m01s / 9m21s / 6m52s. The previous measured longest preview shard was 14m05s and Pages 13m17s. These are per-run observations, with runner-load variation, rather than a controlled benchmark. + +Final local acceptance after the follow-up fixes: 249 unit files / 6712 tests passed with all coverage thresholds (statements 87.45%, branches 83.51%, functions 88.26%, lines 90.01%). Formatting, oxlint/TypeScript, production build and Compose configuration passed again. On that fresh production build, the existing focus/loading/save scenarios passed on WebKit/Firefox (8 cases) and Chromium (4 cases), followed by all four WebKit/Firefox serial font-ordering and silent-worker-update cases, with zero retries. + +A final fixture audit reproduced another WebKit route bypass in the loading-layout test: despite a controlling worker, its Hugging Face page-route interception count remained zero. That test now holds model fetches through the real HTTP fixture and asserts requests are held for both the initial load and retry before measuring layout or cancelling. Existing strict layout, focus and cancellation assertions passed on WebKit/Firefox (4 cases) and Chromium (2 cases). This follow-up changes only the test boundary; production sources match the already verified build. + +### Nightly engine partition scope lock + +The user-authorized CI partition optimization also applies to the remaining nightly bottleneck. Main CI now completes roughly 332 Chromium cases with six workers across three runners in about ten minutes; the complete nightly pass puts both engines, roughly twice that work, on one runner with two workers. The first diagnostic run has exceeded fifty minutes. Split the existing nightly browser work by engine without increasing per-runner concurrency. + +- Files: `.github/workflows/nightly-corpus.yml` and focused workflow/CLI selection regressions only. +- Design: a fail-fast-disabled WebKit/Firefox matrix, engine-specific browser installation and project selection in both normal and serial passes, unique per-engine artifacts containing both report directories, plus an aggregate job retaining the original `Cross-browser (WebKit + Firefox)` check name. +- Compatibility: unchanged scheduling, gate decisions, corpus/budget jobs and manual defaults; two workers per normal runner and one per serial pass; serial execution after a normal-pass failure; no application or dependency changes. +- Acceptance: actual project selection covers each engine exactly once; workflow regressions prove both passes use the selected project, artifacts cannot collide and the summary rejects any failed matrix result; focused tests and formatting pass, then the complete split workflow passes on the pushed PR head. + +The engine partition is implemented and independently reviewed without actionable findings. Focused workflow regressions passed 69 tests after the expected RED run. Independent real CLI discovery proved complete, disjoint sets: normal 688 cases = 344 WebKit + 344 Firefox, serial 4 cases = 2 + 2. The final runtime acceptance is the split workflow on the next pushed head. + +### Full nightly fixture acceptance + +The first complete split run (`39096982286`) passed Firefox but exposed five deterministic WebKit fixture failures. Both engine artifacts retained normal and serial HTML reports; WebKit's failed normal pass still ran and passed its two serial cases, confirming failure-report retention in the actual workflow. + +- Two remaining URL-open fixtures still used page routes. Instrumented original tests recorded zero route interceptions while the embed host was worker-controlled. Both now use the existing HTTP fixture, assert exactly one handler request, and retain precise 404, successful replacement, retry and saved-byte assertions. +- Native WebKit exposes the save button as `Save (⌘+S)`. The formatted-content round trip now accepts the exact native Ctrl/Command labels while retaining saved-content and Undo checks. +- GTK WebKit normalizes spaces in downloaded filenames. Structured CSV export independently measures native filename policy, asserts the application's exact `Release table.csv` anchor name, and verifies all 26 rows, edited content and escaped formula bytes. +- WebKit discarded the history fixture's instance-level `indexedDB.open` override during startup. An exact local reproduction found the init probe present but untouched, the method restored to native and saved history already rendered. Patching `IDBFactory.prototype.open` preserves the receiver and arguments. Early held-history/status/empty-transcript preconditions now prove the intended startup ownership race actually runs before request submission. + +These follow-ups change only test boundaries and assertions. All five affected scenarios passed on Chromium, WebKit and Firefox (15 cases total, no retries). Fresh focused reviews found no actionable issues; final remote acceptance remains attached to PR #281 on its latest pushed head. + +### Native focus ownership follow-up scope lock + +The next full run passed WebKit but Firefox failed the first dictation setup twice. Actual trace snapshots show `Connect first` in the SDK's hidden `area_id` textarea while the parent composer remains empty. Local nominal runs pass, identifying a timing-dependent focus race rather than a changed readiness label. SDK source and instrumented focus stacks identify unconditional native/deferred editor-focus paths; the precise late callback differs with timing. + +The existing user-authorized cross-browser acceptance scope includes assistant focus owners. Bound this repair to a same-origin iframe focus guard, its existing preparation integration, explicit assistant focus claims and focused unit/E2E regressions. Preserve native focus calls unless a connected, visible assistant control currently owns focus. Explicit editor pointer/keyboard handoff, external focus, closure and hidden views must release that ownership. Keep vendor assets, public APIs, storage and provider contracts unchanged. Prove the real SDK's delayed focus cannot redirect composer text, and genuine editor input remains usable; rerun source checks and complete CI on the pushed head. + +The actual SDK regression failed on both Firefox entry paths before the guard. It now protects only exact native document focus targets (including live SDK input references), leaving modal and toolbar focus native. A second instrumented reproduction showed Firefox delivering the parent composer click without transferring focus out of the iframe; a single animation-frame retry completes that explicit handoff only while the same visible target still owns it and the guarded editor remains active. New editor/other-control intent cancels the retry. Tests retain genuine document typing and native modal focus assertions; no retries or timeouts were relaxed. + +Final local source validation: 250 unit files / 6753 tests passed; statement/branch/function/line coverage 87.49% / 83.56% / 88.30% / 90.03%. Production build, full formatting, oxlint/TypeScript and Compose validation passed. The native focus regression passed both entry paths on Chromium, WebKit and Firefox (6 cases, no retries). CLI discovery confirms normal nightly selection 692 = 346 + 346 and serial selection 4 = 2 + 2. Independent focused review found no unresolved actionable issues. + +### Cached browser system dependency follow-up scope lock + +Run 39103182477 cannot launch WebKit: every browser-dependent case exits before navigation because `libevent-2.1.so.7` is absent. Setup logs prove a binary-cache hit skipped apt entirely on the fresh Ubuntu runner. Browser binaries do not cache system libraries. Bound this follow-up to `.github/scripts/install-playwright.sh`, its executable unit regressions and existing workflow contract comments. Preserve cached Chromium's established fast path and explicit advisory refresh; require bounded, retried system dependency installation for every cached non-Chromium browser (including mixed selections), and fail installation clearly if it cannot succeed. Cold browser installation, job topology, schedules and test retries remain unchanged. + +### Cache fixture ownership after navigation + +Final-head run 39103705490 passed main/preview/budget and both complete browser passes, but WebKit's cached-model command-style case retried after seeing zero models. A focused production-build rerun reproduced that precondition failure three times without retries. Instrumented probes observed the instance-level `navigator.storage.getDirectory` override restored to native after navigation, and separately observed seeded Cache API entries unavailable after navigation. The browser's underlying storage behavior is not inferred beyond these observations. + +Bound the repair to this UI fixture: define its fallback override on the storage prototype, navigate to the ready editor before seeding, and verify the override and two real cache entries before opening the assistant. This case tests delete/confirmation/pending styling, so it does not need a landing-to-editor persistence precondition. Keep all existing cancellation, danger styling, pending deletion and final-removal assertions and existing retry/timeout policy. Independent focused review found no actionable issues; the corrected case passed three consecutive runs on each of Chromium, WebKit and Firefox (nine cases, no retries). + +### Exact baseline before the overlapping save + +Run 39106538022 passed the final cache-model fixture but Firefox's overlapping recovery/manual-save case failed twice: saved text was `Recvery version manual version`, then `Reovery version manual version`. The trace's screencast at 1547492.945 already shows `Recvery version` before the recovery hook is installed at 1547624.42, proving the missing letter precedes conversion. The individual native callback responsible for initial key loss is not inferred. + +Keep this regression focused on ownership of a real blank-document save: prepare initial text through one real browser `keyboard.insertText` input after a physical editor click, assert exact live paragraphs before arming recovery, and assert the unchanged real per-key manual edit before actual Ctrl+S while conversion delivery remains held. Preserve exact saved bytes, one committed disk write, one picker call and the eventual recovery row. Do not alter the shared typing helper, product code, retries or timeouts. + +The corrected overlap case passed three consecutive runs each on Firefox and Chromium (six cases, no retries). WebKit retained its existing three capability skips because writable OPFS is unavailable; no skip logic changed. Independent focused review found no actionable issues. diff --git a/lib/agent-plugin/document-read-answer.ts b/lib/agent-plugin/document-read-answer.ts index cf2a78c2a..3e5b95236 100644 --- a/lib/agent-plugin/document-read-answer.ts +++ b/lib/agent-plugin/document-read-answer.ts @@ -1,3 +1,4 @@ +import { incompleteResponseReason } from '@ranuts/agent-core/llm/completion'; import { REFERENCE_CITATION_RULE } from '@ranuts/agent-core/llm/prompt'; import type { LLMMessage, LLMProvider } from '@ranuts/agent-core/llm/types'; import type { DocumentContext } from './document-context'; @@ -44,7 +45,7 @@ export async function generateReadAnswer( ) : await provider.chat(messages, [], signal); signal.throwIfAborted(); - if (response.toolCalls.length || ['length', 'max_tokens'].includes(response.stopReason) || !response.text.trim()) + if (response.toolCalls.length || incompleteResponseReason(response) || !response.text.trim()) throw new Error('Incomplete document answer'); return response.text.trim(); } diff --git a/lib/agent-plugin/document-task.ts b/lib/agent-plugin/document-task.ts index 77eca4ca5..46573d61a 100644 --- a/lib/agent-plugin/document-task.ts +++ b/lib/agent-plugin/document-task.ts @@ -2,7 +2,13 @@ import { JsonTextStream } from '@ranuts/agent-core/llm/json-text-stream'; import type { LLMMessage, LLMProvider, LLMToolDef, LLMResponse } from '@ranuts/agent-core/llm/types'; import type { AgentTool } from '@ranuts/agent-core/types'; import type { DocumentContext } from './document-context'; -import { runAgent, validateToolInput, type AgentRunOptions, type AgentRunResult } from '@ranuts/agent-core/runtime'; +import { + runAgent, + incompleteResponseReason, + validateToolInput, + type AgentRunOptions, + type AgentRunResult, +} from '@ranuts/agent-core/runtime'; import { createDocumentAgentTools, type DocumentAgentToolOptions } from './document-agent-tools'; import { t } from '@ranuts/shared/i18n'; @@ -36,11 +42,7 @@ async function pdfEffect(provider: LLMProvider, request: string, signal?: AbortS ? await provider.generateJSON(messages, schema, signal) : await provider.chat(messages, [], signal); signal?.throwIfAborted(); - if ( - response.toolCalls.length || - ['length', 'max_tokens'].includes(response.stopReason) || - response.text.length > 1000 - ) + if (response.toolCalls.length || incompleteResponseReason(response) || response.text.length > 1000) throw new Error('Incomplete document task response'); const value = JSON.parse(response.text.replace(/^\s*\s*<\/think>\s*/, '').trim()); if ( @@ -197,9 +199,17 @@ export function createSchemaTaskProvider(provider: LLMProvider, context: () => D `Conversation and actual results: ${JSON.stringify(messages)}`, ].join('\n'); const request = [{ role: 'user' as const, content: prompt }]; - const display = onDelta ? new JsonTextStream(onDelta, { answerOnly: true }) : undefined; + let partialAnswer = ''; + const display = new JsonTextStream( + (delta) => { + partialAnswer += delta; + onDelta?.(delta); + }, + { answerOnly: true }, + ); + const streaming = onDelta && provider.generateJSONStream; const response = - display && provider.generateJSONStream + streaming && provider.generateJSONStream ? await provider.generateJSONStream( request, schema, @@ -212,12 +222,17 @@ export function createSchemaTaskProvider(provider: LLMProvider, context: () => D ? await provider.generateJSON(request, schema, signal) : await provider.chat(request, [], signal); signal?.throwIfAborted(); - if ( - response.toolCalls.length || - ['length', 'max_tokens'].includes(response.stopReason) || - response.text.length > 20000 - ) - throw new Error('Incomplete document task response'); + if (incompleteResponseReason(response)) { + // Decode answer prose only; a partial JSON tool envelope is never executable. + if (!streaming) display.push(response.text); + return { + ...response, + text: partialAnswer, + toolCalls: [], + assistant: { role: 'assistant', content: partialAnswer }, + }; + } + if (response.toolCalls.length || response.text.length > 20000) throw new Error('Incomplete document task response'); const output: unknown = JSON.parse(response.text.trim().replace(/^\s*\s*<\/think>\s*/, '')); if (!output || typeof output !== 'object' || Array.isArray(output)) throw new Error('Invalid document task response'); @@ -247,7 +262,7 @@ export function createSchemaTaskProvider(provider: LLMProvider, context: () => D !['answered', 'partial', 'unsupported'].includes(String(value.disposition)) ) throw new Error('Invalid document task answer'); - display?.finish(value.text); + if (streaming) display.finish(value.text); return { ...response, text: value.text, diff --git a/lib/agent-plugin/document-tool-plan.ts b/lib/agent-plugin/document-tool-plan.ts index 18c4edbc0..305f1e26f 100644 --- a/lib/agent-plugin/document-tool-plan.ts +++ b/lib/agent-plugin/document-tool-plan.ts @@ -1,3 +1,4 @@ +import { incompleteResponseReason } from '@ranuts/agent-core/llm/completion'; import { validateToolInput } from '@ranuts/agent-core/runtime'; import type { LLMProvider } from '@ranuts/agent-core/llm/types'; import type { AgentTool } from '@ranuts/agent-core/types'; @@ -491,7 +492,7 @@ export async function generateDocumentToolPlan( ? await provider.generateJSON(messages, schema, signal) : await provider.chat(messages, [], signal); signal.throwIfAborted(); - if (response.toolCalls.length || ['length', 'max_tokens'].includes(response.stopReason)) + if (response.toolCalls.length || incompleteResponseReason(response)) throw new Error('Incomplete document operation response'); const plan = parseDocumentToolPlan(response.text, context); if ( diff --git a/lib/agent-plugin/tools.ts b/lib/agent-plugin/tools.ts index 52ee4e755..9fc15a4a0 100644 --- a/lib/agent-plugin/tools.ts +++ b/lib/agent-plugin/tools.ts @@ -19,6 +19,7 @@ import { textToHtml, wordContent } from './word-content'; export { textToHtml } from './word-content'; import { requireEditorApi, requireEditorContext } from './editor-bridge'; import type { AgentTool } from '@ranuts/agent-core/types'; +import { fitTextResult, minimumTextResult } from '@ranuts/agent-core/runtime'; import { createSlideTool } from './presentation-content'; import { setBoldTool, setParagraphAlignmentTool } from './formatting-tools'; import { readPresentationShapeText } from './slide-text-read'; @@ -160,6 +161,7 @@ const DEFAULT_MAX_CHARS = 8000; export const getDocumentTextTool: AgentTool = { name: 'get_document_text', + fitResult: { minimum: minimumTextResult, fit: fitTextResult }, description: 'Read source text from the current Word document so the application can answer factual questions about it. ' + 'Use this when a question refers to the open document, including when no text is selected. ' + @@ -196,6 +198,7 @@ export const getPresentationTextTool: AgentTool< { text: string; truncated: boolean; slidesRead: number; totalSlides: number } > = { name: 'get_presentation_text', + fitResult: { minimum: minimumTextResult, fit: fitTextResult }, description: 'Presentation only. Read plain text from slide text shapes and groups without moving the selection. Does not read images, charts or speaker notes.', inputSchema: getDocumentTextTool.inputSchema, diff --git a/lib/agent-plugin/ui/controller.ts b/lib/agent-plugin/ui/controller.ts index 9ce174cd5..1ff332517 100644 --- a/lib/agent-plugin/ui/controller.ts +++ b/lib/agent-plugin/ui/controller.ts @@ -65,7 +65,7 @@ export interface TurnRequest { export interface TurnResult { turnId: string; - status: 'success' | 'stopped' | 'failed' | 'limited' | 'review-pending' | 'superseded'; + status: 'success' | 'stopped' | 'failed' | 'limited' | 'incomplete' | 'review-pending' | 'superseded'; } interface ActiveRun { @@ -252,13 +252,13 @@ export class AgentChatController { if (event.streamed && this.options.onAgentDelta) { displayStream.finish(); if (!live()) return; - this.options.onAgentStreamEnd?.(); + if (!event.interrupted) this.options.onAgentStreamEnd?.(); } else if (event.text) { const text = assistantPresentation(event.text); if (text) { run.timing?.filteredText(); visibleAnswer = true; - emitTurn({ role: 'agent', text }); + emitTurn({ role: 'agent', text, ...(event.interrupted ? { interrupted: true as const } : {}) }); } } resetSegment(); @@ -273,6 +273,12 @@ export class AgentChatController { if (result.aborted || run.abort.signal.aborted) { saveStoppedTurn(); return outcome('stopped'); + } else if (result.incompleteReason) { + const text = t('agentResponseIncomplete'); + this.history.push({ role: 'assistant', content: text, hostGuidance: 'status' } as LLMMessage); + saveHistory(); + emitTurn({ role: 'status', text }); + return outcome('incomplete'); } else if (result.stoppedOnLimit) { const text = documentTaskStopText(result.limitReason, verifiedChanges); const receipt = { role: 'assistant' as const, content: text, hostGuidance: 'tool' as const }; diff --git a/lib/agent-plugin/ui/document-draft.ts b/lib/agent-plugin/ui/document-draft.ts index 3e14e3dfd..b4d78d530 100644 --- a/lib/agent-plugin/ui/document-draft.ts +++ b/lib/agent-plugin/ui/document-draft.ts @@ -1,3 +1,4 @@ +import { incompleteResponseReason } from '@ranuts/agent-core/llm/completion'; import { JsonTextStream } from '@ranuts/agent-core/llm/json-text-stream'; import type { LLMProvider } from '@ranuts/agent-core/llm/types'; @@ -49,11 +50,7 @@ export async function generateDocumentDraft( ? await provider.generateJSON(messages, schema, signal) : await provider.chat(messages, [], signal); signal.throwIfAborted(); - if ( - response.toolCalls.length || - ['length', 'max_tokens'].includes(response.stopReason) || - response.text.length > 20000 - ) + if (response.toolCalls.length || incompleteResponseReason(response) || response.text.length > 20000) throw new Error('Incomplete writing response'); const result: unknown = JSON.parse(response.text.replace(/^\s*\s*<\/think>\s*/, '').trim()); if ( diff --git a/lib/agent-plugin/ui/panel.ts b/lib/agent-plugin/ui/panel.ts index 54762f535..15ffe85e5 100644 --- a/lib/agent-plugin/ui/panel.ts +++ b/lib/agent-plugin/ui/panel.ts @@ -35,6 +35,7 @@ import { ButtonBuilder, Div, Label, Span, View, createEffect, signal } from 'ran import { localStorageGetItem, localStorageSetItem } from 'ranuts/utils'; import { getLanguage, t } from '@ranuts/shared/i18n'; import { getReadonlyMode } from '../../onlyoffice/readonly'; +import { claimAssistantFocus, ownsAssistantFocus } from '../../onlyoffice/guards/assistant-focus'; import { mountReviewSettings } from './review-settings'; import { createModelCacheSettings } from './model-cache-settings'; import { mountPanelContext, syncPanelContext } from './panel-context'; @@ -259,9 +260,31 @@ export function createAgentPanel( if (next) queueMicrotask(() => { if (open) { + claimAssistantFocus(panel); if (panel.dataset.view === 'settings') settingsHeading.focus(); else if (panel.dataset.view === 'history') conversationList?.focus(); - else if (!navigation.restore(displayedConversationId, true)) chat.focus(); + else if (!navigation.restore(displayedConversationId, true)) { + const target = chat.el.querySelector('.cui-input'); + const owner = document.activeElement; + const view = panel.dataset.view; + const revision = viewRevision; + chat.focus(); + // An iframe can retain focus while the dock layout settles. Retry + // the fallback composer once without replaying historical state. + if (target && document.activeElement === owner && !panel.contains(owner)) { + requestAnimationFrame(() => { + if ( + open && + panel.isConnected && + panel.dataset.view === view && + viewRevision === revision && + ownsAssistantFocus(panel) && + document.activeElement === owner + ) + target.focus(); + }); + } + } } }); }; @@ -2233,6 +2256,16 @@ export function createAgentPanel( if (generation !== controllerGeneration || conversation !== conversationRevision) throw new Error(t('agentPlanExpired')); if (result.aborted) return measured('stopped'); + if (result.incompleteReason) { + taskDisplay.finish(); + if (!partialTaskAnswer && result.text) + appendTurn({ role: 'agent', text: assistantPresentation(result.text), interrupted: true }); + const text = t('agentResponseIncomplete'); + operationHistory.push({ role: 'assistant', content: text, hostGuidance: 'status' }); + appendTurn({ role: 'status', text }); + partialTaskAnswer = ''; + return measured('incomplete'); + } if (result.stoppedOnLimit) { const text = documentTaskStopText(result.limitReason, verifiedTaskChanges); operationHistory.push({ role: 'assistant', content: text, hostGuidance: 'tool' }); diff --git a/lib/agent-plugin/ui/request-intent.ts b/lib/agent-plugin/ui/request-intent.ts index 5dd853866..6c1f5d3f3 100644 --- a/lib/agent-plugin/ui/request-intent.ts +++ b/lib/agent-plugin/ui/request-intent.ts @@ -1,3 +1,4 @@ +import { incompleteResponseReason } from '@ranuts/agent-core/llm/completion'; import type { WritingLanguage } from '@ranuts/agent-core/llm/writing-task'; import { WRITING_LANGUAGES } from '@ranuts/agent-core/llm/writing-task'; import type { LLMProvider } from '@ranuts/agent-core/llm/types'; @@ -114,11 +115,7 @@ export async function resolveRequestIntent( ? await provider.generateJSON(messages, schema, signal) : await provider.chat(messages, [], signal); signal.throwIfAborted(); - if ( - response.toolCalls.length || - ['length', 'max_tokens'].includes(response.stopReason) || - response.text.length > 1000 - ) + if (response.toolCalls.length || incompleteResponseReason(response) || response.text.length > 1000) throw new Error('Incomplete task routing response'); const output: unknown = JSON.parse(response.text.replace(/^\s*\s*<\/think>\s*/, '').trim()); if ( diff --git a/lib/agent-plugin/ui/request-timing.ts b/lib/agent-plugin/ui/request-timing.ts index 33507cd53..53c3bccf9 100644 --- a/lib/agent-plugin/ui/request-timing.ts +++ b/lib/agent-plugin/ui/request-timing.ts @@ -1,6 +1,7 @@ import type { LLMResponse } from '@ranuts/agent-core/llm/types'; -export type TimingStatus = 'success' | 'stopped' | 'failed' | 'limited' | 'review-pending' | 'superseded'; +export type TimingStatus = + 'success' | 'stopped' | 'failed' | 'limited' | 'incomplete' | 'review-pending' | 'superseded'; export interface RequestTimingRecord { status?: TimingStatus; durationMs?: number; diff --git a/lib/agent-plugin/ui/sidebar-entry.ts b/lib/agent-plugin/ui/sidebar-entry.ts index 1b2f0e8fd..79f78ea15 100644 --- a/lib/agent-plugin/ui/sidebar-entry.ts +++ b/lib/agent-plugin/ui/sidebar-entry.ts @@ -70,6 +70,8 @@ export function createSidebarEntry(toggle: () => void, label: () => string, isAv ''; button.style.cssText = 'display:flex;align-items:center;justify-content:center;padding:0;color:inherit;cursor:pointer;'; + // The vendor rail schedules editor focus on mouse presses. + button.addEventListener('mousedown', (event) => event.stopPropagation()); button.addEventListener('click', (event) => { event.stopPropagation(); if (enabled()) toggle(); diff --git a/lib/document-open.ts b/lib/document-open.ts index ff11062a4..2846706aa 100644 --- a/lib/document-open.ts +++ b/lib/document-open.ts @@ -1,3 +1,5 @@ +import { invalidateDocumentLifetime } from './onlyoffice/open-state'; + /** Shared ownership for local, new and embedded document opens. */ let revision = 0; let current: AbortController | undefined; @@ -35,6 +37,7 @@ export function isMountedDocumentOpen(id: number): boolean { } export function cancelDocumentOpen(): void { + invalidateDocumentLifetime('Document closed'); current?.abort(new DOMException('Document closed', 'AbortError')); current = undefined; mountedRevision = closedRevision = ++revision; diff --git a/lib/document.ts b/lib/document.ts index 6782681b2..cfa08add7 100644 --- a/lib/document.ts +++ b/lib/document.ts @@ -2,7 +2,7 @@ import { beginDocumentOpen, commitDocumentOpen, isMountedDocumentOpen } from './ import { fetchRemoteDocument } from './remote-document'; import { remoteFileName } from './remote-file-name'; import { View } from 'ranui/builder'; -import { getDocmentObj, setDocmentObj } from '@ranuts/shared/store'; +import { setDocmentObj } from '@ranuts/shared/store'; import { t } from '@ranuts/shared/i18n'; import 'ranui/message'; import { handleDocumentOperation, loadEditorApi } from './converter'; @@ -48,15 +48,12 @@ export const onCreateNew = async (ext: string, options?: { docId?: string }): Pr if (hideControlPanelFn) { hideControlPanelFn(); } - setDocmentObj({ - fileName: 'Untitled' + ext, - file: undefined, - }); + const fileName = 'Untitled' + ext; await loadEditorApi(); opening.assertCurrent(); - const { fileName, file: fileBlob } = getDocmentObj(); - await handleDocumentOperation({ file: fileBlob, fileName, isNew: !fileBlob, isCurrent: opening.isCurrent }); + await handleDocumentOperation({ fileName, isNew: true, isCurrent: opening.isCurrent }); if (!commitDocumentOpen(operation)) return; + setDocmentObj({ fileName, file: undefined }); clearDocumentSession(); await clearActiveSource(); if (!isMountedDocumentOpen(operation)) return; @@ -92,18 +89,18 @@ export const openLocalFile = async ( hideControlPanelFn(); } opening.assertCurrent(); - // The editor owns the URL it creates from these bytes. This store needs - // only the File; a second Blob URL would retain every replaced original. - setDocmentObj({ fileName: file.name, file }); - const { fileName, file: fileBlob } = getDocmentObj(); await handleDocumentOperation({ - file: fileBlob, - fileName, - isNew: !fileBlob, + file, + fileName: file.name, + isNew: false, readonly: options?.readonly, isCurrent: opening.isCurrent, }); if (!commitDocumentOpen(operation)) throw new DOMException('Document open superseded', 'AbortError'); + // Publish only the mounted file: failed preprocessing leaves the previous + // editor and its exact source identity available to pending saves. + // The editor owns its Blob URL; the store needs only the original File. + setDocmentObj({ fileName: file.name, file }); // The replacement editor now owns exports; retire the previous identity // before asynchronous source storage, while delaying URL stamping until commit. clearDocumentSession(); @@ -211,24 +208,20 @@ export const openDocumentFromUrl = async ( // Get file blob const file = new File([remote.blob], finalFileName, { type: remote.blob.type }); - // Set document object opening.assertCurrent(); - setDocmentObj({ fileName: finalFileName, file }); - - // Initialize and open document - const { fileName: docFileName, file: fileBlob } = getDocmentObj(); await handleDocumentOperation({ - file: fileBlob, - fileName: docFileName, - isNew: !fileBlob, + file, + fileName: finalFileName, + isNew: false, readonly: options?.readonly, isCurrent: opening.isCurrent, }); if (!commitDocumentOpen(operation)) throw new DOMException('Document open superseded', 'AbortError'); + setDocmentObj({ fileName: finalFileName, file }); clearDocumentSession(); await clearActiveSource(); if (!isMountedDocumentOpen(operation)) throw new DOMException('Document open superseded', 'AbortError'); - startDocumentSession({ title: docFileName, origin: 'url', docId: options?.docId }); + startDocumentSession({ title: finalFileName, origin: 'url', docId: options?.docId }); } catch (error) { if (!opening.isCurrent() || (error as { name?: string })?.name === 'AbortError') throw error; console.error('Error opening document from URL:', error); diff --git a/lib/embed-api.ts b/lib/embed-api.ts index fadc730dd..4f86dcd83 100644 --- a/lib/embed-api.ts +++ b/lib/embed-api.ts @@ -131,10 +131,6 @@ async function openFile(file: File, readonly = false): Promise { const opening = beginDocumentOpen(); await loadEditorApi(); opening.assertCurrent(); - setDocmentObj({ - fileName: file.name, - file, - }); await handleDocumentOperation({ fileName: file.name, file, @@ -143,6 +139,7 @@ async function openFile(file: File, readonly = false): Promise { isCurrent: opening.isCurrent, }); if (!commitDocumentOpen(opening.id)) throw new DOMException('Document open superseded', 'AbortError'); + setDocmentObj({ fileName: file.name, file }); clearDocumentSession(); await clearActiveSource(); if (!isMountedDocumentOpen(opening.id)) throw new DOMException('Document open superseded', 'AbortError'); diff --git a/lib/events.ts b/lib/events.ts index 8b67652e6..a2377b891 100644 --- a/lib/events.ts +++ b/lib/events.ts @@ -3,7 +3,7 @@ import { beginDocumentOpen, cancelDocumentOpen, commitDocumentOpen } from './doc import { clearDocumentSession } from './history/autosave'; import { MessageCodec, Platform } from 'ranuts/utils'; import type { MessageHandler } from 'ranuts/utils'; -import { getDocmentObj, setDocmentObj } from '@ranuts/shared/store'; +import { setDocmentObj } from '@ranuts/shared/store'; import { handleDocumentOperation } from './converter'; import { showLoading } from './loading'; @@ -47,13 +47,9 @@ export const events: Record> = { try { const file = await MessageCodec.decodeFileChunked(chunks); opening.assertCurrent(); - setDocmentObj({ - fileName: file.name, - file: file, - }); - const { fileName, file: fileBlob } = getDocmentObj(); - await handleDocumentOperation({ file: fileBlob, fileName, isNew: !fileBlob, isCurrent: opening.isCurrent }); + await handleDocumentOperation({ file, fileName: file.name, isNew: false, isCurrent: opening.isCurrent }); if (commitDocumentOpen(opening.id)) { + setDocmentObj({ fileName: file.name, file }); clearDocumentSession(); await clearActiveSource(); } diff --git a/lib/onlyoffice/guards/assistant-focus.ts b/lib/onlyoffice/guards/assistant-focus.ts new file mode 100644 index 000000000..6ea813157 --- /dev/null +++ b/lib/onlyoffice/guards/assistant-focus.ts @@ -0,0 +1,157 @@ +/** + * Guard 18: keep unsolicited SDK focus from taking a control the assistant owns. + */ +const owners = new WeakMap(); +const watchedParents = new WeakSet(); +const watchedEditors = new WeakSet(); +const guardedPrototypes = new WeakSet(); + +function visiblePanel(target: HTMLElement): HTMLElement | null { + if (!target.isConnected) return null; + const panel = target.closest('.agent-runtime-panel'); + if (!panel || target.closest('[hidden], .agent-panel-hidden')) return null; + const win = target.ownerDocument.defaultView; + for (let element: HTMLElement | null = target; element; element = element.parentElement) { + const style = win?.getComputedStyle(element); + if (style?.display === 'none' || style?.visibility === 'hidden' || style?.visibility === 'collapse') return null; + } + return panel; +} + +function currentOwner(doc: Document): HTMLElement | undefined { + const owner = owners.get(doc); + if (owner && !visiblePanel(owner)) { + owners.delete(doc); + return undefined; + } + return owner; +} + +function watchParent(doc: Document): void { + if (watchedParents.has(doc)) return; + watchedParents.add(doc); + const onIntent = (event: Event) => { + const view = doc.defaultView; + let target = event.target; + // Pointer events may originate from an SVG inside a button. + while (view && target instanceof view.Element && !(target instanceof view.HTMLElement)) + target = target.parentElement; + if (view && target instanceof view.HTMLElement) claimAssistantFocus(target); + else owners.delete(doc); + }; + doc.addEventListener('focusin', onIntent, true); + doc.addEventListener( + 'pointerdown', + (event) => { + onIntent(event); + const target = currentOwner(doc); + if (!target?.matches('input, textarea, button, select, a[href], [tabindex], [contenteditable="true"]')) return; + // Firefox can ignore the native parent click while the SDK input owns focus. + // Retry only that explicit handoff once, and yield to every newer intent. + doc.defaultView?.requestAnimationFrame(() => { + if (currentOwner(doc) !== target) return; + const active = doc.activeElement; + try { + if (active?.tagName === 'IFRAME' && watchedEditors.has((active as HTMLIFrameElement).contentDocument!)) + target.focus({ preventScroll: true }); + } catch { + // A navigating frame no longer owns an available editor document. + } + }); + }, + true, + ); + doc.addEventListener( + 'keydown', + (event) => { + if (event.key === 'Tab') owners.delete(doc); + }, + true, + ); +} + +/** Claim before focusing: leaving the iframe can synchronously request SDK focus. */ +export function claimAssistantFocus(target: HTMLElement): void { + const doc = target.ownerDocument; + watchParent(doc); + if (visiblePanel(target)) owners.set(doc, target); + else owners.delete(doc); +} + +/** A pending fallback must yield when an explicit editor interaction retired its claim. */ +export function ownsAssistantFocus(panel: HTMLElement): boolean { + const owner = currentOwner(panel.ownerDocument); + return !!owner && panel.contains(owner); +} + +type FocusApi = { getFocusElement?: unknown; WordControl?: { TextBoxInput?: unknown } }; +type FocusGlobals = { + Asc?: { editor?: FocusApi }; + editor?: FocusApi; + AscCommon?: { g_inputContext?: { HtmlArea?: unknown } }; +}; + +function readOptional(read: () => T): T | undefined { + try { + return read(); + } catch { + return undefined; + } +} + +function isDocumentFocusTarget(win: Window, target: HTMLElement): boolean { + const doc = win.document; + if (target.ownerDocument !== doc) return false; + if (target === doc.getElementById('editor_sdk') || target === doc.getElementById('area_id')) return true; + // Resolve the live SDK input on every call: it may be replaced after loading. + // Modal buttons and toolbar controls must retain their native focus behavior. + const frame = win as unknown as FocusGlobals; + const api = readOptional(() => frame.Asc?.editor) ?? readOptional(() => frame.editor); + return ( + readOptional(() => { + const getFocusElement = api?.getFocusElement; + return typeof getFocusElement === 'function' ? getFocusElement.call(api) : undefined; + }) === target || + readOptional(() => api?.WordControl?.TextBoxInput) === target || + readOptional(() => frame.AscCommon?.g_inputContext?.HtmlArea) === target + ); +} + +/** Install only in the SDK's editor realm, leaving native focus unchanged otherwise. */ +export function installAssistantFocusGuard(win: Window): boolean { + try { + if (win.parent === win || !win.document.getElementById('editor_sdk')) return false; + const parentDoc = win.parent.document; + const doc = win.document; + watchParent(parentDoc); + if (!watchedEditors.has(doc)) { + watchedEditors.add(doc); + const release = () => owners.delete(parentDoc); + doc.addEventListener('pointerdown', release, true); + doc.addEventListener('mousedown', release, true); + doc.addEventListener( + 'keydown', + (event) => { + if (event.key === 'Tab') release(); + }, + true, + ); + } + const prototype = (win as Window & typeof globalThis).HTMLElement.prototype; + if (guardedPrototypes.has(prototype)) return true; + const focus = prototype.focus; + prototype.focus = function (...args: Parameters) { + if (currentOwner(parentDoc) && isDocumentFocusTarget(win, this)) return; + const result = focus.apply(this, args); + // A native modal/control may legitimately take focus. Do not release a + // newer host claim if one of its focus handlers already returned there. + if (doc.activeElement === this && parentDoc.activeElement === win.frameElement) owners.delete(parentDoc); + return result; + }; + guardedPrototypes.add(prototype); + return true; + } catch { + // A navigating or cross-origin frame is not an available editor target. + return false; + } +} diff --git a/lib/onlyoffice/iframe-guards.ts b/lib/onlyoffice/iframe-guards.ts index 0c947355d..7c8de3920 100644 --- a/lib/onlyoffice/iframe-guards.ts +++ b/lib/onlyoffice/iframe-guards.ts @@ -16,6 +16,7 @@ import { installHintFallbackGuard } from './guards/hint-fallback'; import { installAboutSourceNotice } from './guards/about-source'; import { installBadImageUrlGuard } from './guards/bad-image-url'; import { installX2tWorkerProxy } from './guards/x2t-worker'; +import { installAssistantFocusGuard } from './guards/assistant-focus'; /** * Same-origin preparation of the editor iframe, applied from onAppReady and @@ -29,8 +30,8 @@ import { installX2tWorkerProxy } from './guards/x2t-worker'; * production bug that reached users. * * Returns true once every guard whose absence would still be felt later has - * landed on some frame, so the caller can stop re-applying: the five that must - * be in place before a document can be opened and saved, plus the wasm binary + * landed on some frame, so the caller can stop re-applying: the guards required + * before a document can be opened and saved, focus ownership, and the wasm binary * release, which reports "in place" once it is watching for x2t rather than * once it has released -- x2t can load long after this timer is gone (see * ./guards/wasm-binary-release). @@ -45,6 +46,7 @@ export function prepareEditorIframe(): boolean { installOpenFailureGuard(win); injectLocalChromeCss(doc); + const assistantFocusGuarded = installAssistantFocusGuard(win); const sharedWorkerShadowed = shadowSharedWorker(win); const fetchFontsGuarded = installFetchFontsGuard(win); @@ -72,7 +74,8 @@ export function prepareEditorIframe(): boolean { saveSemanticsInstalled && longActionGuarded && x2tWorkerProxied && - wasmBinaryHandled + wasmBinaryHandled && + assistantFocusGuarded ) { fullyApplied = true; } diff --git a/lib/onlyoffice/open-state.ts b/lib/onlyoffice/open-state.ts index 62f2865ad..918506b62 100644 --- a/lib/onlyoffice/open-state.ts +++ b/lib/onlyoffice/open-state.ts @@ -16,6 +16,16 @@ // pointless wait. let documentContentReady = false; let contentReadyWaiters: Array<() => void> = []; +let documentLifetime = new AbortController(); + +/** Saves belong to a mounted editor, independently of pending open attempts. */ +export function getDocumentLifetimeSignal(): AbortSignal { + return documentLifetime.signal; +} + +export function invalidateDocumentLifetime(reason: string): void { + documentLifetime.abort(new DOMException(reason, 'AbortError')); +} // Set when the editor's own open conversion failed for the current document // (see installOpenFailureGuard); pending and future saves reject with it // instead of waiting out the readiness timeout. @@ -74,6 +84,8 @@ export function markDocumentContentReady(): void { // Called once per document open (v9 only) to drop any stale ready-state from // a previously open document before the new one's own readiness is known. export function resetOpenState(): void { + invalidateDocumentLifetime('Document replaced'); + documentLifetime = new AbortController(); documentContentReady = false; documentOpenError = null; lastFrameError = null; @@ -92,14 +104,26 @@ export function markDocumentOpenFailed(reason: string): void { // Resolves immediately if already ready; otherwise waits for // markDocumentContentReady() (or the safety-net timeout) so callers never // hang forever. -export function waitForDocumentContentReady(timeoutMs = 15000): Promise { +export function waitForDocumentContentReady(timeoutMs = 15000, signal?: AbortSignal): Promise { + if (signal?.aborted) return Promise.reject(signal.reason); if (documentContentReady) return Promise.resolve(); - return new Promise((resolve) => { - const timer = setTimeout(resolve, timeoutMs); - contentReadyWaiters.push(() => { + return new Promise((resolve, reject) => { + const cleanup = () => { clearTimeout(timer); + signal?.removeEventListener('abort', abort); + contentReadyWaiters = contentReadyWaiters.filter((waiter) => waiter !== ready); + }; + const ready = () => { + cleanup(); resolve(); - }); + }; + const abort = () => { + cleanup(); + reject(signal?.reason); + }; + const timer = setTimeout(ready, timeoutMs); + contentReadyWaiters.push(ready); + signal?.addEventListener('abort', abort, { once: true }); }); } diff --git a/lib/onlyoffice/save-stream.ts b/lib/onlyoffice/save-stream.ts index 7af55b2fc..3ba3937b3 100644 --- a/lib/onlyoffice/save-stream.ts +++ b/lib/onlyoffice/save-stream.ts @@ -3,7 +3,7 @@ import { t } from '@ranuts/shared/i18n'; import { X2TConverter, saveFileToDisk } from '@ranuts/converter'; import { oAscFileType } from '../file-types'; import { getFileExtension, getNormalizedFile, getSavedFileMimeType } from './file-helpers'; -import { getDocumentOpenError, waitForDocumentContentReady } from './open-state'; +import { getDocumentLifetimeSignal, getDocumentOpenError, waitForDocumentContentReady } from './open-state'; import { getReadonlyMode } from './readonly'; import { isEmbedMode } from '../embed-mode'; import { markDocumentSaved } from '../unsaved-guard'; @@ -51,9 +51,39 @@ type EmbeddedSaveRequest = { timeoutId: number; fallbackId: number; settled: boolean; + isCurrent: () => boolean; + signal: AbortSignal; + onAbort: () => void; }; let embeddedSaveRequest: EmbeddedSaveRequest | null = null; +// Carry ownership through SDK serialization, including options retained while +// pagination defers a download. Later toolbar saves keep their own destination. +let triggeringSaveRequest: EmbeddedSaveRequest | null = null; +const nativeExportRequests = new WeakMap(); +type NativeExportMethod = (this: Record, ...args: unknown[]) => unknown; +const trackedNativeMethods = new WeakSet(); + +function installNativeExportOwnership(win: Window): void { + const api = (win as Window & { Asc?: { editor?: Record } }).Asc?.editor; + if (!api) return; + for (const name of ['downloadAs', '_downloadAsFromLocal']) { + const method = api[name] as NativeExportMethod | undefined; + if (typeof method !== 'function' || trackedNativeMethods.has(method)) continue; + const tracked: NativeExportMethod = function (...args) { + const request = args + .filter((arg): arg is object => !!arg && typeof arg === 'object') + .map((arg) => nativeExportRequests.get(arg)) + .find((request) => request !== undefined); + // CheckPrint retains these exact options across pagination/reader-mode + // work and resumes via downloadAs after the initial call has returned. + if (request) return runRequestedExport(request, () => method.apply(this, args)); + return method.apply(this, args); + }; + trackedNativeMethods.add(tracked); + api[name] = tracked; + } +} // requestSaveDocument budget. Readiness (onDocumentReady) may take long on a // cold, slow link: the editor first downloads and inflates x2t.wasm @@ -83,6 +113,7 @@ function rejectEmbeddedSaveRequest(request: EmbeddedSaveRequest, error: Error): function cleanupEmbeddedSaveRequest(request: EmbeddedSaveRequest): void { window.clearTimeout(request.timeoutId); window.clearTimeout(request.fallbackId); + request.signal.removeEventListener('abort', request.onAbort); if (embeddedSaveRequest === request) { embeddedSaveRequest = null; } @@ -116,7 +147,7 @@ interface FileStreamMessage { // lib/converter's singleton. let sheetJsConverter: X2TConverter | null = null; -function standaloneExportState(win: Window, sequence = 0) { +function standaloneExportState(win: Window, sequence = 0, request: EmbeddedSaveRequest | null = null) { const source = getDocmentObj(); const editor = window.editor; const point = captureSavePoint(); @@ -124,7 +155,7 @@ function standaloneExportState(win: Window, sequence = 0) { source, sequence, point, - request: embeddedSaveRequest, + request, isCurrent: () => getDocmentObj() === source && window.editor === editor && @@ -138,6 +169,7 @@ const writtenSequences = new WeakMap(); /** Bind serialized bytes to their conversion, then queue them in actual stream order. */ export function installSaveStreamTracking(win: Window): boolean { + installNativeExportOwnership(win); type Result = { binary: object }; type Converter = { convertFromBin(request: unknown): Promise; @@ -156,7 +188,9 @@ export function installSaveStreamTracking(win: Window): boolean { const source = getDocmentObj(); const sequence = (exportSequences.get(source) ?? 0) + 1; exportSequences.set(source, sequence); - const state = standaloneExportState(win, sequence); + const candidate = triggeringSaveRequest; + const request = candidate?.isCurrent() ? candidate : null; + const state = standaloneExportState(win, sequence, request); return convert.call(this, input).then((result) => { if (result?.binary && typeof result.binary === 'object') outputs.set(result.binary, state); return result; @@ -179,6 +213,7 @@ function routeSavedFile(file: File, state: ReturnType void; isLoadFullApi?: boolean; isDocumentLoadComplete?: boolean; + isLongAction?: () => boolean; } | undefined; if (api && typeof api.asc_DownloadAs === 'function' && AscNs?.asc_CDownloadOptions) { @@ -325,8 +361,13 @@ function triggerPersonalDownloadAs(targetExt: string): boolean { // of undefined (reading 'forEach')") or is silently dropped. Only // fire once the SDK itself reports full readiness -- the caller's // retry loop keeps polling until then. - if (!api.isLoadFullApi || !api.isDocumentLoadComplete) return false; - api.asc_DownloadAs(new AscNs.asc_CDownloadOptions(code)); + if (!api.isLoadFullApi || !api.isDocumentLoadComplete || api.isLongAction?.()) return false; + // The readiness callback and guard-install retry can race on a cold + // load. Never start an export whose conversion cannot carry its owner. + if (!installSaveStreamTracking(window.frames[i])) return false; + const options = new AscNs.asc_CDownloadOptions!(code) as object; + nativeExportRequests.set(options, request); + runRequestedExport(request, () => api.asc_DownloadAs!(options)); return true; } } catch { @@ -336,6 +377,25 @@ function triggerPersonalDownloadAs(targetExt: string): boolean { return false; } +function ensureCurrentSaveRequest(request: EmbeddedSaveRequest): boolean { + if (request.settled) return false; + if (request.isCurrent()) return true; + cleanupEmbeddedSaveRequest(request); + rejectEmbeddedSaveRequest(request, new DOMException('Document changed before save completed', 'AbortError')); + return false; +} + +function runRequestedExport(request: EmbeddedSaveRequest, action: () => T): T | undefined { + if (!ensureCurrentSaveRequest(request)) return; + const previous = triggeringSaveRequest; + triggeringSaveRequest = request; + try { + return action(); + } finally { + triggeringSaveRequest = previous; + } +} + export function requestSaveDocument( targetExt = 'XLSX', options: { @@ -359,19 +419,21 @@ export function requestSaveDocument( return Promise.reject(new Error('A save request is already in progress')); } + const source = getDocmentObj(); + const editor = window.editor; + const signal = getDocumentLifetimeSignal(); + if (signal.aborted) return Promise.reject(signal.reason); + return new Promise((resolve, reject) => { const normalizedTargetExt = targetExt.toUpperCase(); const fallbackId = window.setTimeout(() => { - if (!embeddedSaveRequest || embeddedSaveRequest.settled) { - return; - } + if (!ensureCurrentSaveRequest(request)) return; - const { file, fileName } = getDocmentObj() || {}; + const { file, fileName } = source; const originalExt = getFileExtension(fileName || file?.name || ''); if (options.returnOriginalOnTimeout && file && originalExt === normalizedTargetExt) { - const request = embeddedSaveRequest; cleanupEmbeddedSaveRequest(request); resolveEmbeddedSaveRequest(request, getNormalizedFile(file)); } @@ -385,26 +447,29 @@ export function requestSaveDocument( // open-conversion failure rejects the request immediately // (installOpenFailureGuard). const timeoutId = window.setTimeout(() => { - if (!embeddedSaveRequest) { - return; - } - const request = embeddedSaveRequest; + if (!ensureCurrentSaveRequest(request)) return; cleanupEmbeddedSaveRequest(request); rejectEmbeddedSaveRequest(request, new Error('Save request timed out before receiving edited file data')); }, SAVE_REQUEST_TIMEOUT_MS); - embeddedSaveRequest = { + const request: EmbeddedSaveRequest = { targetExt: normalizedTargetExt, resolve, reject, timeoutId, fallbackId, settled: false, + signal, + isCurrent: () => !signal.aborted && getDocmentObj() === source && window.editor === editor, + onAbort: () => { + cleanupEmbeddedSaveRequest(request); + rejectEmbeddedSaveRequest(request, signal.reason); + }, }; + embeddedSaveRequest = request; + signal.addEventListener('abort', request.onAbort, { once: true }); - const editor = window.editor; if (!editor || typeof editor.downloadAs !== 'function') { - const request = embeddedSaveRequest; cleanupEmbeddedSaveRequest(request); rejectEmbeddedSaveRequest(request, new Error('The current editor does not support downloadAs export')); return; @@ -417,13 +482,13 @@ export function requestSaveDocument( // editor boot can take a long time (observed in headless CI / first // preview visits). onDocumentReady is a dependable signal // (createPersonalEditorInstance wires it), so wait for it -- capped - // below the request's own 60 s timeout so the trigger still gets a + // below the request's own timeout so the trigger still gets a // window to fire -- then retry until the SDK reports full readiness // (see triggerPersonalDownloadAs) or the request settles. const downloadAs = editor.downloadAs.bind(editor); - const request = embeddedSaveRequest; void (async () => { - await waitForDocumentContentReady(SAVE_READY_WAIT_MS); + await waitForDocumentContentReady(SAVE_READY_WAIT_MS, signal); + if (!ensureCurrentSaveRequest(request)) return; const failure = getDocumentOpenError(); if (failure && request && !request.settled) { cleanupEmbeddedSaveRequest(request); @@ -431,14 +496,17 @@ export function requestSaveDocument( return; } const retryDeadline = Date.now() + SAVE_RETRY_WINDOW_MS; - while (!request?.settled && !triggerPersonalDownloadAs(normalizedTargetExt)) { + while (ensureCurrentSaveRequest(request) && !triggerPersonalDownloadAs(normalizedTargetExt, request)) { if (Date.now() > retryDeadline) { - downloadAs(normalizedTargetExt); + runRequestedExport(request, () => downloadAs(normalizedTargetExt)); return; } await new Promise((resolve) => setTimeout(resolve, 500)); } - })(); + })().catch((error: Error) => { + cleanupEmbeddedSaveRequest(request); + rejectEmbeddedSaveRequest(request, error); + }); }); } diff --git a/packages/agent-core/src/context-budget.ts b/packages/agent-core/src/context-budget.ts index 284094f64..29029cfc7 100644 --- a/packages/agent-core/src/context-budget.ts +++ b/packages/agent-core/src/context-budget.ts @@ -1,5 +1,6 @@ import { REFERENCE_CITATION_RULE } from './llm/prompt'; -import type { LLMMessage } from './llm/types'; +import type { LLMContent, LLMMessage } from './llm/types'; +import type { AgentTool } from './types'; /** A conservative UTF-8 byte budget, not an exact model-token count. */ export const DEFAULT_CONTEXT_BYTES = 6144; @@ -7,6 +8,103 @@ const encoder = new TextEncoder(); const messageBytes = (message: LLMMessage) => encoder.encode(JSON.stringify({ role: message.role, content: message.content })).byteLength + 16; +/** Payload space left after retaining the whole latest user/tool turn. */ +export function remainingContextBytes(messages: LLMMessage[], maxBytes = DEFAULT_CONTEXT_BYTES): number { + const start = messages.findLastIndex( + (message) => + message.role === 'user' && + (typeof message.content === 'string' || !message.content.some((block) => block.type === 'tool_result')), + ); + return maxBytes - messages.slice(Math.max(0, start)).reduce((sum, message) => sum + messageBytes(message), 0); +} + +type TextField = { parent: Record; characters: string[] }; +function textResultFields(result: unknown): { value: unknown; fields: TextField[] } { + const value = structuredClone(result); + const fields: TextField[] = []; + const visit = (node: unknown): void => { + if (!node || typeof node !== 'object') return; + const parent = node as Record; + if (typeof parent.text === 'string') fields.push({ parent, characters: Array.from(parent.text) }); + for (const child of Object.values(parent)) visit(child); + }; + visit(value); + return { value, fields }; +} +function clipTextFields(fields: TextField[], length: number): void { + for (const { parent, characters } of fields) { + parent.text = characters.slice(0, length).join(''); + if (length < characters.length) parent.truncated = true; + } +} +/** Preserve metadata even when none of the source excerpt can fit. */ +export function minimumTextResult(result: unknown): unknown { + const { value, fields } = textResultFields(result); + clipTextFields(fields, 0); + return value; +} + +/** Opt-in excerpt policy for structured text reads, including host result envelopes. + * Preserve all scope/availability metadata and never split a Unicode code point. */ +export function fitTextResult(result: unknown, fits: (candidate: unknown) => boolean): unknown { + if (fits(result)) return result; + const { value, fields } = textResultFields(result); + const clipped = (length: number) => { + clipTextFields(fields, length); + return value; + }; + if (!fields.length || !fits(clipped(0))) throw new Error('agentContextTooLong'); + let low = 0, + high = Math.max(...fields.map((field) => field.characters.length)); + while (low < high) { + const middle = Math.ceil((low + high) / 2); + if (fits(clipped(middle))) low = middle; + else high = middle - 1; + } + return clipped(low); +} + +type ToolResult = Extract; +export interface BudgetedToolResult { + result: ToolResult; + output: unknown; + policy: NonNullable; +} +/** Refit all opted-in reads as new call/result shells grow the current turn. + * Minimum valid outputs establish the real available space, without reserving + * guessed capacity or changing arbitrary tool results. Oversized immutable + * exchanges stay archival; the next inference enforces its own request budget. */ +export function budgetToolResults( + messages: LLMMessage[], + entries: BudgetedToolResult[], + maxBytes = DEFAULT_CONTEXT_BYTES, +): void { + if (!entries.length) return; + const original = entries.map(({ result }) => result.content); + const restore = () => + entries.forEach(({ result }, index) => { + result.content = original[index]; + }); + const payloadSize = (value: unknown) => encoder.encode(JSON.stringify(JSON.stringify(value ?? null))).byteLength; + try { + for (const { result, output, policy } of entries) result.content = JSON.stringify(policy.minimum(output) ?? null); + if (remainingContextBytes(messages, maxBytes) < 0) { + restore(); + return; + } + for (const [index, { result, output, policy }] of entries.entries()) { + const minimumBytes = encoder.encode(JSON.stringify(result.content)).byteLength; + const extra = Math.floor(remainingContextBytes(messages, maxBytes) / (entries.length - index)); + result.content = JSON.stringify( + policy.fit(output, (candidate) => payloadSize(candidate) <= minimumBytes + extra) ?? null, + ); + } + } catch (error) { + restore(); + throw error; + } +} + /** Optional reference data must never crowd out the user's current turn. */ export function contextualizeMessages( messages: LLMMessage[], diff --git a/packages/agent-core/src/llm/action-plan.ts b/packages/agent-core/src/llm/action-plan.ts index 539e533e1..1745042b7 100644 --- a/packages/agent-core/src/llm/action-plan.ts +++ b/packages/agent-core/src/llm/action-plan.ts @@ -1,3 +1,4 @@ +import { incompleteResponseReason } from './completion'; import type { LLMProvider } from './types'; export type ActionEditor = 'word' | 'cell'; @@ -118,7 +119,7 @@ export async function generateActionPlan( ? await provider.generateJSON(messages, schema, signal) : await provider.chat(messages, [], signal); signal.throwIfAborted(); - if (response.toolCalls?.length || response.stopReason === 'max_tokens' || response.stopReason === 'length') + if (response.toolCalls?.length || incompleteResponseReason(response)) throw new Error('Incomplete or unexpected action response'); const output: unknown = JSON.parse(response.text.replace(/^\s*\s*<\/think>\s*/, '').trim()); if (!exactKeys(output, ['status', 'content']) || typeof output.content !== 'string') diff --git a/packages/agent-core/src/llm/completion.ts b/packages/agent-core/src/llm/completion.ts new file mode 100644 index 000000000..89f685822 --- /dev/null +++ b/packages/agent-core/src/llm/completion.ts @@ -0,0 +1,13 @@ +import type { LLMResponse } from './types'; + +export type IncompleteResponseReason = 'max_tokens' | 'content_filter' | 'provider'; + +/** Normalise provider termination without exposing backend error prose to the host UI. */ +export function incompleteResponseReason( + response: Pick, +): IncompleteResponseReason | undefined { + if (response.stopReason === 'length' || response.stopReason === 'max_tokens') return 'max_tokens'; + if (response.stopReason === 'content_filter') return 'content_filter'; + if (response.stopReason === 'incomplete') return 'provider'; + return undefined; +} diff --git a/packages/agent-core/src/llm/gemini.ts b/packages/agent-core/src/llm/gemini.ts index e4a4c137c..0391c6de2 100644 --- a/packages/agent-core/src/llm/gemini.ts +++ b/packages/agent-core/src/llm/gemini.ts @@ -123,10 +123,35 @@ export function parseGeminiResponse(response: GeminiResponse): LLMResponse { assistant.push({ type: 'tool_use', id, name: part.functionCall.name, input }); } }); + const finishReason = candidate?.finishReason; + // CONTINUATION exhausts the current generation budget and requires another + // request. Only explicit STOP completes a candidate. Keep missing reasons + // compatible with intermediate streaming chunks; other explicit terminations + // are incomplete and cannot authorize a tool call. + const stopReason = + finishReason === 'MAX_TOKENS' || finishReason === 'CONTINUATION' + ? 'max_tokens' + : [ + 'SAFETY', + 'RECITATION', + 'BLOCKLIST', + 'PROHIBITED_CONTENT', + 'SPII', + 'LANGUAGE', + 'IMAGE_SAFETY', + 'IMAGE_PROHIBITED_CONTENT', + 'IMAGE_RECITATION', + 'ESCALATION', + 'PUP_LIMITED_DISABLED', + ].includes(finishReason ?? '') + ? 'content_filter' + : finishReason === 'STOP' || finishReason === undefined + ? (finishReason ?? 'stop') + : 'incomplete'; return { text, toolCalls, - stopReason: candidate?.finishReason === 'MAX_TOKENS' ? 'max_tokens' : (candidate?.finishReason ?? 'stop'), + stopReason, assistant: { role: 'assistant', content: assistant }, }; } diff --git a/packages/agent-core/src/llm/types.ts b/packages/agent-core/src/llm/types.ts index 3d70b3d71..22238cc4a 100644 --- a/packages/agent-core/src/llm/types.ts +++ b/packages/agent-core/src/llm/types.ts @@ -34,7 +34,7 @@ export interface LLMMessage { /** Opaque host presentation, stripped at runtime provider boundary. Never model context. */ hostPresentation?: { id: string; metadata: Readonly> }; content: string | LLMContent[]; - /** Visible partial prose preserved after cancellation; never a complete answer. */ + /** Visible partial prose preserved after cancellation or incomplete generation; never a complete answer. */ interrupted?: true; /** Host-created document body; never inferred from generic assistant prose. */ documentArtifact?: true; @@ -73,7 +73,7 @@ export interface LLMResponse { text: string; /** Tool calls the model wants executed (empty when none). */ toolCalls: LLMToolCall[]; - /** Why generation stopped, e.g. "end_turn" | "tool_use" | "max_tokens". */ + /** Why generation stopped, e.g. "end_turn" | "tool_use" | "max_tokens" | "content_filter" | "incomplete". */ stopReason: string; /** The assistant message to append to history before the next turn. */ assistant: LLMMessage; diff --git a/packages/agent-core/src/llm/writing-task.ts b/packages/agent-core/src/llm/writing-task.ts index 46b5cc892..e425a9ce7 100644 --- a/packages/agent-core/src/llm/writing-task.ts +++ b/packages/agent-core/src/llm/writing-task.ts @@ -1,3 +1,4 @@ +import { incompleteResponseReason } from './completion'; import { validateWritingCurrency } from './writing-currency'; import type { LLMMessage, LLMProvider, LLMResponse } from './types'; @@ -60,8 +61,7 @@ export function buildWritingMessages(request: WritingRequest): LLMMessage[] { } function parseWritingBody(response: LLMResponse): { text: string } { - if (response.toolCalls.length || ['max_tokens', 'length'].includes(response.stopReason)) - throw new Error('Incomplete writing response'); + if (response.toolCalls.length || incompleteResponseReason(response)) throw new Error('Incomplete writing response'); if (response.text.length > 20000) throw new Error('Writing response is too large'); const output: unknown = JSON.parse(response.text.replace(/^\s*\s*<\/think>\s*/, '').trim()); if ( diff --git a/packages/agent-core/src/runtime.ts b/packages/agent-core/src/runtime.ts index 00aad3486..e6aa8bf36 100644 --- a/packages/agent-core/src/runtime.ts +++ b/packages/agent-core/src/runtime.ts @@ -11,15 +11,24 @@ * with a scripted provider and mock tools. */ import type { AgentTool } from './types'; +import { incompleteResponseReason, type IncompleteResponseReason } from './llm/completion'; +export { incompleteResponseReason } from './llm/completion'; import { validateToolInput } from './tool-validation'; export { validateToolInput } from './tool-validation'; -import { budgetMessages, contextualizeMessages, DEFAULT_CONTEXT_BYTES } from './context-budget'; +import { + budgetMessages, + contextualizeMessages, + DEFAULT_CONTEXT_BYTES, + budgetToolResults, + type BudgetedToolResult, +} from './context-budget'; +export { fitTextResult, minimumTextResult } from './context-budget'; import type { LLMContent, LLMMessage, LLMProvider, LLMResponse, LLMToolDef } from './llm/types'; /** Progress event emitted during a run (for UI: chat bubbles, tool activity). */ export type AgentEvent = | { type: 'usage'; usage: NonNullable } - | { type: 'assistant'; text: string; streamed: boolean } + | { type: 'assistant'; text: string; streamed: boolean; interrupted?: true } | { type: 'assistant_delta'; text: string } | { type: 'tool_call'; name: string; input: Record } | { @@ -72,6 +81,8 @@ export interface AgentRunResult { toolCallCount: number; /** A host tool explicitly ended the turn; this alone does not imply success. */ completedByTool?: true; + /** Generation ended before a complete answer or tool request was produced. */ + incompleteReason?: IncompleteResponseReason; /** True if the run stopped because it hit `maxIterations`. */ stoppedOnLimit: boolean; /** Identifies a guard stop without interpreting internal error prose in the UI. */ @@ -108,6 +119,7 @@ export async function runAgent( if (![maxIterations, maxToolCalls, maxRepeatedCalls].every((n) => Number.isSafeInteger(n) && n > 0)) throw new Error('Agent limits must be positive integers'); const seenCalls = new Map(); + const resultBudgets: BudgetedToolResult[] = []; let contextNoticeSent = false; let firstProviderText = false; @@ -137,7 +149,11 @@ export async function runAgent( const budgetedMessages = exactBudget ? [...contextualMessages] : budgetMessages(contextualMessages, options.maxContextBytes); - const requestMessages = budgetedMessages.map(({ hostPresentation: _presentation, ...message }) => message); + // Later exchanges may refit earlier read excerpts. Keep each provider's + // already-sent request independent from those archival updates. + const requestMessages = structuredClone( + budgetedMessages.map(({ hostPresentation: _presentation, ...message }) => message), + ); if ((contextual.trimmed || requestMessages.length < messages.length) && !contextNoticeSent) { contextNoticeSent = true; options.onContextTrimmed?.(); @@ -166,6 +182,20 @@ export async function runAgent( options.onContextTrimmed?.(); } if (response.usage) options.onEvent?.({ type: 'usage', usage: response.usage }); + const incompleteReason = incompleteResponseReason(response); + if (incompleteReason) { + // A truncated tool request can contain a valid-looking partial argument + // object. Retain only its prose: no call is executable or left unmatched + // in the next provider request. + if (deferredDeltas.length) options.onEvent?.({ type: 'assistant_delta', text: deferredDeltas.join('') }); + if (response.text) { + options.onEvent?.({ type: 'assistant', text: response.text, streamed, interrupted: true }); + const assistant: LLMMessage = { role: 'assistant', content: response.text, interrupted: true }; + const presentation = options.annotateAssistant?.(assistant); + messages.push(presentation ? { ...assistant, hostPresentation: presentation } : assistant); + } + return { text: response.text, messages, toolCallCount, stoppedOnLimit: false, aborted: false, incompleteReason }; + } if (!response.toolCalls.length) { if (deferredDeltas.length) options.onEvent?.({ type: 'assistant_delta', text: deferredDeltas.join('') }); if (response.text) options.onEvent?.({ type: 'assistant', text: response.text, streamed }); @@ -178,18 +208,30 @@ export async function runAgent( return { text: response.text, messages, toolCallCount, stoppedOnLimit: false, aborted: false }; } - const results: LLMContent[] = []; + const results: LLMContent[] = response.toolCalls.map((call) => ({ + type: 'tool_result', + toolUseId: call.id, + content: '', + isError: false, + })); + messages.push({ role: 'user', content: results }); + const refitResults = () => { + if (!exactBudget) budgetToolResults(messages, resultBudgets, options.maxContextBytes); + }; + // Refit earlier reads around the actual new shells. An oversized terminal + // exchange needs no inference; only provider-bound requests must fit. + refitResults(); let terminal = false; let limited = false; let limitReason: Exclude; - for (const call of response.toolCalls) { + for (const [index, call] of response.toolCalls.entries()) { if (options.signal?.aborted || terminal) { - results.push({ + results[index] = { type: 'tool_result', toolUseId: call.id, content: options.signal?.aborted ? 'Cancelled before execution' : 'Turn concluded before execution', isError: true, - }); + }; continue; } toolCallCount++; @@ -204,12 +246,24 @@ export async function runAgent( : tool && repeats > (tool.readOnlyHint ? maxRepeatedCalls : 1) ? 'Repeated identical tool call refused; inspect the prior result before continuing' : undefined; - const { content, isError } = policyError + let { content, isError } = policyError ? { content: policyError, isError: true } : await executeToolCall(tools, call.name, call.input, options.signal); + const result: Extract = { + type: 'tool_result', + toolUseId: call.id, + content, + isError, + }; + results[index] = result; + if (!isError && tool?.fitResult) + resultBudgets.push({ result, output: JSON.parse(content), policy: tool.fitResult }); + refitResults(); + content = result.content; if (policyError) { limited = terminal = true; limitReason = toolCallCount > maxToolCalls ? 'tool_budget' : 'repeated_call'; + result.guardReason = limitReason; } if ( typeof tool?.concludesTurn === 'function' @@ -225,15 +279,8 @@ export async function runAgent( ...(tool ? { readOnlyHint: tool.readOnlyHint } : {}), ...(policyError ? { limitReason } : {}), }); - results.push({ - type: 'tool_result', - toolUseId: call.id, - content, - isError, - ...(policyError ? { guardReason: limitReason } : {}), - }); } - messages.push({ role: 'user', content: results }); + refitResults(); options.onToolExchange?.(structuredClone(messages)); if (terminal && !options.signal?.aborted) return { diff --git a/packages/agent-core/src/types.ts b/packages/agent-core/src/types.ts index c2a66f468..e106a4885 100644 --- a/packages/agent-core/src/types.ts +++ b/packages/agent-core/src/types.ts @@ -23,6 +23,13 @@ export interface AgentTool

, R = unknown> { readOnlyHint: boolean; /** Host policy evaluated after execution. A boolean retains the successful-result policy. */ concludesTurn?: boolean | ((result: { content: string; isError: boolean }) => boolean); + /** Optional structured excerpt policy. The runtime supplies the exact serialized + * result budget; tools without this capability are never truncated. */ + fitResult?: { + /** Smallest valid result, with the same scope and explicit partial-read flags. */ + minimum(result: unknown): unknown; + fit(result: unknown, fits: (candidate: unknown) => boolean): unknown; + }; /** Execute the tool. Throws on invalid input or when the editor isn't ready. */ execute: (params: P, signal?: AbortSignal) => Promise; } diff --git a/packages/shared/src/i18n/messages/de.ts b/packages/shared/src/i18n/messages/de.ts index 1a9c6b212..072b195a2 100644 --- a/packages/shared/src/i18n/messages/de.ts +++ b/packages/shared/src/i18n/messages/de.ts @@ -414,6 +414,8 @@ export const de: Partial = { agentModelQuota: 'Browserspeicher für Modelle ist voll. Website-Speicher freigeben oder kleineres Modell wählen und erneut versuchen.', agentStopped: 'Angehalten.', + agentResponseIncomplete: + 'Die Antwort ist unvollständig. Der bisherige Text bleibt erhalten; wartende Anfragen sind pausiert.', agentMaxSteps: 'Maximale Anzahl an Schritten erreicht; angehalten.', agentTaskPartialFailed: 'Bestätigte Änderungen angewendet: {count}. Die übrigen Aufgaben konnten nicht abgeschlossen werden. Prüfen Sie das Ergebnis und fragen Sie nur nach den offenen Aufgaben. Angewendete Änderungen können rückgängig gemacht werden.', diff --git a/packages/shared/src/i18n/messages/en.ts b/packages/shared/src/i18n/messages/en.ts index f9152dcdc..75b587401 100644 --- a/packages/shared/src/i18n/messages/en.ts +++ b/packages/shared/src/i18n/messages/en.ts @@ -391,6 +391,7 @@ export const en: I18nMessages = { agentModelLoadFailed: 'Model loading failed. Retry or choose another model.', agentModelQuota: 'Browser model storage is full. Free site storage or choose a smaller model, then retry.', agentStopped: 'Stopped.', + agentResponseIncomplete: 'The response is incomplete. Any partial text is kept; queued requests are paused.', agentMaxSteps: 'Reached the maximum number of steps; stopped.', agentTaskPartialFailed: 'Approved changes applied: {count}. The remaining work could not be completed. Check the result and request only the unfinished work; completed changes can be undone.', diff --git a/packages/shared/src/i18n/messages/es.ts b/packages/shared/src/i18n/messages/es.ts index 69523e268..f65c11a93 100644 --- a/packages/shared/src/i18n/messages/es.ts +++ b/packages/shared/src/i18n/messages/es.ts @@ -401,6 +401,8 @@ export const es: Partial = { agentModelQuota: 'El almacenamiento del navegador está lleno. Libera almacenamiento del sitio o elige un modelo más pequeño y reintenta.', agentStopped: 'Detenido.', + agentResponseIncomplete: + 'La respuesta está incompleta. Se conserva el texto parcial y se pausan las solicitudes en cola.', agentMaxSteps: 'Se alcanzó el número máximo de pasos; proceso detenido.', agentTaskPartialFailed: 'Cambios aprobados aplicados: {count}. No se pudo completar el trabajo restante. Revisa el resultado y solicita solo lo pendiente; puedes deshacer los cambios aplicados.', diff --git a/packages/shared/src/i18n/messages/ja.ts b/packages/shared/src/i18n/messages/ja.ts index f490a84db..98044130e 100644 --- a/packages/shared/src/i18n/messages/ja.ts +++ b/packages/shared/src/i18n/messages/ja.ts @@ -396,6 +396,7 @@ export const ja: Partial = { agentModelQuota: 'ブラウザーのモデル保存領域が不足しています。サイトの保存領域を空けるか小さいモデルを選び、再試行してください。', agentStopped: '停止しました。', + agentResponseIncomplete: '応答が完了していません。途中の内容を保持し、待機中のリクエストを一時停止しました。', agentMaxSteps: '最大ステップ数に達したため停止しました。', agentTaskPartialFailed: '確認済みの変更を {count} 件適用しました。残りの作業は完了できませんでした。結果を確認し、未完了の作業だけを依頼してください。適用済みの変更は元に戻せます。', diff --git a/packages/shared/src/i18n/messages/ko.ts b/packages/shared/src/i18n/messages/ko.ts index 023c66cf0..b01cf849b 100644 --- a/packages/shared/src/i18n/messages/ko.ts +++ b/packages/shared/src/i18n/messages/ko.ts @@ -385,6 +385,7 @@ export const ko: Partial = { agentModelQuota: '브라우저 모델 저장 공간이 부족합니다. 사이트 저장 공간을 확보하거나 더 작은 모델을 선택한 후 다시 시도하세요.', agentStopped: '중지했습니다.', + agentResponseIncomplete: '응답이 완료되지 않았습니다. 일부 내용을 보존하고 대기 중인 요청을 일시 중지했습니다.', agentMaxSteps: '최대 단계 수에 도달하여 중지했습니다.', agentTaskPartialFailed: '확인한 변경 {count}개를 적용했습니다. 나머지 작업은 완료하지 못했습니다. 결과를 확인한 뒤 미완료 작업만 요청하세요. 적용한 변경은 실행 취소할 수 있습니다.', diff --git a/packages/shared/src/i18n/messages/pt.ts b/packages/shared/src/i18n/messages/pt.ts index 8d732670e..e688d9a01 100644 --- a/packages/shared/src/i18n/messages/pt.ts +++ b/packages/shared/src/i18n/messages/pt.ts @@ -398,6 +398,8 @@ export const pt: Partial = { agentModelQuota: 'O armazenamento do navegador está cheio. Libere o armazenamento do site ou escolha um modelo menor e tente novamente.', agentStopped: 'Parado.', + agentResponseIncomplete: + 'A resposta está incompleta. O texto parcial foi mantido e os pedidos na fila foram pausados.', agentMaxSteps: 'Foi atingido o número máximo de passos; processo parado.', agentTaskPartialFailed: 'Alterações aprovadas aplicadas: {count}. Não foi possível concluir o trabalho restante. Confira o resultado e solicite apenas o que falta; as alterações aplicadas podem ser desfeitas.', diff --git a/packages/shared/src/i18n/messages/zh-CN.ts b/packages/shared/src/i18n/messages/zh-CN.ts index 2de2c85e6..acb380c83 100644 --- a/packages/shared/src/i18n/messages/zh-CN.ts +++ b/packages/shared/src/i18n/messages/zh-CN.ts @@ -370,6 +370,7 @@ export const zhCN: I18nMessages = { agentModelLoadFailed: '模型加载失败。请重试或选择其他模型。', agentModelQuota: '浏览器模型存储空间不足。请释放站点存储或选择更小的模型后重试。', agentStopped: '已停止。', + agentResponseIncomplete: '回复未完成。已保留部分内容,后续排队请求已暂停。', agentMaxSteps: '已达到最大执行步数,已停止。', agentTaskPartialFailed: '已应用 {count} 项确认的修改。后续任务未能完成。请先检查结果,再提出尚未完成的要求;已完成的修改可以撤销。', diff --git a/packages/shared/src/i18n/types.ts b/packages/shared/src/i18n/types.ts index 0ce6afdbe..dfaae7229 100644 --- a/packages/shared/src/i18n/types.ts +++ b/packages/shared/src/i18n/types.ts @@ -381,6 +381,7 @@ export interface I18nMessages { agentModelSourceInvalid: string; agentModelQuota: string; agentStopped: string; + agentResponseIncomplete: string; agentMaxSteps: string; agentTaskPartialFailed: string; agentTaskRepeatStopped: string; diff --git a/playwright.browsers.config.ts b/playwright.browsers.config.ts index 230469a2f..e379c738e 100644 --- a/playwright.browsers.config.ts +++ b/playwright.browsers.config.ts @@ -1,3 +1,4 @@ +import { playwrightArtifacts } from './bin/ci-artifacts.mjs'; import { defineConfig, devices } from '@playwright/test'; import base from './playwright.config'; @@ -9,7 +10,7 @@ import base from './playwright.config'; */ export default defineConfig({ ...base, - reporter: [['list'], ['html', { outputFolder: 'playwright-report-browsers', open: 'never' }]], + ...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 // "flaky" in the report instead of red, while a real regression still fails. diff --git a/playwright.config.ts b/playwright.config.ts index a6e7ffef7..00265196c 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,3 +1,5 @@ +import { ciTestMatch } from './bin/ci-shards.mjs'; +import { playwrightArtifacts } from './bin/ci-artifacts.mjs'; import { defineConfig, devices } from '@playwright/test'; // Override when several E2E runs share a machine (parallel sessions each @@ -21,8 +23,8 @@ export default defineConfig({ timeout: 5_000, }, fullyParallel: true, - outputDir: RESULTS_DIR, - reporter: [['list'], ['html', { outputFolder: 'playwright-report', open: 'never' }]], + ...playwrightArtifacts('playwright-report', RESULTS_DIR), + ...ciTestMatch('preview'), use: { baseURL: REMOTE || `http://127.0.0.1:${PORT}`, trace: 'retain-on-failure', diff --git a/playwright.docker.config.ts b/playwright.docker.config.ts index a6a6366d6..1402deefd 100644 --- a/playwright.docker.config.ts +++ b/playwright.docker.config.ts @@ -1,3 +1,5 @@ +import { ciTestMatch } from './bin/ci-shards.mjs'; +import { playwrightArtifacts } from './bin/ci-artifacts.mjs'; import { defineConfig, devices } from '@playwright/test'; // Runs the SAME e2e suite (test/e2e) against the production Docker image @@ -26,7 +28,8 @@ export default defineConfig({ timeout: 10_000, }, fullyParallel: true, - reporter: [['list'], ['html', { outputFolder: 'playwright-report-docker', open: 'never' }]], + ...playwrightArtifacts('playwright-report-docker', 'test-results-docker'), + ...ciTestMatch('preview'), use: { baseURL: 'http://127.0.0.1:8090', // Exercise an untrustworthy HTTP origin too: localhost would conceal diff --git a/playwright.pages.config.ts b/playwright.pages.config.ts index 56b31a6b1..2a9ebfce5 100644 --- a/playwright.pages.config.ts +++ b/playwright.pages.config.ts @@ -1,3 +1,5 @@ +import { ciTestMatch } from './bin/ci-shards.mjs'; +import { playwrightArtifacts } from './bin/ci-artifacts.mjs'; import { defineConfig } from '@playwright/test'; import base from './playwright.config'; @@ -35,10 +37,10 @@ export default defineConfig({ // Declared here rather than on the CI command line so a local run of this // config means the same thing CI's does. grepInvert: /@serial/, - outputDir: 'test-results-pages', - reporter: [['list'], ['html', { outputFolder: 'playwright-report-pages', open: 'never' }]], - // Test-level sharding avoids assigning a whole large spec to one runner. - // workers: 1 still executes one test at a time against workerd. + ...playwrightArtifacts('playwright-report-pages', 'test-results-pages'), + ...ciTestMatch('pages'), + // CI assigns files by measured total durations. fullyParallel keeps the + // same execution semantics; workers: 1 still runs one test at a time. fullyParallel: true, workers: 1, retries: 1, diff --git a/public/sw.js b/public/sw.js index cf3cf5ac5..30c39c1d8 100644 --- a/public/sw.js +++ b/public/sw.js @@ -713,7 +713,8 @@ self.addEventListener('fetch', (event) => { return networkResponse; }) .catch(() => { - return cachedResponse; + // An offline cache miss still needs a Response for respondWith. + return cachedResponse || Response.error(); }); return cachedResponse || fetchPromise; diff --git a/styles/assistant.css b/styles/assistant.css index fde1d1ca1..4440bdd62 100644 --- a/styles/assistant.css +++ b/styles/assistant.css @@ -703,6 +703,7 @@ r-dropdown[role='listbox'] { border: 0; } .agent-model-progress { + box-sizing: border-box; display: block; width: 100%; height: 3px; diff --git a/test/e2e/agent-artifact-revision.spec.ts b/test/e2e/agent-artifact-revision.spec.ts index 4bb27565c..8dd0e34cf 100644 --- a/test/e2e/agent-artifact-revision.spec.ts +++ b/test/e2e/agent-artifact-revision.spec.ts @@ -1,3 +1,4 @@ +import { mockApi } from './lib/mock-api'; import { expect, test } from './lib/l0'; test('artifact AI revisions stay in an explicit draft review and preserve newer edits', async ({ page }, testInfo) => { @@ -45,7 +46,7 @@ test('artifact AI revisions stay in an explicit draft review and preserve newer const requests: Array> = []; let delayed = false, release: () => void = () => {}; - await page.route('http://localhost:11434/api/**', async (route) => { + await mockApi(page, 'http://localhost:11434/api/**', async (route) => { if (route.request().method() === 'OPTIONS') { await route.fulfill({ status: 204, diff --git a/test/e2e/agent-composer-ux.spec.ts b/test/e2e/agent-composer-ux.spec.ts index 6147c0112..6cc770832 100644 --- a/test/e2e/agent-composer-ux.spec.ts +++ b/test/e2e/agent-composer-ux.spec.ts @@ -1,3 +1,5 @@ +import { captureClipboard } from './lib/clipboard'; +import { mockApi } from './lib/mock-api'; import { expect, test } from './lib/l0'; import { buildDocx, toBase64 } from './lib/ooxml'; @@ -40,7 +42,7 @@ for (const ext of ['docx', 'xlsx', 'pptx', 'pdf'] as const) { release = resolve; }); let answers = 0; - await page.route('http://localhost:11434/api/**', async (route) => { + await mockApi(page, 'http://localhost:11434/api/**', async (route) => { if (route.request().method() === 'OPTIONS') { await route.fulfill({ status: 204, @@ -182,11 +184,12 @@ for (const ext of ['docx', 'xlsx', 'pptx', 'pdf'] as const) { ).toEqual([4, 17, 'backward']); await expect.poll(() => log.evaluate((el) => el.scrollTop)).toBe(0); await expect(input).not.toBeFocused(); - await page.context().grantPermissions(['clipboard-read', 'clipboard-write']); + const copiedText = await captureClipboard(page); const copy = page.locator('.cui-copy').first(); const width = (await copy.boundingBox())!.width; await copy.click(); await expect(copy).toHaveAccessibleName('Copied'); + expect(await copiedText()).toBe('Completed response'); await expect(page.locator('.cui-feedback')).toHaveText('Copied'); expect((await copy.boundingBox())!.width).toBe(width); await expect(copy).toHaveAccessibleName('Copy'); @@ -200,11 +203,12 @@ for (const ext of ['docx', 'xlsx', 'pptx', 'pdf'] as const) { await expect.poll(() => log.evaluate((el) => el.scrollHeight - el.scrollTop - el.clientHeight)).toBeLessThan(3); const code = page.locator('.cui-msg-agent pre'); await code.focus(); - await code.press('ArrowRight'); + // Native WebKit scrolling needs a key hold spanning an animation frame. + await code.press('ArrowRight', { delay: 100 }); await expect.poll(() => code.evaluate((el) => el.scrollLeft)).toBeGreaterThan(0); const table = page.locator('.cui-msg-agent .cui-table'); await table.focus(); - await table.press('ArrowRight'); + await table.press('ArrowRight', { delay: 100 }); await expect.poll(() => table.evaluate((el) => el.scrollLeft)).toBeGreaterThan(0); await page.screenshot({ path: testInfo.outputPath('chat-details.png') }); } finally { @@ -230,7 +234,7 @@ for (const width of [320, 360, 1280]) { }), ); }); - await page.route('http://localhost:11434/api/**', (route) => + await mockApi(page, 'http://localhost:11434/api/**', (route) => route.fulfill({ json: { models: [{ name: 'density-test' }] } }), ); await page.goto('/editor?new=docx&locale=en'); @@ -297,6 +301,7 @@ for (const width of [320, 360, 1280]) { await composer.screenshot({ path: testInfo.outputPath(`composer-only-${width}.png`) }); await page.evaluate(() => document.documentElement.setAttribute('data-ran-theme', 'dark')); await expect(composer).toHaveCSS('box-shadow', 'none'); + await page.keyboard.press('Tab'); await page.locator('.agent-sources-toggle').focus(); expect(await page.locator('.agent-sources-toggle').evaluate((el) => getComputedStyle(el).outlineWidth)).toBe('2px'); await page.locator('.agent-runtime-panel').screenshot({ path: testInfo.outputPath(`composer-dark-${width}.png`) }); diff --git a/test/e2e/agent-content-review.spec.ts b/test/e2e/agent-content-review.spec.ts index 981527830..e14cde08a 100644 --- a/test/e2e/agent-content-review.spec.ts +++ b/test/e2e/agent-content-review.spec.ts @@ -1,3 +1,4 @@ +import { mockApi } from './lib/mock-api'; import { readFile } from 'node:fs/promises'; import type { Page } from '@playwright/test'; import { expect, test } from './lib/l0'; @@ -23,7 +24,7 @@ async function open( ); }, endpoint); let requested = 0; - await page.route(`${endpoint}/chat/completions`, async (route) => { + await mockApi(page, `${endpoint}/chat/completions`, async (route) => { const call = calls[requested++]; await route.fulfill({ json: { @@ -127,7 +128,7 @@ test('formatted Word content retains native headings and lists with safe readbac }); await page .frameLocator('iframe[name="frameEditor"]') - .getByRole('button', { name: 'Save (Ctrl+S)', exact: true }) + .getByRole('button', { name: /^Save \((?:Ctrl|⌘)\+S\)$/ }) .click(); const download = await downloadPromise; const saved = await download.path(); diff --git a/test/e2e/agent-dictation.spec.ts b/test/e2e/agent-dictation.spec.ts index 154af017e..dbf20603c 100644 --- a/test/e2e/agent-dictation.spec.ts +++ b/test/e2e/agent-dictation.spec.ts @@ -1,3 +1,4 @@ +import { mockApi } from './lib/mock-api'; import { expect, test } from './lib/l0'; import { buildDocx, toBase64 } from './lib/ooxml'; @@ -115,7 +116,7 @@ for (const ext of ['docx', 'xlsx', 'pptx', 'pdf'] as const) { let messages = 0; let holdNextAnswer = false; let releaseAnswer: (() => void) | undefined; - await page.route('http://localhost:11434/api/**', async (route) => { + await mockApi(page, 'http://localhost:11434/api/**', async (route) => { if (route.request().method() === 'OPTIONS') { await route.fulfill({ status: 204, diff --git a/test/e2e/agent-editor-focus.spec.ts b/test/e2e/agent-editor-focus.spec.ts new file mode 100644 index 000000000..3acbb8b8a --- /dev/null +++ b/test/e2e/agent-editor-focus.spec.ts @@ -0,0 +1,76 @@ +import { expect, test } from './lib/l0'; + +for (const entry of ['programmatic', 'sidebar'] as const) { + test(`${entry}: native deferred editor focus preserves the assistant composer until explicit editor input`, async ({ + page, + }) => { + test.setTimeout(90000); + await page.addInitScript(() => { + localStorage.setItem('document-assistant-enabled', 'true'); + localStorage.setItem( + 'agent-writing-endpoint', + JSON.stringify({ version: 1, kind: 'loopback', baseUrl: 'http://localhost:11434', model: 'focus-test' }), + ); + }); + await page.goto('/editor?new=docx&locale=en'); + await page.waitForFunction(() => Boolean(window.__ooFrames.readyEditor())); + const frame = page.frameLocator('iframe[name="frameEditor"]'); + if (entry === 'programmatic') await page.evaluate(() => window.__toggleAgentPanel?.()); + else await frame.locator('.agent-sidebar-entry').click(); + const input = page.locator('.cui-input'); + await expect(input).toBeFocused(); + + // DocumentHolder's deferred completion focuses this exact SDK element. + // Wait for its native Firefox focus callback as well, rather than racing + // an assertion ahead of the callback that originally redirected typing. + const deferredFocus = () => + page.evaluate(async () => { + const win = window.__ooFrames.readyEditor()!; + const target = win.document.getElementById('editor_sdk')!; + target.focus(); + await new Promise((resolve) => win.setTimeout(resolve, 0)); + return target.id; + }); + expect(await deferredFocus()).toBe('editor_sdk'); + await expect(input).toBeFocused(); + await input.fill('Keep this request in the assistant'); + expect(await deferredFocus()).toBe('editor_sdk'); + await expect(input).toBeFocused(); + await expect(input).toHaveValue('Keep this request in the assistant'); + + // A real editor click releases the claim; native document input must still + // work while the assistant remains open. + await frame.locator('#editor_sdk').click({ position: { x: 300, y: 200 } }); + await expect(input).not.toBeFocused(); + await page.keyboard.type('Document handoff stays usable'); + const documentText = () => + page.evaluate(() => { + const win = window.__ooFrames.readyEditor() as any; + const logic = (win.Asc?.editor ?? win.editor).WordControl.m_oLogicDocument; + const paragraphs: any[] = []; + for (const element of logic.Content) element.GetAllParagraphs({ All: true }, paragraphs); + return paragraphs.map((paragraph) => paragraph.GetText({ Numbering: false })).join('\n'); + }); + await expect.poll(documentText).toContain('Document handoff stays usable'); + expect(await documentText()).not.toContain('Keep this request in the assistant'); + + // Returning through a genuine parent pointer interaction claims the + // composer again, including when the editor already owns native focus. + await input.click(); + expect(await deferredFocus()).toBe('editor_sdk'); + await expect(input).toBeFocused(); + await expect(input).toHaveValue('Keep this request in the assistant'); + + // The native window manager must still focus actionable dialogs; the + // document refocus guard must not trap their keyboard controls outside. + await page.evaluate(() => { + const win = window.__ooFrames.readyEditor() as any; + win.Common.UI.alert({ title: 'Native focus boundary', msg: 'A native modal keeps its keyboard focus.' }); + }); + const dialog = frame.locator('.asc-window').filter({ hasText: 'Native focus boundary' }); + const ok = dialog.getByRole('button', { name: 'OK', exact: true }); + await expect(ok).toBeFocused(); + await ok.click(); + await expect(dialog).toBeHidden(); + }); +} diff --git a/test/e2e/agent-error-recovery.spec.ts b/test/e2e/agent-error-recovery.spec.ts index a20fa6656..b699fc823 100644 --- a/test/e2e/agent-error-recovery.spec.ts +++ b/test/e2e/agent-error-recovery.spec.ts @@ -1,3 +1,4 @@ +import { mockApi } from './lib/mock-api'; import { expect, test } from './lib/l0'; test('explains Gemini rate limiting and restores the request without exposing its error body', async ({ page }) => { @@ -18,7 +19,7 @@ test('explains Gemini rate limiting and restores the request without exposing it ); }); let requests = 0; - await page.route('https://generativelanguage.googleapis.com/**', async (route) => { + await mockApi(page, 'https://generativelanguage.googleapis.com/**', async (route) => { const headers = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'Content-Type,x-goog-api-key', @@ -71,7 +72,7 @@ test('explains a service failure without private details and restores the origin ); }); let requests = 0; - await page.route('http://localhost:11434/api/**', async (route) => { + await mockApi(page, 'http://localhost:11434/api/**', async (route) => { if (route.request().method() === 'OPTIONS') { await route.fulfill({ status: 204, diff --git a/test/e2e/agent-history-rendering.spec.ts b/test/e2e/agent-history-rendering.spec.ts index ec60ccefd..3410e4a43 100644 --- a/test/e2e/agent-history-rendering.spec.ts +++ b/test/e2e/agent-history-rendering.spec.ts @@ -1,3 +1,4 @@ +import { mockApi } from './lib/mock-api'; import { expect, test } from './lib/l0'; test('restores a long saved conversation and switches sessions with completed answer actions', async ({ page }) => { @@ -323,12 +324,15 @@ test('keeps the first validating request in its original chat while startup hist db.close(); }); await page.addInitScript(() => { - const open = indexedDB.open.bind(indexedDB); + // WebKit drops an override stored on the indexedDB instance during + // editor startup. Patch its stable prototype so the first history open + // is actually held until this test releases it. + const open = IDBFactory.prototype.open; const success = Object.getOwnPropertyDescriptor(IDBRequest.prototype, 'onsuccess')!; const host = window as unknown as { releaseAssistantHistory?: () => void }; let delayed = false; - indexedDB.open = (...args: Parameters) => { - const request = open(...args); + IDBFactory.prototype.open = function (...args: Parameters) { + const request = open.apply(this, args); if (args[0] === 'document-ai-conversations' && !delayed) { delayed = true; Object.defineProperty(request, 'onsuccess', { @@ -348,7 +352,7 @@ test('keeps the first validating request in its original chat while startup hist }; }); let releaseRouting: (() => void) | undefined; - await page.route('http://localhost:11434/api/**', async (route) => { + await mockApi(page, 'http://localhost:11434/api/**', async (route) => { if (route.request().method() === 'OPTIONS') { await route.fulfill({ status: 204, @@ -378,6 +382,12 @@ test('keeps the first validating request in its original chat while startup hist await page.goto('/editor?new=docx&locale=en'); await page.waitForFunction(() => Boolean(window.__ooFrames.readyEditor())); await page.frameLocator('iframe[name="frameEditor"]').locator('.agent-sidebar-entry').click(); + await page.waitForFunction( + () => + typeof (window as unknown as { releaseAssistantHistory?: () => void }).releaseAssistantHistory === 'function', + ); + await expect(page.locator('.agent-history-status')).toHaveText('Updating chat history…'); + await expect(page.locator('.cui-msg-user')).toHaveCount(0); const input = page.locator('.cui-input'); await input.fill('Tell me a brief story'); await page.locator('.cui-send').click(); @@ -392,10 +402,6 @@ test('keeps the first validating request in its original chat while startup hist for (const action of ['edit', 'remove', 'send-now']) await expect(page.locator(`.agent-queue-${action}`)).toBeDisabled(); await expect(page.locator('.cui-stop')).toBeEnabled(); - await page.waitForFunction( - () => - typeof (window as unknown as { releaseAssistantHistory?: () => void }).releaseAssistantHistory === 'function', - ); await page.evaluate(() => (window as unknown as { releaseAssistantHistory(): void }).releaseAssistantHistory()); await expect(page.locator('.agent-history-status')).toContainText('saving is enabled'); await expect(page.locator('.agent-queue-text')).toHaveText('Tell me a brief story'); @@ -478,7 +484,7 @@ test('deleting an inactive saved chat preserves a held reply and the next draft release = resolve; }); let answers = 0; - await page.route('http://localhost:11434/api/**', async (route) => { + await mockApi(page, 'http://localhost:11434/api/**', async (route) => { if (route.request().method() === 'OPTIONS') { await route.fulfill({ status: 204, diff --git a/test/e2e/agent-imported-formats.spec.ts b/test/e2e/agent-imported-formats.spec.ts index dd9afa361..594dcdc68 100644 --- a/test/e2e/agent-imported-formats.spec.ts +++ b/test/e2e/agent-imported-formats.spec.ts @@ -1,3 +1,4 @@ +import { mockApi } from './lib/mock-api'; import { taskProtocol } from './lib/task-protocol'; import { expect, test } from './lib/l0'; import { buildOdf, ODF_FIXTURES } from './lib/odf'; @@ -65,7 +66,7 @@ for (const fixture of fixtures) { let readContinuation = false; let editing = false; let replacing = false; - await page.route('http://localhost:11434/api/**', async (route) => { + await mockApi(page, 'http://localhost:11434/api/**', async (route) => { if (route.request().method() === 'OPTIONS') { await route.fulfill({ status: 204, diff --git a/test/e2e/agent-process-feedback.spec.ts b/test/e2e/agent-process-feedback.spec.ts index 33bd0e393..8471c57a4 100644 --- a/test/e2e/agent-process-feedback.spec.ts +++ b/test/e2e/agent-process-feedback.spec.ts @@ -1,3 +1,4 @@ +import { mockApi } from './lib/mock-api'; import { expect, test } from './lib/l0'; test('a held request shows actual classification and generation, then clears waiting on completion and Stop', async ({ @@ -25,7 +26,7 @@ test('a held request shows actual classification and generation, then clears wai const stoppedGate = new Promise((resolve) => (releaseStopped = resolve)); let classifications = 0; let answers = 0; - await page.route('http://localhost:11434/api/**', async (route) => { + await mockApi(page, 'http://localhost:11434/api/**', async (route) => { if (route.request().method() === 'OPTIONS') { await route.fulfill({ status: 204, @@ -142,7 +143,7 @@ test('Chinese waiting feedback remains localized and stable in a narrow real edi let release!: () => void; const gate = new Promise((resolve) => (release = resolve)); let classifications = 0; - await page.route('http://localhost:11434/api/**', async (route) => { + await mockApi(page, 'http://localhost:11434/api/**', async (route) => { if (route.request().method() === 'OPTIONS') { await route.fulfill({ status: 204, diff --git a/test/e2e/agent-request-queue.spec.ts b/test/e2e/agent-request-queue.spec.ts index e4c3643a0..052477780 100644 --- a/test/e2e/agent-request-queue.spec.ts +++ b/test/e2e/agent-request-queue.spec.ts @@ -1,3 +1,4 @@ +import { mockApi } from './lib/mock-api'; import { expect, test } from './lib/l0'; for (const ext of ['docx', 'pptx'] as const) { @@ -21,7 +22,7 @@ for (const ext of ['docx', 'pptx'] as const) { }); const calls: string[] = []; const releases = new Map void>(); - await page.route('http://localhost:11434/api/**', async (route) => { + await mockApi(page, 'http://localhost:11434/api/**', async (route) => { if (route.request().method() === 'OPTIONS') { await route.fulfill({ status: 204, diff --git a/test/e2e/agent-result-hierarchy.spec.ts b/test/e2e/agent-result-hierarchy.spec.ts index 9e3ecc5f1..bb06a542c 100644 --- a/test/e2e/agent-result-hierarchy.spec.ts +++ b/test/e2e/agent-result-hierarchy.spec.ts @@ -1,3 +1,4 @@ +import { captureClipboard } from './lib/clipboard'; import { expect, test } from './lib/l0'; test('restored drafts retain clear identity, readable narrow content and source-only keyboard copying', async ({ @@ -61,7 +62,7 @@ test('restored drafts retain clear identity, readable narrow content and source- await expect(page.locator('[data-interrupted] .cui-result-label')).toHaveText('Incomplete response'); await expect(page.locator('.cui-apply')).toHaveCount(0); await expect(draft).toHaveAttribute('data-source', source); - await page.context().grantPermissions(['clipboard-read', 'clipboard-write']); + const copiedText = await captureClipboard(page); const copy = draft.locator('.cui-copy'); await expect(copy).toBeVisible(); await page.keyboard.press('Tab'); @@ -75,7 +76,7 @@ test('restored drafts retain clear identity, readable narrow content and source- ).toBe(true); await copy.press('Enter'); await expect(copy).toHaveAccessibleName('Copied'); - expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(source); + expect(await copiedText()).toBe(source); const resize = page.getByRole('separator', { name: 'Resize AI panel' }); for (const theme of ['light', 'dark'] as const) { await page.emulateMedia({ colorScheme: theme, reducedMotion: 'reduce' }); @@ -99,7 +100,11 @@ test('restored drafts retain clear identity, readable narrow content and source- await draft.scrollIntoViewIfNeeded(); await expect.poll(() => draft.evaluate((el) => el.scrollWidth - el.clientWidth)).toBeLessThan(2); await page.locator('.agent-runtime-panel').screenshot({ path: testInfo.outputPath('results-narrow.png') }); - await draft.locator('pre').focus(); - await page.keyboard.press('ArrowRight'); - await expect.poll(() => draft.locator('pre').evaluate((el) => el.scrollLeft)).toBeGreaterThan(0); + const code = draft.locator('pre'); + await code.focus(); + await expect(code).toBeFocused(); + // WebKit can cancel native scroll before its first animation frame when + // keydown/keyup share a tick. Exercise a physical key press instead. + await code.press('ArrowRight', { delay: 100 }); + await expect.poll(() => code.evaluate((el) => el.scrollLeft)).toBeGreaterThan(0); }); diff --git a/test/e2e/agent-sources-artifacts.spec.ts b/test/e2e/agent-sources-artifacts.spec.ts index 29f25e371..73fc1f320 100644 --- a/test/e2e/agent-sources-artifacts.spec.ts +++ b/test/e2e/agent-sources-artifacts.spec.ts @@ -1,3 +1,5 @@ +import { mockApi, originalApiUrl } from './lib/mock-api'; +import { readFile } from 'node:fs/promises'; import { expect, test } from './lib/l0'; test('local sources and versioned artifacts remain explicit, compact and reviewed', async ({ page }, testInfo) => { @@ -98,7 +100,7 @@ test('attached reference excerpts reach only the explicitly selected AI service' ); }); const bodies: string[] = []; - await page.route('http://localhost:11434/api/**', async (route) => { + await mockApi(page, 'http://localhost:11434/api/**', async (route) => { if (route.request().method() === 'OPTIONS') { await route.fulfill({ status: 204, @@ -245,6 +247,7 @@ test('cloud model settings exclude browser runtime configuration and cache hints test('offline workspace hides cloud discovery and download controls while retaining local work without automatic reconnect', async ({ page, context, + l0, }, testInfo) => { test.setTimeout(90000); await page.addInitScript(() => { @@ -253,7 +256,8 @@ test('offline workspace hides cloud discovery and download controls while retain }); const connectorRequests: string[] = []; page.on('request', (request) => { - if (request.url().includes('/api/connectors/')) connectorRequests.push(request.url()); + const url = originalApiUrl(page, request.url()); + if (new URL(url).pathname.startsWith('/api/connectors/')) connectorRequests.push(url); }); await page.goto('/editor?new=docx&locale=en'); await page.waitForFunction(() => Boolean(window.__ooFrames.readyEditor())); @@ -286,6 +290,32 @@ test('offline workspace hides cloud discovery and download controls while retain }); db.close(); }); + // Cache inventory may probe an unprepared SDK while offline. WebKit emits + // pageerror for these caught import rejections; correlate only exact optional + // module failures, keeping prepared core and unrelated module failures fatal. + const manifest = await page.evaluate(async () => { + await navigator.serviceWorker.ready; + const source = await (await fetch('/sw.js', { cache: 'no-store' })).text(); + return { + core: JSON.parse(/const APP_ASSETS_TO_CACHE = (\[.*?\]);/.exec(source)![1]) as string[], + capabilities: JSON.parse(/const ASSISTANT_CAPABILITY_ASSETS = (\{.*?\});/.exec(source)![1]) as Record< + string, + string[] + >, + }; + }); + const absolute = (path: string) => new URL(path, page.url()).href; + const core = new Set(manifest.core.map(absolute)); + const optionalScripts = new Set( + [...manifest.capabilities.webgpu, ...(manifest.capabilities.wllama || [])] + .filter((path) => path.endsWith('.js')) + .map(absolute), + ); + const failedWhileOffline = new Set(); + let offline = true; + page.on('requestfailed', (request) => { + if (offline) failedWhileOffline.add(originalApiUrl(page, request.url())); + }); await context.setOffline(true); await page.locator('.agent-sources-toggle').click(); await expect(page.locator('.agent-cloud-sources')).toBeHidden(); @@ -317,6 +347,7 @@ test('offline workspace hides cloud discovery and download controls while retain await expect(page.locator('.agent-artifact-editor select option')).toHaveCount(2); await expect(page.locator('.agent-artifact-status')).toHaveText('Saved on this device'); await context.setOffline(false); + offline = false; await page.getByRole('button', { name: 'Artifacts', exact: true }).click(); await page.locator('.agent-sources-toggle').click(); await expect(page.locator('.agent-cloud-sources')).toBeVisible(); @@ -324,6 +355,18 @@ test('offline workspace hides cloud discovery and download controls while retain expect(connectorRequests).toEqual([]); await page.getByRole('button', { name: 'Back to conversation', exact: true }).click(); await expect(page.locator('.cui-input')).toHaveValue('Keep this draft offline'); + expect([...failedWhileOffline].filter((url) => core.has(url))).toEqual([]); + expect( + [...failedWhileOffline].filter((url) => new URL(url).pathname.startsWith('/assets/') && !optionalScripts.has(url)), + ).toEqual([]); + const loaderDiagnostics = /^(?:Response served by service worker is an error|Cannot load \.)$/; + const diagnostics = l0.pageErrors.filter((message) => loaderDiagnostics.test(message)); + if (diagnostics.length) { + const optionalFailures = [...failedWhileOffline].filter((url) => optionalScripts.has(url)); + expect(optionalFailures.length).toBeGreaterThan(0); + expect(diagnostics.length).toBeLessThanOrEqual(2 * optionalFailures.length); + l0.allowFrameError(loaderDiagnostics); + } }); test('expired Drive authorization and attachment limits expose usable recovery actions', async ({ page }) => { @@ -333,7 +376,7 @@ test('expired Drive authorization and attachment limits expose usable recovery a localStorage.setItem('agent-local-preset', 'Qwen3-4B-q4f16_1-MLC'); }); let reads = 0; - await page.route('**/api/connectors/google/**', async (route) => { + await mockApi(page, '**/api/connectors/google/**', async (route) => { if (route.request().url().includes('/read')) { reads++; await route.fulfill({ status: 401, json: { error: 'reconnect_required' } }); @@ -452,7 +495,7 @@ test('switching conversations releases a pending source read without attaching i const waitForRelease = new Promise((resolve) => { release = resolve; }); - await page.route('**/api/connectors/google/**', async (route) => { + await mockApi(page, '**/api/connectors/google/**', async (route) => { if (route.request().url().includes('/read')) { reading = true; await waitForRelease; @@ -472,7 +515,9 @@ test('switching conversations releases a pending source read without attaching i .locator('.agent-sources-view input[type=file]') .setInputFiles({ name: 'fresh.txt', mimeType: 'text/plain', buffer: Buffer.from('New conversation data') }); await expect(page.locator('.agent-source-row')).toHaveText('fresh.txtRemove'); - const completed = page.waitForResponse((response) => response.url().includes('/api/connectors/google/read')); + const completed = page.waitForResponse( + (response) => new URL(originalApiUrl(page, response.url())).pathname === '/api/connectors/google/read', + ); release(); await completed; await expect(page.locator('.agent-source-row')).toHaveText('fresh.txtRemove'); @@ -486,6 +531,29 @@ test('structured results validate explicitly, preview compactly and export witho }, testInfo) => { test.setTimeout(90000); await page.goto('/'); + // Measure the engine/platform's native filename policy separately from the + // exact filename requested by the application (GTK WebKit replaces spaces). + const nativeDownload = page.waitForEvent('download'); + await page.evaluate(() => { + const link = document.createElement('a'); + link.href = URL.createObjectURL(new Blob(['filename probe'], { type: 'text/csv;charset=utf-8' })); + link.download = 'Release table.csv'; + document.body.append(link); + link.click(); + link.remove(); + }); + const nativeFile = await nativeDownload; + const nativeFilename = nativeFile.suggestedFilename(); + await nativeFile.delete(); + await page.addInitScript(() => { + const host = window.top as Window & { __artifactDownloadNames?: string[] }; + host.__artifactDownloadNames ??= []; + const click = HTMLAnchorElement.prototype.click; + HTMLAnchorElement.prototype.click = function () { + if (this.download && this.href.startsWith('blob:')) host.__artifactDownloadNames!.push(this.download); + return click.call(this); + }; + }); await page.evaluate(() => { localStorage.setItem('document-assistant-enabled', 'true'); localStorage.setItem( @@ -502,7 +570,7 @@ test('structured results validate explicitly, preview compactly and export witho }); let calls = 0; const requests: Record[] = []; - await page.route('http://localhost:11434/api/**', async (route) => { + await mockApi(page, 'http://localhost:11434/api/**', async (route) => { if (route.request().method() === 'OPTIONS') { await route.fulfill({ status: 204, @@ -577,7 +645,16 @@ test('structured results validate explicitly, preview compactly and export witho const download = page.waitForEvent('download'); await page.getByRole('button', { name: 'CSV', exact: true }).click(); const file = await download; - expect(file.suggestedFilename()).toBe('Release table.csv'); + expect( + await page.evaluate(() => (window as Window & { __artifactDownloadNames?: string[] }).__artifactDownloadNames), + ).toEqual(['Release table.csv']); + expect(file.suggestedFilename()).toBe(nativeFilename); + const exported = await readFile((await file.path())!, 'utf8'); + const rows = exported.split('\r\n'); + expect(rows).toHaveLength(26); + expect(rows[0]).toBe('"Name","Value"'); + expect(rows[1]).toBe('"AI row","\'=1+1"'); + expect(rows.at(-1)).toBe('"row 24","value"'); for (const theme of ['light', 'dark'] as const) { await page.setViewportSize({ width: 360, height: 760 }); await page.emulateMedia({ colorScheme: theme }); diff --git a/test/e2e/agent-stop-resume.spec.ts b/test/e2e/agent-stop-resume.spec.ts index 4e4d267f6..bb621c069 100644 --- a/test/e2e/agent-stop-resume.spec.ts +++ b/test/e2e/agent-stop-resume.spec.ts @@ -1,3 +1,4 @@ +import { mockApi } from './lib/mock-api'; import { expect, test } from './lib/l0'; for (const ext of ['docx', 'pptx'] as const) { @@ -23,7 +24,7 @@ for (const ext of ['docx', 'pptx'] as const) { release = resolve; }); let answers = 0; - await page.route('http://localhost:11434/api/**', async (route) => { + await mockApi(page, 'http://localhost:11434/api/**', async (route) => { if (route.request().method() === 'OPTIONS') { await route.fulfill({ status: 204, diff --git a/test/e2e/agent-task-loop.spec.ts b/test/e2e/agent-task-loop.spec.ts index da91ad607..f0611dcb2 100644 --- a/test/e2e/agent-task-loop.spec.ts +++ b/test/e2e/agent-task-loop.spec.ts @@ -1,3 +1,4 @@ +import { mockApi } from './lib/mock-api'; import { expect, test } from './lib/l0'; import { taskProtocol } from './lib/task-protocol'; @@ -24,7 +25,7 @@ for (const native of [false, true]) }, native); let nativeCalls = 0; if (native) - await page.route('https://assistant.example.invalid/v1/chat/completions', async (route) => { + await mockApi(page, 'https://assistant.example.invalid/v1/chat/completions', async (route) => { const request = route.request().postDataJSON(); expect(request.tools.some((tool: any) => tool.function.name === 'create_slide')).toBe(true); nativeCalls++; @@ -57,7 +58,7 @@ for (const native of [false, true]) }, }); }); - await page.route('http://localhost:11434/api/**', async (route) => { + await mockApi(page, 'http://localhost:11434/api/**', async (route) => { if (route.request().url().endsWith('/tags')) return route.fulfill({ json: { models: [{ name: 'task-test' }] } }); const request = route.request().postDataJSON(); @@ -148,7 +149,7 @@ for (const cancel of [false, true]) { ), ); let decisions = 0; - await page.route('http://localhost:11434/api/**', async (route) => { + await mockApi(page, 'http://localhost:11434/api/**', async (route) => { if (route.request().url().endsWith('/tags')) { await route.fulfill({ json: { models: [{ name: 'task-test' }] } }); return; diff --git a/test/e2e/agent-workspace-consistency.spec.ts b/test/e2e/agent-workspace-consistency.spec.ts index 5f3251e1e..b761e2d8c 100644 --- a/test/e2e/agent-workspace-consistency.spec.ts +++ b/test/e2e/agent-workspace-consistency.spec.ts @@ -181,18 +181,28 @@ test('cached-model delete keeps its danger role through confirmation and pending return { color: style.color, background: style.backgroundColor }; }, }); - const remove = FileSystemDirectoryHandle.prototype.removeEntry; + // This case checks the UI's pending deletion state. Use the real Cache API + // fallback on every engine: Linux WebKit exposes OPFS methods but rejects + // writable creation, so method presence cannot seed a portable fixture. + Object.defineProperty(Object.getPrototypeOf(navigator.storage), 'getDirectory', { + configurable: true, + value: undefined, + }); + const remove = Cache.prototype.delete; let release: () => void = () => {}; const hold = new Promise((resolve) => (release = resolve)); - FileSystemDirectoryHandle.prototype.removeEntry = async function (name, options) { - if (name.includes('command-fixture.gguf')) await hold; - return remove.call(this, name, options); + Cache.prototype.delete = async function (request, options) { + const url = typeof request === 'string' ? request : request instanceof Request ? request.url : String(request); + if (url.includes('command-fixture.gguf')) await hold; + return remove.call(this, request, options); }; Object.assign(window, { releaseCommandDeletion: release }); }); - await page.goto('/'); - await page.evaluate(async () => { - const cache = await (await navigator.storage.getDirectory()).getDirectoryHandle('cache', { create: true }); + await page.goto('/editor?new=docx&locale=en'); + await page.waitForFunction(() => Boolean(window.__ooFrames.readyEditor())); + expect(await page.evaluate(() => typeof navigator.storage.getDirectory)).toBe('undefined'); + const seeded = await page.evaluate(async () => { + const cache = await caches.open('local-ai-wllama-models-v1'); for (const [name, value] of [ ['command-fixture.gguf', 'x'], [ @@ -200,13 +210,11 @@ test('cached-model delete keeps its danger role through confirmation and pending JSON.stringify({ originalURL: 'https://example.invalid/command-fixture.gguf', originalSize: 1, etag: '' }), ], ]) { - const writable = await (await cache.getFileHandle(name, { create: true })).createWritable(); - await writable.write(value); - await writable.close(); + await cache.put(`/__local_ai_model_cache__/entry-${encodeURIComponent(name)}`, new Response(value)); } + return (await cache.keys()).length; }); - await page.goto('/editor?new=docx&locale=en'); - await page.waitForFunction(() => Boolean(window.__ooFrames.readyEditor())); + expect(seeded).toBe(2); await page.frameLocator('iframe[name="frameEditor"]').locator('.agent-sidebar-entry').click(); await page.locator('.agent-panel-settings-toggle').click(); await expect(page.locator('.agent-model-cache > summary')).toContainText('(1)'); diff --git a/test/e2e/agent-workspace.spec.ts b/test/e2e/agent-workspace.spec.ts index 44d60020a..fc3e15e4e 100644 --- a/test/e2e/agent-workspace.spec.ts +++ b/test/e2e/agent-workspace.spec.ts @@ -1,3 +1,4 @@ +import { mockApi } from './lib/mock-api'; import { expect, test } from './lib/l0'; import type { Page } from '@playwright/test'; @@ -38,7 +39,7 @@ test('selected Word task stages without calls, skips classification, reviews and test.setTimeout(120_000); let classification = 0, writing = 0; - await page.route('http://localhost:11434/api/**', async (route) => { + await mockApi(page, 'http://localhost:11434/api/**', async (route) => { if (route.request().method() === 'OPTIONS') { await route.fulfill({ status: 204, @@ -136,7 +137,7 @@ test('selected Word task stages without calls, skips classification, reviews and test('stale selected task retains draft and requests reselection before any model call', async ({ page }) => { test.setTimeout(120_000); let calls = 0; - await page.route('http://localhost:11434/api/**', async (route) => { + await mockApi(page, 'http://localhost:11434/api/**', async (route) => { if (route.request().url().endsWith('/tags')) { await route.fulfill({ json: { models: [{ name: 'workspace-test' }] } }); return; @@ -192,7 +193,7 @@ test('native provider still routes staged polish through writing and review', as test.setTimeout(120_000); let classifications = 0, writing = 0; - await page.route('https://assistant.example.invalid/v1/chat/completions', async (route) => { + await mockApi(page, 'https://assistant.example.invalid/v1/chat/completions', async (route) => { const body = route.request().postDataJSON(); const routing = !!body.response_format?.json_schema?.schema?.properties?.task; if (routing) classifications++; diff --git a/test/e2e/agent-write-tools.spec.ts b/test/e2e/agent-write-tools.spec.ts index 9ac4e060b..b15d5129b 100644 --- a/test/e2e/agent-write-tools.spec.ts +++ b/test/e2e/agent-write-tools.spec.ts @@ -1,3 +1,4 @@ +import { mockApi } from './lib/mock-api'; import { taskProtocol } from './lib/task-protocol'; import type { Page } from '@playwright/test'; import { expect, test } from './lib/l0'; @@ -187,7 +188,7 @@ test.describe('agent document writes (real editor)', () => { ), ); let refinements = 0; - await page.route('http://localhost:11434/api/**', async (route) => { + await mockApi(page, 'http://localhost:11434/api/**', async (route) => { if (route.request().method() === 'OPTIONS') { await route.fulfill({ status: 204, @@ -496,7 +497,7 @@ test.describe('agent document writes (real editor)', () => { }), ), ); - await page.route('http://localhost:11434/api/**', async (route) => { + await mockApi(page, 'http://localhost:11434/api/**', async (route) => { if (route.request().url().endsWith('/tags')) { await route.fulfill({ json: { models: [{ name: 'hitl-test' }] } }); return; @@ -565,7 +566,7 @@ test.describe('agent document writes (real editor)', () => { }), ), ); - await page.route('http://localhost:11434/api/**', async (route) => { + await mockApi(page, 'http://localhost:11434/api/**', async (route) => { if (route.request().url().endsWith('/tags')) { await route.fulfill({ json: { models: [{ name: 'hitl-test' }] } }); return; @@ -648,7 +649,7 @@ test.describe('agent document writes (real editor)', () => { }), ), ); - await page.route('http://localhost:11434/api/**', async (route) => { + await mockApi(page, 'http://localhost:11434/api/**', async (route) => { if (route.request().url().endsWith('/tags')) { await route.fulfill({ json: { models: [{ name: 'receipt-test' }] } }); return; diff --git a/test/e2e/assistant-loading-locale.spec.ts b/test/e2e/assistant-loading-locale.spec.ts index 4bab9cf6e..56621033a 100644 --- a/test/e2e/assistant-loading-locale.spec.ts +++ b/test/e2e/assistant-loading-locale.spec.ts @@ -1,5 +1,6 @@ import { expect, test } from './lib/l0'; import { buildDocx } from './lib/ooxml'; +import { mockApi } from './lib/mock-api'; for (const entry of ['docx', 'xlsx', 'pptx', 'open'] as const) { test(`English homepage keeps ${entry} and its assistant English after a Chinese preference`, async ({ page }) => { @@ -66,15 +67,19 @@ for (const width of [360, 640]) { const pending = new Promise((resolve) => { release = resolve; }); - await page.route('https://huggingface.co/**', async (route) => { + let heldRequests = 0; + await mockApi(page, 'https://huggingface.co/**', async (route) => { + heldRequests++; await pending; - await route.abort(); + await route.fulfill({ status: 503 }); }); await page.goto('/editor?new=docx&locale=en'); await page.waitForFunction(() => Boolean(window.__ooFrames.readyEditor())); + await page.waitForFunction(() => Boolean(navigator.serviceWorker.controller)); await page.frameLocator('iframe[name="frameEditor"]').locator('.agent-sidebar-entry').click(); await page.locator('.agent-panel-settings-toggle').click(); await page.locator('.agent-panel-load').click(); + await expect.poll(() => heldRequests).toBeGreaterThan(0); await expect(page.locator('.agent-panel-load-stop')).toBeVisible(); await expect(page.locator('.agent-panel-load-stop')).toBeFocused(); const geometry = () => @@ -114,7 +119,9 @@ for (const width of [360, 640]) { await page.locator('.agent-panel-load-stop').press('Enter'); await expect(page.locator('.agent-runtime-row')).toBeHidden(); await expect(page.locator('.agent-panel-load')).toBeFocused(); + const beforeRetry = heldRequests; await page.locator('.agent-panel-load').press('Enter'); + await expect.poll(() => heldRequests).toBeGreaterThan(beforeRetry); await expect(page.locator('.agent-panel-load-stop')).toBeVisible(); await page.locator('.agent-panel-settings-toggle').click(); const chat = await geometry(); diff --git a/test/e2e/assistant-offline-assets.spec.ts b/test/e2e/assistant-offline-assets.spec.ts index 06711bbd1..e0574de46 100644 --- a/test/e2e/assistant-offline-assets.spec.ts +++ b/test/e2e/assistant-offline-assets.spec.ts @@ -14,7 +14,12 @@ async function assetLists(page: import('@playwright/test').Page) { }); } -test('prepares only the requested runtime and keeps it available offline', async ({ page, context }) => { +test('prepares only the requested runtime and keeps it available offline', async ({ + page, + context, + l0, + projectName, +}) => { test.setTimeout(120_000); await page.goto('/'); const lists = await assetLists(page); @@ -63,9 +68,29 @@ test('prepares only the requested runtime and keeps it available offline', async expect(available.every(Boolean)).toBe(true); await page.goto('/editor?new=docx'); await page.waitForFunction(() => Boolean(window.__ooFrames.readyEditor()), undefined, { timeout: 60_000 }); + // Only unprepared optional SDK discovery may fail here. WebKit reports + // caught dynamic-import failures through pageerror as well as fetch rejection. + const optionalScripts = new Set( + [...lists.capabilities.webgpu, ...(lists.capabilities.wllama || []), ...lists.capabilities['cpu-compat']].filter( + (path) => path.endsWith('.js'), + ), + ); + const failed = new Set(); + page.on('requestfailed', (request) => failed.add(new URL(request.url()).pathname)); await page.frameLocator('iframe[name="frameEditor"]').locator('.agent-sidebar-entry').click(); await page.locator('.agent-onboarding .agent-enable-switch').click(); await expect(page.locator('.agent-runtime-panel')).toBeVisible(); + if (projectName === 'webkit') { + await expect.poll(() => [...failed].filter((path) => optionalScripts.has(path)).length).toBeGreaterThan(0); + } + expect([...failed].filter((path) => lists.core.includes(path) || lists.capabilities.cpu.includes(path))).toEqual([]); + expect([...failed].filter((path) => path.startsWith('/assets/') && !optionalScripts.has(path))).toEqual([]); + const loaderDiagnostics = /^(?:Response served by service worker is an error|Cannot load \.)$/; + const diagnostics = l0.pageErrors.filter((message) => loaderDiagnostics.test(message)); + expect(diagnostics.length).toBeLessThanOrEqual(2 * [...failed].filter((path) => optionalScripts.has(path)).length); + // The allowance is conditional on real, independently observed failures of + // exact unprepared module URLs; prepared CPU/core failures remain failures. + l0.allowFrameError(loaderDiagnostics); }); test('a remembered opt-in on Home and a cloud-only panel download no local inference or PDF assets', async ({ @@ -109,19 +134,26 @@ test('a new deployment restores the union of prepared capabilities from older wo context, }) => { test.setTimeout(120_000); - // Seed persisted outgoing-worker metadata before this browser registers the new build. - await page.goto('/manifest.json'); - await page.evaluate(async () => { - for (const [build, capability] of [ - ['old-a', 'cpu'], - ['old-b', 'pdf'], - ]) { - const cache = await caches.open(`document-editor-core-${build}`); - await cache.put( - `/__document-assistant-prepared__/${capability}`, - new Response('1', { headers: { 'X-Document-Assistant-Prepared': '1' } }), - ); - } + // WebKit can drop page-written CacheStorage bodies when navigation swaps + // renderer processes. Seed in this document, immediately before the app's + // real registration, so the new production worker sees the outgoing markers. + await page.addInitScript(() => { + const register = navigator.serviceWorker.register.bind(navigator.serviceWorker); + navigator.serviceWorker.register = async (...args) => { + for (const [build, capability] of [ + ['old-a', 'cpu'], + ['old-b', 'pdf'], + ]) { + const cache = await caches.open(`document-editor-core-${build}`); + await cache.put( + `/__document-assistant-prepared__/${capability}`, + new Response('1', { + headers: { 'X-Document-Assistant-Prepared': '1' }, + }), + ); + } + return register(...args); + }; }); await page.goto('/'); const lists = await assetLists(page); diff --git a/test/e2e/embed-api.spec.ts b/test/e2e/embed-api.spec.ts index 1f65c4d2d..d183e666f 100644 --- a/test/e2e/embed-api.spec.ts +++ b/test/e2e/embed-api.spec.ts @@ -116,7 +116,10 @@ test.describe('postMessage API', () => { expect(after).toBe(true); }); - test('embedOrigin param blocks messages from a mismatched origin', async ({ page }) => { + test('embedOrigin param blocks messages from a mismatched origin', async ({ page, l0 }) => { + l0.allowConsole( + /Unable to post message to https:\/\/allowed\.example\.com|target origin.*allowed\.example\.com|target origin.*does not match/i, + ); // page.evaluate postMessage has origin http://127.0.0.1:4173 (same origin), // which does NOT match the allowed 'https://allowed.example.com', // so the message handler should be skipped and no response received. diff --git a/test/e2e/lib/clipboard.ts b/test/e2e/lib/clipboard.ts new file mode 100644 index 000000000..283bd4415 --- /dev/null +++ b/test/e2e/lib/clipboard.ts @@ -0,0 +1,18 @@ +import type { Page } from '@playwright/test'; + +/** Observe the app's exact clipboard payload without engine-specific OS grants. */ +export async function captureClipboard(page: Page): Promise<() => Promise> { + await page.evaluate(() => { + const root = window as Window & { __copiedText?: string }; + root.__copiedText = ''; + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { + writeText: async (text: string) => { + root.__copiedText = text; + }, + }, + }); + }); + return () => page.evaluate(() => (window as Window & { __copiedText?: string }).__copiedText || ''); +} diff --git a/test/e2e/lib/mock-api.ts b/test/e2e/lib/mock-api.ts new file mode 100644 index 000000000..4aca2a9b1 --- /dev/null +++ b/test/e2e/lib/mock-api.ts @@ -0,0 +1,131 @@ +import { createServer, type Server } from 'node:http'; +import type { Page, Route } from '@playwright/test'; + +type ApiRequest = Pick, 'url' | 'method' | 'postData' | 'postDataJSON'>; +export type ApiRoute = { request(): ApiRequest; fulfill(options: Parameters[0]): Promise }; +type Handler = (route: ApiRoute) => unknown; +const servers = new WeakMap< + Page, + { server: Server; origin: string; handlers: Array<{ match: RegExp; handler: Handler }> } +>(); + +/** Resolve the logical API URL when inspecting browser network events. */ +export function originalApiUrl(page: Page, url: string): string { + const state = servers.get(page); + if (!state) return url; + try { + const request = new URL(url); + if (request.origin !== state.origin || request.pathname !== '/') return url; + const original = request.searchParams.get('url'); + return original && state.handlers.some(({ match }) => match.test(original)) ? original : url; + } catch { + return url; + } +} + +/** + * Serve fake API responses over real HTTP. WebKit does not deliver SW-controlled + * fetches to page.route, even when the SW lets the cross-origin request through. + * Only explicitly registered API URLs are rewritten; browser fetch still owns + * CORS, response streams and AbortSignal. Production worker control stays on. + */ +export async function mockApi(page: Page, pattern: string, handler: Handler): Promise { + let state = servers.get(page); + if (!state) { + const handlers: Array<{ match: RegExp; handler: Handler }> = []; + const server = createServer(async (request, response) => { + response.setHeader('Access-Control-Allow-Origin', '*'); + response.setHeader('Access-Control-Allow-Headers', request.headers['access-control-request-headers'] || '*'); + response.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); + if (request.method === 'OPTIONS') { + response.writeHead(204).end(); + return; + } + const original = new URL(request.url!, 'http://fixture').searchParams.get('url') || ''; + const target = [...handlers].reverse().find(({ match }) => match.test(original)); + if (!target) { + response.writeHead(500).end('Unregistered API fixture URL'); + return; + } + const chunks: Buffer[] = []; + try { + for await (const chunk of request) chunks.push(Buffer.from(chunk)); + const body = Buffer.concat(chunks).toString(); + await target.handler({ + request: () => ({ + url: () => original, + method: () => request.method!, + postData: () => body || null, + postDataJSON: () => JSON.parse(body), + }), + fulfill: async (options = {}) => { + if (response.destroyed) return; + for (const [key, value] of Object.entries(options.headers || {})) response.setHeader(key, value); + if (options.contentType) response.setHeader('Content-Type', options.contentType); + if (options.json !== undefined) response.setHeader('Content-Type', 'application/json'); + response.writeHead(options.status || 200); + response.end(options.json !== undefined ? JSON.stringify(options.json) : options.body || ''); + }, + }); + } catch (error) { + if (!response.destroyed) response.writeHead(500).end(String(error)); + } + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address() as { port: number }; + state = { server, origin: `http://127.0.0.1:${address.port}`, handlers }; + servers.set(page, state); + page.once('close', () => { + server.closeAllConnections(); + server.close(); + }); + } + const source = + '^' + + pattern + .split('**') + .map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) + .join('.*') + + '$'; + state.handlers.push({ match: new RegExp(source), handler }); + const options = { source, origin: state.origin }; + const install = ({ source, origin }: typeof options) => { + const root = window as Window & { __apiFixtures?: Array<{ source: string; origin: string }> }; + if (!root.__apiFixtures) { + root.__apiFixtures = []; + const nativeFetch = window.fetch.bind(window); + window.fetch = (input, init) => { + let url: string; + try { + url = input instanceof Request ? input.url : new URL(String(input), location.href).href; + } catch { + return nativeFetch(input, init); + } + const fixture = [...root.__apiFixtures!].reverse().find(({ source }) => new RegExp(source).test(url)); + if (!fixture) return nativeFetch(input, init); + const rewritten = `${fixture.origin}/?url=${encodeURIComponent(url)}`; + if (!(input instanceof Request)) return nativeFetch(rewritten, init); + const request = new Request(input, init); + return request.arrayBuffer().then((body) => + nativeFetch(rewritten, { + method: request.method, + headers: request.headers, + signal: request.signal, + mode: request.mode, + credentials: request.credentials, + cache: request.cache, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + integrity: request.integrity, + keepalive: request.keepalive, + body: request.method === 'GET' || request.method === 'HEAD' ? undefined : body, + }), + ); + }; + } + root.__apiFixtures.push({ source, origin }); + }; + await page.addInitScript(install, options); + await page.evaluate(install, options); +} diff --git a/test/e2e/main-site.spec.ts b/test/e2e/main-site.spec.ts index 90adbf33a..4ee188fa9 100644 --- a/test/e2e/main-site.spec.ts +++ b/test/e2e/main-site.spec.ts @@ -31,12 +31,40 @@ test.describe('standalone site (real editor)', () => { ); test('open a local docx from the hero button, type, Ctrl+S downloads the edited file', async ({ page }) => { + const fileName = '主站打开 (1).docx'; + await page.addInitScript(() => { + const root = window.top as Window & { __mainSiteDownloadNames?: string[] }; + root.__mainSiteDownloadNames ??= []; + const click = HTMLAnchorElement.prototype.click; + HTMLAnchorElement.prototype.click = function () { + if (this.download && this.href.startsWith('blob:')) root.__mainSiteDownloadNames!.push(this.download); + return click.call(this); + }; + }); const dir = join('test-results', 'main-site'); mkdirSync(dir, { recursive: true }); - const path = join(dir, '主站打开 (1).docx'); + const path = join(dir, fileName); writeFileSync(path, buildDocx('main site paragraph')); await page.goto('/'); + // Compare against this browser's own filename policy, independently of the + // app. The app's exact requested anchor name is asserted below as well. + const nativeDownloadPromise = page.waitForEvent('download'); + await page.evaluate((name) => { + const link = document.createElement('a'); + link.href = URL.createObjectURL( + new Blob(['filename probe'], { + type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + }), + ); + link.download = name; + document.body.append(link); + link.click(); + link.remove(); + }, fileName); + const nativeDownload = await nativeDownloadPromise; + const nativeSuggestedName = nativeDownload.suggestedFilename(); + await nativeDownload.delete(); const chooserPromise = page.waitForEvent('filechooser'); await page.locator('#hero-open').click(); const chooser = await chooserPromise; @@ -52,7 +80,10 @@ test.describe('standalone site (real editor)', () => { const downloadPromise = page.waitForEvent('download', { timeout: 60_000 }); await page.keyboard.press('Control+s'); const download = await downloadPromise; - expect(download.suggestedFilename()).toBe('主站打开 (1).docx'); + expect( + await page.evaluate(() => (window as Window & { __mainSiteDownloadNames?: string[] }).__mainSiteDownloadNames), + ).toEqual([fileName]); + expect(download.suggestedFilename()).toBe(nativeSuggestedName); const saved = await download.path(); const bytes = new Uint8Array(readFileSync(saved)); expect(bytes[0]).toBe(0x50); diff --git a/test/e2e/network-fixture.spec.ts b/test/e2e/network-fixture.spec.ts new file mode 100644 index 000000000..67b0336c3 --- /dev/null +++ b/test/e2e/network-fixture.spec.ts @@ -0,0 +1,91 @@ +import { createServer } from 'node:http'; +import { readFile } from 'node:fs/promises'; +import { expect, test } from './lib/l0'; +import { mockApi, originalApiUrl } from './lib/mock-api'; + +test('API fixtures preserve status, streaming and cancellation under the production service worker', async ({ + page, + l0, +}) => { + // The same-origin Request case intentionally triggers browser security diagnostics. + l0.allowFrameError(/^\/127\.0\.0\.1:\d+\/\?url=http%3A%2F%2Flocalhost%3A11434%2Fapi%2Fchat\.$/); + l0.allowConsole( + /Security Error:.*may not load data from http:\/\/127\.0\.0\.1:\d+\/\?url=http%3A%2F%2Flocalhost%3A11434%2Fapi%2Fchat/, + ); + l0.allowConsole( + /^Fetch API cannot load http:\/\/127\.0\.0\.1:\d+\/\?url=http%3A%2F%2Flocalhost%3A11434%2Fapi%2Fchat\. Request mode is "same-origin" but the URL's origin is not same as the request origin http:\/\/127\.0\.0\.1:\d+\.$/, + ); + const sw = await readFile('public/sw.js', 'utf8'); + const server = createServer((request, response) => { + response.setHeader('content-type', request.url === '/sw.js' ? 'text/javascript' : 'text/html'); + response.end(request.url === '/sw.js' ? sw : 'API fixture regression'); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address() as { port: number }; + let release!: () => void; + const pending = new Promise((resolve) => (release = resolve)); + try { + await mockApi(page, 'http://localhost:11434/api/**', async (route) => { + if (route.request().url().endsWith('/pending')) await pending; + await route.fulfill({ status: 202, contentType: 'text/event-stream', body: 'data: fixture\n\n' }); + }); + const origin = `http://127.0.0.1:${address.port}`; + const navigation = page.waitForRequest((request) => request.url() === `${origin}/`); + await page.goto(origin); + expect(originalApiUrl(page, (await navigation).url())).toBe(`${origin}/`); + await page.evaluate(async () => { + await navigator.serviceWorker.register('/sw.js'); + await navigator.serviceWorker.ready; + }); + await page.reload(); + expect(await page.evaluate(() => Boolean(navigator.serviceWorker.controller))).toBe(true); + const api = 'http://localhost:11434/api/chat'; + const events = Promise.all([ + page.waitForRequest((request) => originalApiUrl(page, request.url()) === api && request.method() === 'POST', { + timeout: 5000, + }), + page.waitForResponse( + (response) => originalApiUrl(page, response.url()) === api && response.request().method() === 'POST', + { timeout: 5000 }, + ), + ]); + const received = await page.evaluate(async () => { + const response = await fetch('http://localhost:11434/api/chat', { method: 'POST', body: '{}' }); + const chunk = await response.body!.getReader().read(); + return { status: response.status, text: new TextDecoder().decode(chunk.value) }; + }); + expect(received).toEqual({ status: 202, text: 'data: fixture\n\n' }); + const [request, response] = await events; + expect(originalApiUrl(page, request.url())).toBe(api); + expect(request.postData()).toBe('{}'); + expect(originalApiUrl(page, response.url())).toBe(api); + expect(response.status()).toBe(202); + expect( + await page.evaluate(async () => { + try { + await fetch(new Request('http://localhost:11434/api/chat', { mode: 'same-origin' })); + return 'completed'; + } catch (error) { + return (error as Error).name; + } + }), + ).toBe('TypeError'); + expect( + await page.evaluate(async () => { + const controller = new AbortController(); + const request = fetch('http://localhost:11434/api/pending', { signal: controller.signal }); + controller.abort(); + try { + await request; + return 'completed'; + } catch (error) { + return (error as Error).name; + } + }), + ).toBe('AbortError'); + } finally { + release(); + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + } +}); diff --git a/test/e2e/open-failure.spec.ts b/test/e2e/open-failure.spec.ts index 8a423f0db..7797fe211 100644 --- a/test/e2e/open-failure.spec.ts +++ b/test/e2e/open-failure.spec.ts @@ -11,9 +11,9 @@ import { expect, test } from './lib/l0'; * no asc_onError fired, no dialog, and document:save waited out its 60 s * timeout. Corpus campaign defect #3. * - * This is also the L0 fixture's self-test: it proves the fixture observes - * asc_onError and frame rejections (a silently broken hook would make every - * other suite look cleaner than it is). + * These cases verify the SDK error channel. A separate controlled frame + * rejection below checks L0 without depending on engine-specific vendor + * rejection delivery. * * Four shapes of unusable input, because they fail in different places. * Garbage bytes are rejected before anything is unpacked. A truncated file -- @@ -118,10 +118,36 @@ test.describe('open failure surfacing (real editor)', () => { expect(save.error).toMatch(/failed to open/i); expect(save.ms).toBeLessThan(10_000); - // The fixture saw the SDK error, and the frame rejection where the app - // produces one. + // All engines must expose the public SDK failure. Vendor promise + // rejection delivery differs between engines and is not this contract. expect((await l0.ascErrors()).map((e) => e.id)).toContain('-82'); - expect((await l0.frameErrors()).some((e) => /Document conversion failed/.test(e.message))).toBe(frameRejection); }); } }); + +// A controlled rejection proves the collector works on every engine even when +// that engine handles the vendor's failed-conversion promise internally. +test('L0 observes an unhandled rejection raised inside a same-origin iframe', async ({ page, l0 }) => { + l0.allowFrameError(/l0-frame-rejection-selftest/); + await page.goto('/'); + await page.evaluate(() => { + const frame = document.createElement('iframe'); + frame.srcdoc = 'L0 self-test'; + document.body.append(frame); + }); + const frame = page.frameLocator('iframe'); + await expect(frame.locator('body')).toHaveText('L0 self-test'); + await frame.locator('body').evaluate(() => { + setTimeout(() => { + void Promise.reject(new Error('l0-frame-rejection-selftest')); + }, 0); + }); + await expect + .poll( + async () => + (await l0.frameErrors()).filter( + (error) => error.kind === 'unhandledrejection' && error.message === 'l0-frame-rejection-selftest', + ).length, + ) + .toBe(1); +}); diff --git a/test/e2e/open-replacement.spec.ts b/test/e2e/open-replacement.spec.ts index 2de7e9307..38172b848 100644 --- a/test/e2e/open-replacement.spec.ts +++ b/test/e2e/open-replacement.spec.ts @@ -1,13 +1,16 @@ import { expect, test } from './lib/l0'; import { waitForEditorReady } from './actions/editor'; import { buildDocx, toBase64 } from './lib/ooxml'; +import { mockApi } from './lib/mock-api'; test('a failed newer download leaves an in-progress replacement usable', async ({ page, l0 }) => { test.setTimeout(120_000); l0.allowConsole(/^Error opening document from URL:/); - await page.route('https://files.example.invalid/missing-replacement.docx', (route) => - route.fulfill({ status: 404, body: 'missing', headers: { 'Access-Control-Allow-Origin': '*' } }), - ); + let missingRequests = 0; + await mockApi(page, 'https://files.example.invalid/missing-replacement.docx', (route) => { + missingRequests++; + return route.fulfill({ status: 404, body: 'missing' }); + }); await page.goto('/embed-demo.html'); await expect(page.locator('#status')).toHaveText('ready', { timeout: 60_000 }); const base64 = toBase64(buildDocx('A replacement remains available after a failed download.')); @@ -40,6 +43,7 @@ test('a failed newer download leaves an in-progress replacement usable', async ( return String(error); } }); + expect(missingRequests).toBe(1); expect(failed).toContain('404'); expect(await page.evaluate(() => (window as any).replacementResult)).toBe('opened'); await waitForEditorReady(page); diff --git a/test/e2e/open-retry.spec.ts b/test/e2e/open-retry.spec.ts index bcad118ed..32392227f 100644 --- a/test/e2e/open-retry.spec.ts +++ b/test/e2e/open-retry.spec.ts @@ -1,5 +1,6 @@ import { FONT_SYSTEM_WAIT_MS } from '../../lib/onlyoffice/font-system'; import { expect, test } from './lib/l0'; +import { mockApi } from './lib/mock-api'; /** * Environment-class open failures must be retried, not reported (GitHub #144: @@ -29,9 +30,11 @@ test.describe('open retry after an environment failure (real editor)', () => { l0.allowFrameError(/Document conversion failed/); l0.allowConsole(/Document conversion failed|open conversion failed|retrying the open|fetchFonts called before/); l0.allowConsole(/^Error opening document from URL:/); - await page.route('https://files.example.invalid/missing-retry.docx', (route) => - route.fulfill({ status: 404, body: 'missing', headers: { 'Access-Control-Allow-Origin': '*' } }), - ); + let missingRequests = 0; + await mockApi(page, 'https://files.example.invalid/missing-retry.docx', (route) => { + missingRequests++; + return route.fulfill({ status: 404, body: 'missing' }); + }); await page.addInitScript( ([key]) => { @@ -171,6 +174,7 @@ test.describe('open retry after an environment failure (real editor)', () => { // The fault really fired (a test that silently stopped injecting would // otherwise pass forever) and the retry opened the document anyway. + expect(missingRequests).toBe(1); expect(result.fault).toBe('fired'); expect(result.loaded).toBe(true); expect(appLog.some((line) => /open conversion failed/.test(line))).toBe(true); diff --git a/test/e2e/open-url-failure.spec.ts b/test/e2e/open-url-failure.spec.ts index 02b261e17..7e80365f1 100644 --- a/test/e2e/open-url-failure.spec.ts +++ b/test/e2e/open-url-failure.spec.ts @@ -22,7 +22,9 @@ test.describe('a document link that cannot be fetched', () => { test.describe.configure({ timeout: 120_000 }); test('is reported in a toast that says what to do, not in an alert', async ({ page, l0 }) => { - l0.allowConsole(/Error opening document from URL|Failed to fetch|Failed to load resource|net::ERR/); + l0.allowConsole( + /Error opening document from URL|Failed to fetch|Failed to load resource|net::ERR|Cross-Origin Request Blocked:.*cors-refused\.invalid/, + ); // An alert would block the page; record any that appear instead of letting // Playwright's auto-dismiss hide the regression. diff --git a/test/e2e/pdf-agent-contract.spec.ts b/test/e2e/pdf-agent-contract.spec.ts index d5ff47f71..3cee958ef 100644 --- a/test/e2e/pdf-agent-contract.spec.ts +++ b/test/e2e/pdf-agent-contract.spec.ts @@ -1,3 +1,4 @@ +import { mockApi } from './lib/mock-api'; import { taskProtocol } from './lib/task-protocol'; import { buildDocx, toBase64 } from './lib/ooxml'; import { expect, test } from './lib/l0'; @@ -36,7 +37,7 @@ test('native PDF preflight rejects body edits, preserves ordinary chat and permi let taskCalls = 0; let classifications = 0; let greetings = 0; - await page.route('https://assistant.example.invalid/v1/chat/completions', async (route) => { + await mockApi(page, 'https://assistant.example.invalid/v1/chat/completions', async (route) => { const request = route.request().postDataJSON(); if (phase === 'greet') { // Plain greetings deliberately bypass both PDF effect classification and tools. @@ -302,7 +303,7 @@ test('PDF assistant reads scoped content and reviews a page note before a revers ); let grounded = false; let groundedReadContinuation = false; - await page.route('http://localhost:11434/api/**', async (route) => { + await mockApi(page, 'http://localhost:11434/api/**', async (route) => { if (route.request().method() === 'OPTIONS') { await route.fulfill({ status: 204, diff --git a/test/e2e/save-request-ownership.spec.ts b/test/e2e/save-request-ownership.spec.ts new file mode 100644 index 000000000..d9c539a1a --- /dev/null +++ b/test/e2e/save-request-ownership.spec.ts @@ -0,0 +1,42 @@ +import { expect, test } from './lib/l0'; +import { buildDocx, toBase64 } from './lib/ooxml'; +import { waitForEditorReady } from './actions/editor'; + +test('a PDF export retained by native pagination resolves its embed request without a disk download', async ({ + page, +}) => { + test.setTimeout(120_000); + let downloads = 0; + page.on('download', () => downloads++); + await page.goto('/embed-demo.html'); + await expect(page.locator('#status')).toHaveText('ready', { timeout: 60_000 }); + await page.evaluate( + async (buffer) => { + await post('document:open-buffer', { fileName: 'Deferred.docx', buffer }); + }, + toBase64(buildDocx('PDF ownership survives deferred pagination')), + ); + await waitForEditorReady(page); + await page.evaluate(() => { + const drawing = (window.__ooFrames.readyEditor() as any).Asc.editor.WordControl.m_oDrawingDocument; + drawing.m_bIsDocumentCalculating = true; + (window as any).__pendingPdf = post('document:save', { targetExt: 'pdf' }); + }); + await page.waitForFunction(() => { + const drawing = (window.__ooFrames.readyEditor() as any).Asc.editor.WordControl.m_oDrawingDocument; + return Array.isArray(drawing.m_arPrintingWaitEndRecalculate); + }); + await page.evaluate(() => { + const api = (window.__ooFrames.readyEditor() as any).Asc.editor; + const drawing = api.WordControl.m_oDrawingDocument; + drawing.m_bIsDocumentCalculating = false; + // The SDK's OnEndRecalculate resumes using the exact retained invocation. + api.downloadAs.apply(api, drawing.m_arPrintingWaitEndRecalculate); + }); + const saved = await page.evaluate(async () => { + const result = await (window as any).__pendingPdf; + return { name: result.fileName, header: await result.file.slice(0, 5).text() }; + }); + expect(saved).toEqual({ name: 'Deferred.pdf', header: '%PDF-' }); + expect(downloads).toBe(0); +}); diff --git a/test/e2e/save-to-file.spec.ts b/test/e2e/save-to-file.spec.ts index 81105d598..d3fb81b72 100644 --- a/test/e2e/save-to-file.spec.ts +++ b/test/e2e/save-to-file.spec.ts @@ -1,7 +1,7 @@ import { mkdirSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import type { Page } from '@playwright/test'; -import { readHistoryKeys } from './lib/history-db'; +import { readHistoryDocs, readHistoryKeys } from './lib/history-db'; import { buildDocx, ooxmlText, zipEntryText } from './lib/ooxml'; import { expect, test } from './lib/l0'; import { typeIntoDocument, waitForEditorReady as waitForSdkReady } from './actions/editor'; @@ -267,6 +267,64 @@ test.describe('saving into the document own file (real editor)', () => { expect(await savedText(page)).toMatch(/first version second version/i); }); + test('Ctrl+S reaches disk while a recovery conversion is awaiting delivery', async ({ page }) => { + await page.goto('/editor?new=docx'); + await waitForEditorReady(page); + const sdk = page.frameLocator('iframe[name="frameEditor"]').locator('#editor_sdk'); + await sdk.click({ position: { x: 300, y: 200 } }); + // This case measures save ownership, so seed its baseline atomically through + // the real browser input path rather than racing the SDK's initial key hooks. + await page.keyboard.insertText('Recovery version'); + const documentText = () => + page.evaluate(() => { + const win = window.__ooFrames.readyEditor() as any; + const logic = (win.Asc?.editor ?? win.editor).WordControl.m_oLogicDocument; + const paragraphs: any[] = []; + for (const element of logic.Content) element.GetAllParagraphs({ All: true }, paragraphs); + return paragraphs + .map((paragraph) => paragraph.GetText({ Numbering: false })) + .join('') + .trim(); + }); + await expect.poll(documentText).toBe('Recovery version'); + await page.evaluate(() => { + const frame = document.querySelector('iframe[name="frameEditor"]')!; + const converter = (frame.contentWindow as any).AscCommon.x2t; + const convert = converter.convertFromBin.bind(converter); + let first = true; + converter.convertFromBin = (input: unknown) => { + const result = convert(input); + if (!first) return result; + first = false; + // Hold delivery after the real SDK serialization and x2t conversion. + // A manual save can now overlap without changing the recovery bytes. + return result.then( + (output: unknown) => + new Promise((resolve) => { + (window as any).__releaseRecovery = () => resolve(output); + }), + ); + }; + Object.defineProperty(document, 'visibilityState', { value: 'hidden', configurable: true }); + document.dispatchEvent(new Event('visibilitychange')); + Object.defineProperty(document, 'visibilityState', { value: 'visible', configurable: true }); + }); + await page.waitForFunction(() => typeof (window as any).__releaseRecovery === 'function'); + try { + await page.keyboard.press('End'); + await page.keyboard.type(' manual version', { delay: 30 }); + await expect.poll(documentText).toBe('Recovery version manual version'); + await page.keyboard.press('Control+s'); + await waitForWrites(page, 1); + expect(await savedText(page)).toContain('Recovery version manual version'); + } finally { + await page.evaluate(() => (window as any).__releaseRecovery()); + } + await expect.poll(async () => (await readHistoryDocs(page)).length).toBe(1); + expect(await writesCommitted(page)).toBe(1); + expect(await pickerCalls(page)).toBe(1); + }); + for (const extension of ['xlsx', 'pptx']) { test(`${extension}: another edit after saving rearms the unsaved-changes warning`, async ({ page }) => { await page.goto(`/editor?new=${extension}`); diff --git a/test/e2e/theme-presentation.spec.ts b/test/e2e/theme-presentation.spec.ts index f729adc38..7cbbb9bb0 100644 --- a/test/e2e/theme-presentation.spec.ts +++ b/test/e2e/theme-presentation.spec.ts @@ -3,9 +3,11 @@ import { expect, test } from './lib/l0'; for (const system of ['light', 'dark'] as const) { for (const choice of ['system', 'light', 'dark'] as const) { test(`${system} system with ${choice} choice keeps theme metadata and icons consistent`, async ({ page }) => { - await page.emulateMedia({ colorScheme: system }); await page.addInitScript((theme) => localStorage.setItem('ran-theme', theme), choice); await page.goto('/'); + // Firefox's emulation can reset on the first navigation. Apply it to + // this document, then assert the real media-query/theme change. + await page.emulateMedia({ colorScheme: system }); const expected = choice === 'system' ? system : choice; await expect(page.locator('link[rel="icon"][type="image/svg+xml"]')).toHaveAttribute( 'href', diff --git a/test/unit/agent-completion.test.ts b/test/unit/agent-completion.test.ts new file mode 100644 index 000000000..d5c2e9068 --- /dev/null +++ b/test/unit/agent-completion.test.ts @@ -0,0 +1,197 @@ +import { expect, it, vi } from 'vitest'; +import { runAgent, type AgentEvent } from '@ranuts/agent-core/runtime'; +import { parseGeminiResponse } from '@ranuts/agent-core/llm/gemini'; +import { parseOpenAIResponse } from '@ranuts/agent-core/llm/openai-format'; +import type { LLMMessage, LLMProvider } from '@ranuts/agent-core/llm/types'; +import { t } from '@ranuts/shared/i18n'; +import { AgentChatController, type ChatTurn } from '../../lib/agent-plugin/ui/controller'; +import { RequestQueue } from '../../lib/agent-plugin/ui/request-queue'; +import { historyToTurns } from '../../lib/agent-plugin/ui/storage'; +import { ChatView } from '../../packages/chat-ui/src/chat-view'; + +const cases = [ + 'length', + 'max_tokens', + 'content_filter', + 'MAX_TOKENS', + 'CONTINUATION', + 'SAFETY', + 'RECITATION', + 'BLOCKLIST', + 'PROHIBITED_CONTENT', + 'SPII', + 'LANGUAGE', + 'IMAGE_SAFETY', + 'IMAGE_PROHIBITED_CONTENT', + 'IMAGE_RECITATION', + 'ESCALATION', + 'PUP_LIMITED_DISABLED', + 'MALFORMED_FUNCTION_CALL', + 'UNEXPECTED_TOOL_CALL', + 'TOO_MANY_TOOL_CALLS', + 'MISSING_THOUGHT_SIGNATURE', + 'MALFORMED_RESPONSE', + 'OTHER', + 'IMAGE_OTHER', + 'NO_IMAGE', + 'FINISH_REASON_UNSPECIFIED', + 'FUTURE_TERMINATION', +] as const; +const expectedReason = (reason: string) => + ['length', 'max_tokens', 'MAX_TOKENS', 'CONTINUATION'].includes(reason) + ? 'max_tokens' + : [ + 'content_filter', + 'SAFETY', + 'RECITATION', + 'BLOCKLIST', + 'PROHIBITED_CONTENT', + 'SPII', + 'LANGUAGE', + 'IMAGE_SAFETY', + 'IMAGE_PROHIBITED_CONTENT', + 'IMAGE_RECITATION', + 'ESCALATION', + 'PUP_LIMITED_DISABLED', + ].includes(reason) + ? 'content_filter' + : 'provider'; +function provider(stopReason: string, streaming: boolean, tool = false): LLMProvider { + const response = + stopReason === stopReason.toUpperCase() + ? parseGeminiResponse({ + candidates: [ + { + finishReason: stopReason, + content: { + parts: [ + { text: 'Partial answer ending mid' }, + ...(tool ? [{ functionCall: { name: 'write', args: { text: 'Unfinished request' } } }] : []), + ], + }, + }, + ], + }) + : parseOpenAIResponse({ + choices: [ + { + finish_reason: stopReason, + message: { + content: 'Partial answer ending mid', + ...(tool + ? { + tool_calls: [ + { + id: 'unfinished', + type: 'function' as const, + function: { name: 'write', arguments: '{"text":' }, + }, + ], + } + : {}), + }, + }, + ], + }); + return { + name: 'test', + isReady: () => true, + chat: async () => response, + ...(streaming + ? { + chatStream: async (_messages: unknown, _tools: unknown, delta: (text: string) => void) => { + delta(response.text); + return response; + }, + } + : {}), + }; +} + +for (const streaming of [false, true]) { + it.each(cases)(`preserves %s output as incomplete (${streaming ? 'stream' : 'chat'})`, async (reason) => { + const events: AgentEvent[] = []; + const result = await runAgent(provider(reason, streaming), 'question', { onEvent: (event) => events.push(event) }); + expect(result).toMatchObject({ + text: 'Partial answer ending mid', + incompleteReason: expectedReason(reason), + aborted: false, + }); + expect(result.messages.at(-1)).toMatchObject({ interrupted: true }); + expect(events).toContainEqual({ type: 'assistant', text: result.text, streamed: streaming, interrupted: true }); + }); + it.each(cases)( + `never executes incomplete tool arguments on %s (${streaming ? 'stream' : 'chat'})`, + async (reason) => { + const execute = vi.fn(async () => ({ changed: true })); + const result = await runAgent(provider(reason, streaming, true), 'write', { + tools: { write: { name: 'write', description: 'write', inputSchema: {}, readOnlyHint: false, execute } }, + }); + expect(execute).not.toHaveBeenCalled(); + expect(result.toolCallCount).toBe(0); + expect(result.messages).toEqual([ + { role: 'user', content: 'write' }, + { role: 'assistant', content: 'Partial answer ending mid', interrupted: true }, + ]); + }, + ); + it.each(cases)( + `marks live and restored %s answers incomplete and pauses queued successors (${streaming ? 'stream' : 'chat'})`, + async (reason) => { + const turns: ChatTurn[] = []; + let saved: LLMMessage[] = []; + const view = new ChatView({ onSend: () => {}, onApplyMessage: async () => 'verified' }); + view.el.querySelector('.cui-messages')!.scrollTo = vi.fn(); + view.setRunning(true); + const controller = new AgentChatController( + provider(reason, streaming), + (turn) => { + turns.push(turn); + view.append(turn); + }, + { + onAgentDelta: (delta) => view.appendDelta(delta), + onAgentStreamEnd: () => view.endStream(), + storage: { + load: () => [], + save: (messages) => { + saved = structuredClone(messages); + }, + clear: () => {}, + }, + }, + ); + const executed: string[] = []; + const outcomes: string[] = []; + const queue = new RequestQueue({ + execute: async (entry, accept) => { + executed.push(entry.text); + accept(); + const result = await controller.send(entry.text); + outcomes.push(result.status); + return result; + }, + cancel: () => controller.stop(), + revoke: () => {}, + changed: () => {}, + }); + queue.select('a'); + queue.enqueue('a', 'first', {}); + queue.enqueue('a', 'second', {}); + await vi.waitFor(() => expect(queue.busy).toBe(false)); + expect(outcomes).toEqual(['incomplete']); + expect(executed).toEqual(['first']); + expect(queue.state('a')).toMatchObject({ paused: true, entries: [{ text: 'second' }] }); + expect(turns.at(-1)).toEqual({ role: 'status', text: t('agentResponseIncomplete') }); + expect(historyToTurns(saved)).toContainEqual({ + role: 'agent', + text: 'Partial answer ending mid', + interrupted: true, + }); + expect(view.getLastAnswer()).toBe(''); + expect(view.el.querySelector('[data-interrupted]')?.textContent).toContain('Partial answer ending mid'); + expect(view.el.querySelector('.cui-apply')).toBeNull(); + view.dispose(); + }, + ); +} diff --git a/test/unit/agent-direct-completion.test.ts b/test/unit/agent-direct-completion.test.ts new file mode 100644 index 000000000..9e1576cca --- /dev/null +++ b/test/unit/agent-direct-completion.test.ts @@ -0,0 +1,148 @@ +import { expect, it } from 'vitest'; +import { parseGeminiResponse } from '@ranuts/agent-core/llm/gemini'; +import { parseOpenAIResponse } from '@ranuts/agent-core/llm/openai-format'; +import { generateWriting } from '@ranuts/agent-core/llm/writing-task'; +import { generateActionPlan } from '@ranuts/agent-core/llm/action-plan'; +import type { LLMProvider, LLMResponse } from '@ranuts/agent-core/llm/types'; +import { generateReadAnswer } from '../../lib/agent-plugin/document-read-answer'; +import { generateDocumentToolPlan } from '../../lib/agent-plugin/document-tool-plan'; +import { generateDocumentDraft } from '../../lib/agent-plugin/ui/document-draft'; +import { resolveRequestIntent } from '../../lib/agent-plugin/ui/request-intent'; + +const reasons = [ + 'content_filter', + 'length', + 'max_tokens', + 'SAFETY', + 'CONTINUATION', + 'MAX_TOKENS', + 'incomplete', + 'OTHER', + 'MALFORMED_FUNCTION_CALL', +] as const; +function parsed(text: string, reason: string): LLMResponse { + return reason === reason.toUpperCase() + ? parseGeminiResponse({ candidates: [{ finishReason: reason, content: { parts: [{ text }] } }] }) + : parseOpenAIResponse({ choices: [{ finish_reason: reason, message: { content: text } }] }); +} +function provider(response: LLMResponse, schema: boolean): LLMProvider { + return { + name: 'direct', + isReady: () => true, + chat: async () => response, + ...(schema ? { generateJSON: async () => response } : {}), + }; +} +for (const schema of [false, true]) { + it.each(reasons)( + `rejects valid %s JSON before returning an applicable writing body (${schema ? 'schema' : 'chat'})`, + async (reason) => { + const response = parsed('{"text":"The meeting will start soon."}', reason); + await expect( + generateWriting( + provider(response, schema), + { + task: 'rewrite', + text: 'The meeting starts soon.', + }, + new AbortController().signal, + ), + ).rejects.toThrow('Incomplete writing response'); + }, + ); + it.each(reasons)( + `rejects a valid ready proposal on %s before producing an executable plan (${schema ? 'schema' : 'chat'})`, + async (reason) => { + const response = parsed('{"status":"ready","content":"Hello there"}', reason); + await expect( + generateActionPlan(provider(response, schema), 'Insert a greeting', 'word', new AbortController().signal), + ).rejects.toThrow('Incomplete or unexpected action response'); + }, + ); + it.each(reasons)( + `rejects a valid %s document tool plan before enabling review (${schema ? 'schema' : 'chat'})`, + async (reason) => { + const response = parsed('{"tool":"insert_text","input":{"text":"Hello there"}}', reason); + await expect( + generateDocumentToolPlan( + provider(response, schema), + 'Make an introduction', + { kind: 'word' }, + new AbortController().signal, + ), + ).rejects.toThrow('Incomplete document operation response'); + }, + ); + it.each(reasons)( + `rejects valid %s task-routing JSON before selecting a tool task (${schema ? 'schema' : 'chat'})`, + async (reason) => { + const response = parsed('{"task":"tools","language":"en"}', reason); + await expect( + resolveRequestIntent( + provider(response, schema), + 'Make an introduction', + { kind: 'word' }, + new AbortController().signal, + ), + ).rejects.toThrow('Incomplete task routing response'); + }, + ); + it.each(reasons)( + `rejects a valid %s document draft before returning a complete artifact (${schema ? 'schema' : 'chat'})`, + async (reason) => { + const response = parsed('{"text":"A complete-looking document body."}', reason); + await expect( + generateDocumentDraft(provider(response, schema), 'Write an introduction', new AbortController().signal), + ).rejects.toThrow('Incomplete writing response'); + }, + ); +} +for (const streaming of [false, true]) { + it.each(reasons)( + `rejects a nonempty %s document answer before declaring it complete (${streaming ? 'stream' : 'chat'})`, + async (reason) => { + const response = parsed('The budget is 120.', reason); + const partial: string[] = []; + const backend: LLMProvider = { + ...provider(response, false), + ...(streaming + ? { + chatStream: async (_messages: unknown, _tools: unknown, delta: (text: string) => void) => { + delta(response.text); + return response; + }, + } + : {}), + }; + await expect( + generateReadAnswer( + backend, + 'What is the budget?', + { kind: 'word' }, + [], + new AbortController().signal, + (delta) => partial.push(delta), + ), + ).rejects.toThrow('Incomplete document answer'); + expect(partial.join('')).toBe(streaming ? 'The budget is 120.' : ''); + }, + ); +} + +it.each(reasons)('keeps a %s streamed artifact preview from becoming an accepted document body', async (reason) => { + const response = parsed('{"text":"A document preview."}', reason); + let partial = ''; + const backend: LLMProvider = { + ...provider(response, true), + generateJSONStream: async (_messages, _schema, delta) => { + delta(response.text); + return response; + }, + }; + await expect( + generateDocumentDraft(backend, 'Write an introduction', new AbortController().signal, (delta) => { + partial += delta; + }), + ).rejects.toThrow('Incomplete writing response'); + expect(partial).toBe('A document preview.'); +}); diff --git a/test/unit/agent-llm-gemini.test.ts b/test/unit/agent-llm-gemini.test.ts index bca09bd42..5f9b29fcf 100644 --- a/test/unit/agent-llm-gemini.test.ts +++ b/test/unit/agent-llm-gemini.test.ts @@ -179,3 +179,49 @@ it('rejects a token-limited Gemini result before writing it into the document', ), ).rejects.toThrow('Incomplete writing response'); }); + +it.each([ + ['MAX_TOKENS', 'max_tokens'], + ['CONTINUATION', 'max_tokens'], + ['SAFETY', 'content_filter'], + ['RECITATION', 'content_filter'], + ['BLOCKLIST', 'content_filter'], + ['PROHIBITED_CONTENT', 'content_filter'], + ['SPII', 'content_filter'], +])('normalizes Gemini %s into a neutral incomplete termination', (finishReason, stopReason) => { + expect( + parseGeminiResponse({ candidates: [{ finishReason, content: { parts: [{ text: 'partial' }] } }] }), + ).toMatchObject({ + text: 'partial', + stopReason, + }); +}); + +it.each([ + ['LANGUAGE', 'content_filter'], + ['IMAGE_SAFETY', 'content_filter'], + ['IMAGE_PROHIBITED_CONTENT', 'content_filter'], + ['IMAGE_RECITATION', 'content_filter'], + ['ESCALATION', 'content_filter'], + ['PUP_LIMITED_DISABLED', 'content_filter'], + ['MALFORMED_FUNCTION_CALL', 'incomplete'], + ['UNEXPECTED_TOOL_CALL', 'incomplete'], + ['TOO_MANY_TOOL_CALLS', 'incomplete'], + ['MISSING_THOUGHT_SIGNATURE', 'incomplete'], + ['MALFORMED_RESPONSE', 'incomplete'], + ['OTHER', 'incomplete'], + ['IMAGE_OTHER', 'incomplete'], + ['NO_IMAGE', 'incomplete'], + ['FINISH_REASON_UNSPECIFIED', 'incomplete'], + ['FUTURE_TERMINATION', 'incomplete'], +])('rejects Gemini abnormal termination %s as incomplete', (finishReason, stopReason) => { + expect( + parseGeminiResponse({ candidates: [{ finishReason, content: { parts: [{ text: 'partial' }] } }] }), + ).toMatchObject({ text: 'partial', stopReason }); +}); +it('keeps absent Gemini finish reasons usable for intermediate stream chunks', () => { + expect(parseGeminiResponse({ candidates: [{ content: { parts: [{ text: 'delta' }] } }] })).toMatchObject({ + text: 'delta', + stopReason: 'stop', + }); +}); diff --git a/test/unit/agent-panel-loading.test.ts b/test/unit/agent-panel-loading.test.ts index 13f37b822..d17d12c26 100644 --- a/test/unit/agent-panel-loading.test.ts +++ b/test/unit/agent-panel-loading.test.ts @@ -53,6 +53,7 @@ const state = vi.hoisted(() => ({ source: '', readText: undefined as string | undefined, readAnswer: vi.fn().mockResolvedValue('120'), + actualDocumentTask: false, directTaskAnswer: undefined as undefined | ((delta?: (text: string) => void) => Promise), editorReady: false, backend: undefined as undefined | ((backend: 'webllm' | 'wllama') => void), @@ -339,7 +340,8 @@ vi.mock('../../lib/agent-plugin/document-task', async (original) => { request: string, options: Parameters[2], ) => { - if (options.target.context.kind === 'pdf') return actual.runDocumentTask(provider, request, options); + if (options.target.context.kind === 'pdf' || state.actualDocumentTask) + return actual.runDocumentTask(provider, request, options); options.beforeRun?.(); return runAgent( createSchemaTaskProvider(provider, () => options.target.context), @@ -416,6 +418,7 @@ afterEach(() => { state.toolPlan.mockReset(); state.readAnswer.mockReset().mockResolvedValue('120'); state.directTaskAnswer = undefined; + state.actualDocumentTask = false; state.toolContext = 'slide'; state.apply.mockClear(); state.routing.mockReset(); @@ -5384,3 +5387,89 @@ it.each(['error', 'expired', 'abort'] as const)( } }, ); + +it.each(['length', 'max_tokens', 'content_filter'])( + 'retains incomplete native document prose and pauses the queue on %s', + async (reason) => { + state.nativeEndpoint = true; + state.editorReady = true; + state.editorKind = 'slide'; + state.toolContext = 'slide'; + let finish!: () => void; + let nativeCalls = 0; + vi.stubGlobal( + 'fetch', + vi.fn(async (_url: string, init: RequestInit) => { + const body = JSON.parse(String(init.body)); + if (body.tools?.length) { + nativeCalls++; + await new Promise((resolve) => { + finish = resolve; + }); + } + return new Response( + JSON.stringify({ + choices: [ + { + message: { content: body.tools?.length ? 'Partial document explanation' : 'connected' }, + finish_reason: body.tools?.length ? reason : 'stop', + }, + ], + }), + ); + }), + ); + try { + const panel = createAgentPanel(); + document.body.append(panel); + const kind = panel.querySelector('.agent-endpoint-kind')!; + kind.value = 'openai-compatible'; + kind.dispatchEvent(new Event('change')); + for (const [selector, value] of [ + ['.agent-panel-endpoint-url', 'https://api.example.com/v1'], + ['.agent-panel-endpoint-model', 'tool-model'], + ['.agent-panel-endpoint-key', 'sk-test'], + ]) { + const field = panel.querySelector(selector)!; + field.value = value; + field.dispatchEvent(new Event('change')); + } + const connect = panel.querySelector('.agent-panel-endpoint-connect')!; + connect.click(); + await vi.waitFor(() => expect(connect.textContent).toBe(t('agentEndpointDisconnect'))); + const task = panel.querySelector('.agent-writing-task')!; + task.value = 'tools'; + task.dispatchEvent(new Event('change')); + enterQueueDraft(panel, 'Read this slide and explain its content'); + await vi.waitFor(() => expect(finish).toBeTypeOf('function')); + enterQueueDraft(panel, 'Hello'); + finish(); + await vi.waitFor(() => expect(panel.textContent).toContain(t('agentResponseIncomplete'))); + expect(panel.querySelector('[data-interrupted]')?.textContent).toContain('Partial document explanation'); + expect(panel.querySelector('.agent-queue-resume')).not.toBeNull(); + expect(panel.querySelector('.agent-queue-text')?.textContent).toBe('Hello'); + expect(nativeCalls).toBe(1); + expect(state.apply).not.toHaveBeenCalled(); + } finally { + finish?.(); + vi.unstubAllGlobals(); + } + }, +); + +it('retains incomplete schema document-task prose without finalizing its live answer', async () => { + state.ready = true; + state.actualDocumentTask = true; + state.semanticResponse = '{"task":"tools","language":"en"}'; + state.structuredStream = async (_messages, _schema, delta) => { + const text = '{"kind":"answer","text":"Partial schema explanation'; + delta(text); + return { text, assistant: { role: 'assistant', content: text }, toolCalls: [], stopReason: 'max_tokens' }; + }; + const panel = createPreparedPanel(); + document.body.append(panel); + enterQueueDraft(panel, 'Explain what should happen next in this presentation.'); + await vi.waitFor(() => expect(panel.textContent).toContain(t('agentResponseIncomplete'))); + expect(panel.querySelector('[data-interrupted]')?.textContent).toContain('Partial schema explanation'); + expect(state.apply).not.toHaveBeenCalled(); +}); diff --git a/test/unit/agent-panel.test.ts b/test/unit/agent-panel.test.ts index c463072c2..7547f4380 100644 --- a/test/unit/agent-panel.test.ts +++ b/test/unit/agent-panel.test.ts @@ -358,6 +358,54 @@ it('syncs native tracking changes without writing settings and detaches on pageh expect(callbacks.size).toBe(0); }); +it.each(['recover', 'external', 'editor', 'close', 'settings'] as const)( + 'handles an iframe rejecting the initial panel focus without overriding %s intent', + async (intent) => { + vi.useFakeTimers(); + const previousPreset = localStorage.getItem('agent-local-preset'); + localStorage.setItem('agent-local-preset', 'Qwen3-4B-q4f16_1-MLC'); + const app = document.createElement('div'); + app.id = 'app'; + const frame = document.createElement('iframe'); + app.append(frame); + document.body.append(app); + const panel = createAgentPanel({ background: true }); + const input = panel.querySelector('.cui-input')!; + // Native cross-frame focus can reject the first attempt during docking. + // Keep the real focus implementation available for a later retry. + const focus = vi.spyOn(input, 'focus').mockImplementationOnce(() => {}); + try { + frame.focus(); + setAgentPanelOpen(true); + await Promise.resolve(); + expect(focus).toHaveBeenCalled(); + expect(document.activeElement).toBe(frame); + let expected: Element = input; + if (intent === 'external') { + const button = document.createElement('button'); + document.body.append(button); + button.focus(); + expected = button; + } else if (intent === 'editor') { + frame.dispatchEvent(new Event('pointerdown', { bubbles: true })); + expected = frame; + } else if (intent === 'close') { + setAgentPanelOpen(false); + expected = document.activeElement!; + } else if (intent === 'settings') { + panel.querySelector('.agent-panel-settings-toggle')!.click(); + expected = document.activeElement!; + } + await vi.advanceTimersByTimeAsync(40); + expect(document.activeElement).toBe(expected); + } finally { + focus.mockRestore(); + if (previousPreset === null) localStorage.removeItem('agent-local-preset'); + else localStorage.setItem('agent-local-preset', previousPreset); + } + }, +); + it('returns keyboard focus to the document rail when closing a focused panel', async () => { const app = document.createElement('div'); app.id = 'app'; diff --git a/test/unit/agent-sidebar-entry.test.ts b/test/unit/agent-sidebar-entry.test.ts index db9209409..80156abfb 100644 --- a/test/unit/agent-sidebar-entry.test.ts +++ b/test/unit/agent-sidebar-entry.test.ts @@ -237,3 +237,34 @@ it('rebinds a frame document on load and ignores late callbacks after disabling await flushMutations(); expect(nextDoc.querySelector('.agent-sidebar-entry')).toBeNull(); }); + +it('isolates native entry presses from vendor rail refocus without cancelling default button behavior', async () => { + vi.useFakeTimers(); + const frame = document.createElement('iframe'); + document.body.append(frame); + const doc = frame.contentDocument!; + doc.body.innerHTML = '

'; + const rail = doc.querySelector('.tool-menu-btns')!; + const editor = doc.querySelector('#editor')!; + // The vendor rail defers editor focus on mousedown. Letting that handler + // see the assistant's entry steals focus after the parent composer opens. + rail.addEventListener('mousedown', () => setTimeout(() => editor.focus(), 0)); + const toggle = vi.fn(); + const entry = createSidebarEntry(toggle, () => 'AI'); + try { + entry.update(true, false); + const button = doc.querySelector('.agent-sidebar-entry')!; + const composer = document.createElement('textarea'); + document.body.append(composer); + composer.focus(); + const press = new MouseEvent('mousedown', { bubbles: true, cancelable: true }); + button.dispatchEvent(press); + await vi.runOnlyPendingTimersAsync(); + expect(document.activeElement).toBe(composer); + expect(press.defaultPrevented).toBe(false); + button.click(); + expect(toggle).toHaveBeenCalledOnce(); + } finally { + entry.dispose(); + } +}); diff --git a/test/unit/agent-tool-context-budget.test.ts b/test/unit/agent-tool-context-budget.test.ts new file mode 100644 index 000000000..097f70b93 --- /dev/null +++ b/test/unit/agent-tool-context-budget.test.ts @@ -0,0 +1,341 @@ +import { expect, it, vi } from 'vitest'; +import { runAgent } from '@ranuts/agent-core/runtime'; +import type { LLMMessage, LLMProvider, LLMResponse } from '@ranuts/agent-core/llm/types'; +import { getDocumentTextTool, getPresentationTextTool } from '../../lib/agent-plugin/tools'; +import { createDocumentAgentTools } from '../../lib/agent-plugin/document-agent-tools'; + +const editor = vi.hoisted(() => ({ body: '', slide: false })); +vi.mock('../../lib/agent-plugin/editor-bridge', () => ({ + requireEditorApi: () => ({ + WordControl: { + m_oLogicDocument: editor.slide + ? { Slides: [{ cSld: { spTree: [{ getText: () => editor.body }] } }] } + : { GetText: () => editor.body }, + }, + }), +})); +const answer: LLMResponse = { + text: 'Summary from the excerpt', + toolCalls: [], + stopReason: 'stop', + assistant: { role: 'assistant', content: 'Summary from the excerpt' }, +}; +const byteSize = (messages: LLMMessage[]) => + messages.reduce( + (sum, message) => + sum + new TextEncoder().encode(JSON.stringify({ role: message.role, content: message.content })).length + 16, + 0, + ); +function scripted(name: string, count = 1) { + const snapshots: LLMMessage[][] = []; + const calls = Array.from({ length: count }, (_, index) => ({ id: `read-${index}`, name, input: {} })); + const provider: LLMProvider = { + name: 'native-cloud', + isReady: () => true, + toolCallingMode: 'native', + chat: async (messages) => { + snapshots.push(structuredClone(messages)); + return snapshots.length === 1 + ? { + text: '', + toolCalls: calls, + stopReason: 'tool_calls', + assistant: { role: 'assistant', content: calls.map((call) => ({ type: 'tool_use' as const, ...call })) }, + } + : answer; + }, + }; + return { provider, snapshots }; +} + +for (const scoped of [false, true]) { + it.each([ + ['ASCII', 'a'.repeat(8000), true], + ['Chinese', '文'.repeat(2200), true], + ['emoji', '😀'.repeat(2600), true], + ['JSON escaping', '"\\\n'.repeat(2400), true], + ['short text', 'a'.repeat(500), false], + ] as const)( + `fits real %s document results before inference two (${scoped ? 'scoped' : 'raw'} tools)`, + async (_label, body, truncated) => { + editor.body = body; + editor.slide = false; + const tools = scoped + ? createDocumentAgentTools({ + target: { context: { kind: 'word' }, label: 'DOCX', selectedText: '', isCurrent: () => true }, + readonly: false, + receipt: () => {}, + review: async () => { + throw new Error('Read must not require review'); + }, + }) + : { get_document_text: getDocumentTextTool }; + const { provider, snapshots } = scripted('get_document_text'); + const result = await runAgent(provider, 'Summarize the current document', { tools }); + expect(result.text).toBe('Summary from the excerpt'); + expect(snapshots).toHaveLength(2); + expect(byteSize(snapshots[1])).toBeLessThanOrEqual(6144); + const results = snapshots[1] + .flatMap((message) => (typeof message.content === 'string' ? [] : message.content)) + .filter((block) => block.type === 'tool_result'); + expect(results).toHaveLength(1); + const output = JSON.parse(results[0].content); + const excerpt = scoped ? output.result : output; + expect(excerpt.truncated).toBe(truncated); + expect(excerpt.text.length).toBeGreaterThan(0); + expect(body.startsWith(excerpt.text)).toBe(true); + expect(excerpt.text).not.toMatch(/[\uD800-\uDBFF]$/u); + if (!truncated) expect(excerpt.text).toBe(body); + if (scoped) expect(output.status).toBe('completed'); + expect(snapshots[1][0].content).toBe('Summarize the current document'); + expect(snapshots[1][1].content).toEqual([ + { type: 'tool_use', id: 'read-0', name: 'get_document_text', input: {} }, + ]); + }, + ); +} + +it('fits two actual read results together with the current request, escaped tool JSON and optional reference data', async () => { + editor.body = '文😀"\\\n'.repeat(2400); + editor.slide = false; + const { provider, snapshots } = scripted('get_document_text', 2); + const question = 'Explain the source: ' + 'q'.repeat(800); + await runAgent(provider, question, { + tools: { get_document_text: getDocumentTextTool }, + maxContextBytes: 2600, + requestContext: JSON.stringify({ scope: 'word', text: 'optional'.repeat(1000) }), + }); + expect(snapshots).toHaveLength(2); + expect(byteSize(snapshots[1])).toBeLessThanOrEqual(2600); + expect(JSON.stringify(snapshots[1][0])).toContain(question); + const results = snapshots[1] + .flatMap((message) => (typeof message.content === 'string' ? [] : message.content)) + .filter((block) => block.type === 'tool_result'); + expect(results.map((block) => block.toolUseId)).toEqual(['read-0', 'read-1']); + for (const block of results) expect(JSON.parse(block.content)).toMatchObject({ truncated: true }); +}); +it('preserves presentation read metadata when fitting UTF-8 excerpts', async () => { + editor.body = '文'.repeat(2600); + editor.slide = true; + const { provider, snapshots } = scripted('get_presentation_text'); + await runAgent(provider, 'Summarize the slides', { tools: { get_presentation_text: getPresentationTextTool } }); + expect(byteSize(snapshots[1])).toBeLessThanOrEqual(6144); + const block = snapshots[1] + .flatMap((message) => (typeof message.content === 'string' ? [] : message.content)) + .find((block) => block.type === 'tool_result'); + expect(JSON.parse(block!.content)).toMatchObject({ truncated: true, slidesRead: 1, totalSlides: 1 }); +}); + +it.each(['read', 'write'] as const)( + 'refits a prior long read when a later %s exchange grows the same turn', + async (next) => { + editor.body = 'a'.repeat(8000); + editor.slide = false; + const snapshots: LLMMessage[][] = []; + const checkpoints: LLMMessage[][] = []; + const writes: string[] = []; + const calls = [ + { id: 'first-read', name: 'get_document_text', input: {} }, + next === 'read' + ? { id: 'next-read', name: 'get_document_text', input: { maxChars: 1000 } } + : { id: 'write', name: 'write', input: { text: 'Verified edit' } }, + ]; + const result = await runAgent( + { + name: 'native-cloud', + isReady: () => true, + chat: async (messages) => { + snapshots.push(structuredClone(messages)); + const call = calls.shift(); + return call + ? { + text: '', + toolCalls: [call], + stopReason: 'tool_calls', + assistant: { role: 'assistant', content: [{ type: 'tool_use', ...call }] }, + } + : answer; + }, + }, + 'Read, then continue the task and confirm the result', + { + tools: { + get_document_text: getDocumentTextTool, + write: { + name: 'write', + description: 'write', + readOnlyHint: false, + inputSchema: {}, + execute: async ({ text }) => { + writes.push(String(text)); + return { verified: true, receipt: 'unchanged protocol' }; + }, + }, + }, + onToolExchange: (messages) => checkpoints.push(structuredClone(messages)), + }, + ); + expect(result.text).toBe('Summary from the excerpt'); + expect(snapshots).toHaveLength(3); + expect(checkpoints).toHaveLength(2); + for (const messages of [...snapshots, ...checkpoints]) expect(byteSize(messages)).toBeLessThanOrEqual(6144); + const results = (messages: LLMMessage[]) => + messages + .flatMap((message) => (typeof message.content === 'string' ? [] : message.content)) + .filter((block) => block.type === 'tool_result'); + const before = JSON.parse(results(snapshots[1])[0].content); + const after = JSON.parse(results(snapshots[2])[0].content); + expect(after.text.length).toBeLessThan(before.text.length); + expect(after).toMatchObject({ truncated: true }); + expect(results(snapshots[2]).map((block) => block.toolUseId)).toEqual([ + 'first-read', + next === 'read' ? 'next-read' : 'write', + ]); + if (next === 'read') expect(JSON.parse(results(snapshots[2])[1].content).text.length).toBeGreaterThan(0); + else { + expect(writes).toEqual(['Verified edit']); + expect(JSON.parse(results(snapshots[2])[1].content)).toEqual({ verified: true, receipt: 'unchanged protocol' }); + } + expect(results(result.messages)).toEqual(results(snapshots[2])); + }, +); + +for (const precedingRead of [false, true]) { + it.each(['input', 'output'] as const)( + `preserves terminal writes with oversized %s and no further inference (${precedingRead ? 'after read' : 'write only'})`, + async (oversized) => { + editor.body = 'a'.repeat(8000); + editor.slide = false; + const writes: string[] = []; + let inferences = 0; + const largeText = 'x'.repeat(8000); + const calls = [ + ...(precedingRead ? [{ id: 'source', name: 'get_document_text', input: {} }] : []), + { id: 'terminal', name: 'write', input: { text: oversized === 'input' ? largeText : 'small text' } }, + ]; + const result = await runAgent( + { + name: 'native-cloud', + isReady: () => true, + chat: async () => { + inferences++; + const call = calls.shift(); + if (!call) throw new Error('Terminal write must not trigger another inference'); + return { + text: '', + toolCalls: [call], + stopReason: 'tool_calls', + assistant: { role: 'assistant', content: [{ type: 'tool_use', ...call }] }, + }; + }, + }, + 'Perform the terminal edit', + { + tools: { + get_document_text: getDocumentTextTool, + write: { + name: 'write', + description: 'write', + readOnlyHint: false, + inputSchema: {}, + concludesTurn: true, + execute: async ({ text }) => { + writes.push(String(text)); + return { written: true, receipt: oversized === 'output' ? largeText : 'Verified' }; + }, + }, + }, + }, + ); + expect(result).toMatchObject({ completedByTool: true, stoppedOnLimit: false, aborted: false }); + expect(writes).toEqual([oversized === 'input' ? largeText : 'small text']); + expect(inferences).toBe(precedingRead ? 2 : 1); + const results = result.messages + .flatMap((message) => (typeof message.content === 'string' ? [] : message.content)) + .filter((block) => block.type === 'tool_result'); + expect(JSON.parse(results.at(-1)!.content)).toEqual({ + written: true, + receipt: oversized === 'output' ? largeText : 'Verified', + }); + }, + ); +} + +it.each(['abort', 'terminal'] as const)( + 'fills pending result shells after %s while refitting an earlier read', + async (mode) => { + editor.body = 'a'.repeat(8000); + editor.slide = false; + const abort = new AbortController(); + let turn = 0; + const checkpoints: LLMMessage[][] = []; + const executed: string[] = []; + const result = await runAgent( + { + name: 'native-cloud', + isReady: () => true, + chat: async () => { + const calls = + turn++ === 0 + ? [{ id: 'read', name: 'get_document_text', input: {} }] + : [ + { id: 'settle', name: 'settle', input: {} }, + { id: 'tail', name: 'tail', input: {} }, + ]; + return { + text: '', + toolCalls: calls, + stopReason: 'tool_calls', + assistant: { role: 'assistant', content: calls.map((call) => ({ type: 'tool_use', ...call })) }, + }; + }, + }, + 'Read and perform the owned step', + { + signal: abort.signal, + onToolExchange: (messages) => checkpoints.push(structuredClone(messages)), + tools: { + get_document_text: getDocumentTextTool, + settle: { + name: 'settle', + description: 'settle', + inputSchema: {}, + readOnlyHint: false, + concludesTurn: mode === 'terminal', + execute: async () => { + executed.push('settle'); + if (mode === 'abort') abort.abort(); + return { verified: true }; + }, + }, + tail: { + name: 'tail', + description: 'tail', + inputSchema: {}, + readOnlyHint: false, + execute: async () => { + executed.push('tail'); + return { verified: true }; + }, + }, + }, + }, + ); + expect(executed).toEqual(['settle']); + expect(turn).toBe(2); + expect(result.aborted).toBe(mode === 'abort'); + if (mode === 'terminal') expect(result.completedByTool).toBe(true); + const checkpoint = checkpoints.at(-1)!; + expect(byteSize(checkpoint)).toBeLessThanOrEqual(6144); + const results = checkpoint + .flatMap((message) => (typeof message.content === 'string' ? [] : message.content)) + .filter((block) => block.type === 'tool_result'); + expect(results.map((block) => block.toolUseId)).toEqual(['read', 'settle', 'tail']); + expect(results.every((block) => block.content.length > 0)).toBe(true); + expect(results.at(-1)).toMatchObject({ + isError: true, + content: mode === 'abort' ? 'Cancelled before execution' : 'Turn concluded before execution', + }); + expect(JSON.parse(results[0].content)).toMatchObject({ truncated: true }); + }, +); diff --git a/test/unit/assistant-focus-guard.test.ts b/test/unit/assistant-focus-guard.test.ts new file mode 100644 index 000000000..1e1446498 --- /dev/null +++ b/test/unit/assistant-focus-guard.test.ts @@ -0,0 +1,378 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + claimAssistantFocus, + installAssistantFocusGuard, + ownsAssistantFocus, +} from '../../lib/onlyoffice/guards/assistant-focus'; + +function fixture() { + document.body.innerHTML = + '
'; + const panel = document.querySelector('.agent-runtime-panel')!; + const input = panel.querySelector('textarea')!; + const external = document.querySelector('.external')!; + const frame = document.querySelector('iframe')!; + const win = frame.contentWindow!; + win.document.body.innerHTML = '
'; + const editor = win.document.querySelector('#area_id')!; + return { panel, input, external, frame, win, editor }; +} + +function pointer(target: Element, type = 'pointerdown') { + target.dispatchEvent(new Event(type, { bubbles: true })); +} + +afterEach(() => { + document.body.innerHTML = ''; + vi.restoreAllMocks(); +}); + +describe('assistant focus ownership across the editor boundary', () => { + it.each(['#area_id', '#editor_sdk'])( + 'blocks background SDK focus on %s after a parent control receives focus', + (selector) => { + const { win, input } = fixture(); + expect(installAssistantFocusGuard(win)).toBe(true); + input.focus(); + win.document.querySelector(selector)!.focus(); + expect(document.activeElement).toBe(input); + }, + ); + + it('retries a parent pointer handoff once when Firefox leaves the editor active', () => { + const { win, input, panel, frame } = fixture(); + let nextFrame: FrameRequestCallback | undefined; + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => { + nextFrame = callback; + return 1; + }); + installAssistantFocusGuard(win); + frame.focus(); + pointer(input); + expect(document.activeElement).toBe(frame); + expect(ownsAssistantFocus(panel)).toBe(true); + expect(nextFrame).toBeDefined(); + nextFrame!(0); + expect(document.activeElement).toBe(input); + }); + + it.each(['editor', 'other-control', 'hidden'])('cancels the pointer retry after %s intent', (intent) => { + const { win, input, panel, frame, external } = fixture(); + let nextFrame: FrameRequestCallback | undefined; + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => { + nextFrame = callback; + return 1; + }); + installAssistantFocusGuard(win); + frame.focus(); + pointer(input); + if (intent === 'editor') pointer(win.document.body); + else if (intent === 'other-control') pointer(external); + else panel.hidden = true; + expect(nextFrame).toBeDefined(); + nextFrame!(0); + expect(document.activeElement).toBe(frame); + }); + + it('allows SDK modal focus and retires assistant ownership after that actual handoff', () => { + const { win, input, panel } = fixture(); + const modal = win.document.createElement('button'); + win.document.body.append(modal); + installAssistantFocusGuard(win); + input.focus(); + modal.focus({ preventScroll: true }); + expect(win.document.activeElement).toBe(modal); + expect(ownsAssistantFocus(panel)).toBe(false); + }); + + it('keeps the claim when a non-document native focus call does not move focus', () => { + const { win, input, panel, editor } = fixture(); + const inert = win.document.createElement('div'); + win.document.body.append(inert); + installAssistantFocusGuard(win); + input.focus(); + inert.focus(); + expect(ownsAssistantFocus(panel)).toBe(true); + editor.focus(); + expect(document.activeElement).toBe(input); + }); + + it('preserves a new assistant claim made reentrantly during native modal focus', () => { + const { win, input, panel, editor } = fixture(); + const modal = win.document.createElement('button'); + win.document.body.append(modal); + installAssistantFocusGuard(win); + input.focus(); + let enteredModal = false; + modal.addEventListener('focus', () => { + enteredModal = true; + claimAssistantFocus(input); + input.focus(); + }); + modal.focus(); + expect(enteredModal).toBe(true); + expect(ownsAssistantFocus(panel)).toBe(true); + editor.focus(); + expect(document.activeElement).toBe(input); + }); + + it.each(['getFocusElement', 'window-editor', 'TextBoxInput', 'HtmlArea'])( + 'uses the current SDK %s reference rather than retaining an old document input', + (source) => { + const { win, input } = fixture(); + const first = win.document.createElement('textarea'); + const second = win.document.createElement('textarea'); + win.document.body.append(first, second); + const api = { + input: first, + getFocusElement() { + return this.input; + }, + }; + const wordControl = { TextBoxInput: first }; + const inputContext = { HtmlArea: first }; + if (source === 'getFocusElement') Object.assign(win, { Asc: { editor: api } }); + if (source === 'window-editor') Object.assign(win, { editor: api }); + if (source === 'TextBoxInput') Object.assign(win, { Asc: { editor: { WordControl: wordControl } } }); + if (source === 'HtmlArea') Object.assign(win, { AscCommon: { g_inputContext: inputContext } }); + installAssistantFocusGuard(win); + input.focus(); + first.focus(); + expect(document.activeElement).toBe(input); + api.input = second; + wordControl.TextBoxInput = second; + inputContext.HtmlArea = second; + first.focus(); + expect(win.document.activeElement).toBe(first); + input.focus(); + second.focus(); + expect(document.activeElement).toBe(input); + }, + ); + + it('ignores malformed SDK references while retaining exact document-node fallbacks', () => { + const { win, input, editor } = fixture(); + Object.assign(win, { + Asc: { editor: { getFocusElement: 'not callable', WordControl: null } }, + AscCommon: { g_inputContext: { HtmlArea: { id: 'area_id' } } }, + }); + const control = win.document.createElement('input'); + win.document.body.append(control); + installAssistantFocusGuard(win); + input.focus(); + control.focus(); + expect(win.document.activeElement).toBe(control); + input.focus(); + editor.focus(); + expect(document.activeElement).toBe(input); + }); + + it('allows native UI focus when optional SDK accessors throw', () => { + const { win, input, editor } = fixture(); + for (const key of ['Asc', 'editor', 'AscCommon']) { + Object.defineProperty(win, key, { + configurable: true, + get() { + throw new Error('SDK unavailable'); + }, + }); + } + const control = win.document.createElement('button'); + win.document.body.append(control); + installAssistantFocusGuard(win); + input.focus(); + expect(() => control.focus()).not.toThrow(); + expect(win.document.activeElement).toBe(control); + input.focus(); + editor.focus(); + expect(document.activeElement).toBe(input); + }); + + it('still recognizes a live input when another optional SDK accessor fails', () => { + const { win, input } = fixture(); + const customInput = win.document.createElement('textarea'); + win.document.body.append(customInput); + Object.defineProperty(win, 'Asc', { + configurable: true, + get() { + throw new Error('Asc unavailable'); + }, + }); + Object.assign(win, { + editor: { + getFocusElement() { + throw new Error('not ready'); + }, + WordControl: { TextBoxInput: customInput }, + }, + }); + installAssistantFocusGuard(win); + input.focus(); + customInput.focus(); + expect(document.activeElement).toBe(input); + }); + + it('does not reserve focus merely because the panel is open', () => { + const { win, editor, panel } = fixture(); + installAssistantFocusGuard(win); + expect(ownsAssistantFocus(panel)).toBe(false); + editor.focus(); + expect(win.document.activeElement).toBe(editor); + }); + + it.each(['pointerdown', 'mousedown'])('lets an explicit editor %s win before vendor handlers', (event) => { + const { win, input, editor, panel } = fixture(); + installAssistantFocusGuard(win); + input.focus(); + editor.addEventListener(event, () => editor.focus()); + pointer(editor, event); + expect(ownsAssistantFocus(panel)).toBe(false); + expect(win.document.activeElement).toBe(editor); + }); + + it('releases focus to a parent control outside the panel', () => { + const { win, input, external, editor, panel } = fixture(); + installAssistantFocusGuard(win); + input.focus(); + external.focus(); + expect(ownsAssistantFocus(panel)).toBe(false); + editor.focus(); + expect(win.document.activeElement).toBe(editor); + }); + + it('releases on an outside pointer before its focus default action', () => { + const { win, input, external, editor, panel } = fixture(); + installAssistantFocusGuard(win); + input.focus(); + pointer(external); + expect(ownsAssistantFocus(panel)).toBe(false); + editor.focus(); + expect(win.document.activeElement).toBe(editor); + }); + + it.each([false, true])('releases an explicit Tab handoff (shift=%s)', (shiftKey) => { + const { win, input, editor, panel } = fixture(); + installAssistantFocusGuard(win); + input.focus(); + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', shiftKey, bubbles: true })); + expect(ownsAssistantFocus(panel)).toBe(false); + editor.focus(); + expect(win.document.activeElement).toBe(editor); + }); + + it('claims a panel pointer before the browser moves focus into it', () => { + const { win, input, external, editor, panel } = fixture(); + installAssistantFocusGuard(win); + external.focus(); + pointer(input); + expect(ownsAssistantFocus(panel)).toBe(true); + editor.focus(); + expect(document.activeElement).toBe(external); + }); + + it('protects the initial host focus from reentrant SDK focus', () => { + const { win, input, editor } = fixture(); + installAssistantFocusGuard(win); + input.addEventListener('focus', () => editor.focus()); + claimAssistantFocus(input); + input.focus(); + expect(document.activeElement).toBe(input); + }); + + it('can claim the panel before an initial focus attempt and loses that claim on editor input', () => { + const { win, panel, editor } = fixture(); + installAssistantFocusGuard(win); + editor.focus(); + claimAssistantFocus(panel); + expect(ownsAssistantFocus(panel)).toBe(true); + pointer(editor); + expect(ownsAssistantFocus(panel)).toBe(false); + }); + + it.each(['closed', 'hidden-view', 'display-none', 'visibility-hidden', 'removed-control', 'removed-panel'])( + 'retires ownership immediately when its control becomes %s', + (change) => { + const { win, input, editor, panel } = fixture(); + installAssistantFocusGuard(win); + input.focus(); + const view = input.parentElement!; + if (change === 'closed') panel.classList.add('agent-panel-hidden'); + if (change === 'hidden-view') view.hidden = true; + if (change === 'display-none') view.style.display = 'none'; + if (change === 'visibility-hidden') view.style.visibility = 'hidden'; + if (change === 'removed-control') input.remove(); + if (change === 'removed-panel') panel.remove(); + editor.focus(); + expect(win.document.activeElement).toBe(editor); + expect(ownsAssistantFocus(panel)).toBe(false); + panel.classList.remove('agent-panel-hidden'); + view.hidden = false; + view.removeAttribute('style'); + expect(ownsAssistantFocus(panel)).toBe(false); + }, + ); + + it('moves ownership to a newly claimed panel without retaining the old panel', () => { + const { win, panel, input, editor } = fixture(); + installAssistantFocusGuard(win); + input.focus(); + const otherPanel = document.createElement('section'); + otherPanel.className = 'agent-runtime-panel'; + const otherInput = document.createElement('textarea'); + otherPanel.append(otherInput); + document.body.append(otherPanel); + claimAssistantFocus(otherInput); + expect(ownsAssistantFocus(panel)).toBe(false); + expect(ownsAssistantFocus(otherPanel)).toBe(true); + panel.remove(); + otherInput.focus(); + editor.focus(); + expect(document.activeElement).toBe(otherInput); + }); + + it('keeps focus ownership scoped to the parent document of each editor', () => { + const { input } = fixture(); + const host = document.createElement('iframe'); + document.body.append(host); + const hostDoc = host.contentDocument!; + const nested = hostDoc.createElement('iframe'); + hostDoc.body.append(nested); + const win = nested.contentWindow!; + win.document.body.innerHTML = '
'; + const editor = win.document.querySelector('textarea')!; + installAssistantFocusGuard(win); + claimAssistantFocus(input); + editor.focus(); + expect(win.document.activeElement).toBe(editor); + }); + + it('preserves native receiver, options and result without wrapping a realm twice', () => { + const { win, editor } = fixture(); + const prototype = (win as Window & typeof globalThis).HTMLElement.prototype; + const native = vi.spyOn(prototype, 'focus'); + installAssistantFocusGuard(win); + const installed = prototype.focus; + expect(installAssistantFocusGuard(win)).toBe(true); + expect(prototype.focus).toBe(installed); + const options = { preventScroll: true }; + expect(editor.focus(options)).toBeUndefined(); + expect(native).toHaveBeenCalledExactlyOnceWith(options); + expect(native.mock.contexts[0]).toBe(editor); + expect(win.document.activeElement).toBe(editor); + }); + + it('leaves non-editor iframe focus untouched and can install once its editor mounts', () => { + const { win, editor, input } = fixture(); + win.document.querySelector('#editor_sdk')!.remove(); + const original = (win as Window & typeof globalThis).HTMLElement.prototype.focus; + expect(installAssistantFocusGuard(win)).toBe(false); + expect((win as Window & typeof globalThis).HTMLElement.prototype.focus).toBe(original); + const marker = win.document.createElement('div'); + marker.id = 'editor_sdk'; + win.document.body.append(marker); + expect(installAssistantFocusGuard(win)).toBe(true); + input.focus(); + editor.focus(); + expect(document.activeElement).toBe(input); + }); +}); diff --git a/test/unit/ci-artifacts.test.ts b/test/unit/ci-artifacts.test.ts new file mode 100644 index 000000000..e3aa00b20 --- /dev/null +++ b/test/unit/ci-artifacts.test.ts @@ -0,0 +1,62 @@ +import { expect, it } from 'vitest'; +import { playwrightArtifacts } from '../../bin/ci-artifacts.mjs'; + +it('keeps normal and serial traces and HTML in separate uploadable directories', () => { + const normal = playwrightArtifacts('default-report', 'default-results', { + E2E_REPORT_DIR: 'reports/normal', + E2E_RESULTS_DIR: 'results/normal', + }); + const serial = playwrightArtifacts('default-report', 'default-results', { + E2E_REPORT_DIR: 'reports/serial', + E2E_RESULTS_DIR: 'results/serial', + }); + expect(normal.outputDir).toBe('results/normal'); + expect(serial.outputDir).toBe('results/serial'); + expect(normal.reporter).toEqual([['list'], ['html', { outputFolder: 'reports/normal', open: 'never' }]]); + expect(serial.reporter).toEqual([['list'], ['html', { outputFolder: 'reports/serial', open: 'never' }]]); + expect(playwrightArtifacts('default-report', 'default-results', {}).outputDir).toBe('default-results'); +}); + +it('retains a failed first pass report and attachment after a successful second Playwright pass', async () => { + const { mkdtemp, readFile, rm, writeFile } = await import('node:fs/promises'); + const { tmpdir } = await import('node:os'); + const { join, resolve } = await import('node:path'); + const { pathToFileURL } = await import('node:url'); + const { createRequire } = await import('node:module'); + const { spawnSync } = await import('node:child_process'); + const require = createRequire(resolve('package.json')); + const root = await mkdtemp(join(tmpdir(), 'ci-artifacts-')); + try { + const playwright = require.resolve('@playwright/test'); + const helper = pathToFileURL(resolve('bin/ci-artifacts.mjs')).href; + await writeFile( + join(root, 'playwright.config.mjs'), + `import {playwrightArtifacts} from ${JSON.stringify(helper)}; export default {testDir: '.', ...playwrightArtifacts('unused-report','unused-results')};`, + ); + await writeFile( + join(root, 'passes.spec.cjs'), + `const {test,expect}=require(${JSON.stringify(playwright)}); const fs=require('node:fs'); test('normal',async({},info)=>{fs.writeFileSync(info.outputPath('evidence.txt'),'first pass evidence');expect(1).toBe(2)});test('serial',()=>{});`, + ); + const run = (pass: string) => + spawnSync( + process.execPath, + [require.resolve('@playwright/test/cli'), 'test', '-c', join(root, 'playwright.config.mjs'), '--grep', pass], + { + env: { + ...process.env, + E2E_REPORT_DIR: join(root, 'reports', pass), + E2E_RESULTS_DIR: join(root, 'results', pass), + }, + encoding: 'utf8', + }, + ); + expect(run('normal').status).toBe(1); + const serial = run('serial'); + expect(serial.status, serial.stdout + serial.stderr).toBe(0); + expect(await readFile(join(root, 'reports/normal/index.html'), 'utf8')).toContain(' { + it('balances measured long suites and gives new specs a deterministic place without duplicates', () => { + const files = ['a.spec.ts', 'b.spec.ts', 'c.spec.ts', 'new.spec.ts']; + const shards = balanceTestFiles(files, { 'a.spec.ts': 90, 'b.spec.ts': 50, 'c.spec.ts': 40 }, 2); + expect(shards).toEqual([ + ['a.spec.ts', 'c.spec.ts'], + ['b.spec.ts', 'new.spec.ts'], + ]); + expect(balanceTestFiles([...files].reverse(), { 'a.spec.ts': 90, 'b.spec.ts': 50, 'c.spec.ts': 40 }, 2)).toEqual( + shards, + ); + expect(shards.flat().sort()).toEqual(files.sort()); + }); + it('selects only its partition and refuses empty or invalid assignments instead of running everything', () => { + const shards = [['a.spec.ts'], ['b.spec.ts']]; + expect(selectShard(shards, '2/2')).toEqual(['b.spec.ts']); + for (const shard of ['0/2', '3/2', '1/3', 'bad']) expect(() => selectShard(shards, shard)).toThrow(); + expect(() => selectShard([[], ['b.spec.ts']], '1/2')).toThrow(); + }); +}); + +it('assigns every current spec once in both CI topologies, including unmeasured specs', async () => { + const { readdirSync } = await import('node:fs'); + const { spawnSync } = await import('node:child_process'); + const { pathToFileURL } = await import('node:url'); + const { resolve } = await import('node:path'); + const moduleUrl = pathToFileURL(resolve('bin/ci-shards.mjs')).href; + const files = readdirSync('test/e2e').filter((file) => file.endsWith('.spec.ts')); + const run = (suite: string, shard: string) => { + const result = spawnSync( + process.execPath, + [ + '--input-type=module', + '-e', + `import {ciTestMatch} from ${JSON.stringify(moduleUrl)}; console.log(JSON.stringify(ciTestMatch(${JSON.stringify(suite)})));`, + ], + { + env: { ...process.env, E2E_SHARD: shard }, + encoding: 'utf8', + }, + ); + expect(result.status, result.stderr).toBe(0); + return JSON.parse(result.stdout); + }; + for (const [suite, count] of [ + ['preview', 3], + ['pages', 5], + ] as const) { + const selected: string[] = []; + for (let index = 1; index <= count; index++) selected.push(...run(suite, `${index}/${count}`).testMatch); + expect(selected.sort()).toEqual(files.map((file) => `**/test/e2e/${file}`).sort()); + } + expect(run('preview', '')).toEqual({}); +}); + +it('keeps nested specs in isolated repository partitions', async () => { + const { mkdtemp, mkdir, copyFile, rm, writeFile } = await import('node:fs/promises'); + const { tmpdir } = await import('node:os'); + const { join } = await import('node:path'); + const { spawnSync } = await import('node:child_process'); + const { pathToFileURL } = await import('node:url'); + const root = await mkdtemp(join(tmpdir(), 'ci-nested-shards-')); + try { + await mkdir(join(root, 'bin')); + await mkdir(join(root, 'test/e2e/nested/deep'), { recursive: true }); + await copyFile('bin/ci-shards.mjs', join(root, 'bin/ci-shards.mjs')); + await writeFile(join(root, 'bin/ci-shard-timings.json'), JSON.stringify({ preview: { 'root.spec.ts': 10 } })); + await writeFile(join(root, 'test/e2e/root.spec.ts'), ''); + await writeFile(join(root, 'test/e2e/nested/deep/new.spec.ts'), ''); + const selected = []; + for (const shard of ['1/2', '2/2']) { + const result = spawnSync( + process.execPath, + [ + '--input-type=module', + '-e', + `import {ciTestMatch} from ${JSON.stringify(pathToFileURL(join(root, 'bin/ci-shards.mjs')).href)}; console.log(JSON.stringify(ciTestMatch('preview').testMatch));`, + ], + { + env: { ...process.env, E2E_SHARD: shard }, + encoding: 'utf8', + }, + ); + expect(result.status, result.stderr).toBe(0); + selected.push(...JSON.parse(result.stdout)); + } + expect(selected.sort()).toEqual(['**/test/e2e/nested/deep/new.spec.ts', '**/test/e2e/root.spec.ts']); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +it('runs root and nested specs with the same basename exactly once through the real Playwright CLI', async () => { + const { mkdtemp, mkdir, copyFile, rm, writeFile } = await import('node:fs/promises'); + const { tmpdir } = await import('node:os'); + const { join, resolve } = await import('node:path'); + const { createRequire } = await import('node:module'); + const { spawnSync } = await import('node:child_process'); + const require = createRequire(resolve('package.json')); + const root = await mkdtemp(join(tmpdir(), 'ci-colliding-shards-')); + try { + await mkdir(join(root, 'bin')); + await mkdir(join(root, 'test/e2e/nested'), { recursive: true }); + await copyFile('bin/ci-shards.mjs', join(root, 'bin/ci-shards.mjs')); + await writeFile(join(root, 'bin/ci-shard-timings.json'), JSON.stringify({ preview: { 'same.spec.ts': 10 } })); + await writeFile( + join(root, 'playwright.config.mjs'), + "import {ciTestMatch} from './bin/ci-shards.mjs'; export default {testDir:'./test/e2e', ...ciTestMatch('preview')};", + ); + for (const [file, title] of [ + ['same.spec.ts', 'root same'], + ['nested/same.spec.ts', 'nested same'], + ]) { + await writeFile( + join(root, 'test/e2e', file), + `const {test}=require(${JSON.stringify(require.resolve('@playwright/test'))});test(${JSON.stringify(title)},()=>{});`, + ); + } + const selected: string[] = []; + const titles = (suite: { specs?: { title: string }[]; suites?: unknown[] }): string[] => [ + ...(suite.specs || []).map((spec) => spec.title), + ...(suite.suites || []).flatMap((child) => titles(child as typeof suite)), + ]; + for (const shard of ['1/2', '2/2']) { + const result = spawnSync( + process.execPath, + [ + require.resolve('@playwright/test/cli'), + 'test', + '-c', + join(root, 'playwright.config.mjs'), + '--list', + '--reporter=json', + ], + { + env: { ...process.env, E2E_SHARD: shard }, + encoding: 'utf8', + }, + ); + expect(result.status, result.stderr + result.stdout).toBe(0); + selected.push(...titles(JSON.parse(result.stdout))); + } + expect(selected.sort()).toEqual(['nested same', 'root same']); + } finally { + await rm(root, { recursive: true, force: true }); + } +}, 20_000); diff --git a/test/unit/document-open.test.ts b/test/unit/document-open.test.ts index a28cb2be6..54d47a2c0 100644 --- a/test/unit/document-open.test.ts +++ b/test/unit/document-open.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -const { convert, start, clear, remember } = vi.hoisted(() => ({ +const { convert, mount, start, clear, remember } = vi.hoisted(() => ({ convert: vi.fn(async (_options: any) => {}), + mount: vi.fn(async (_options: any) => {}), start: vi.fn(), clear: vi.fn(async () => {}), remember: vi.fn(async () => true), @@ -9,13 +10,19 @@ vi.mock('../../lib/converter', () => ({ handleDocumentOperation: convert, loadEd vi.mock('../../lib/history/session', () => ({ startDocumentSession: start, newDocumentId: () => 'new-id' })); vi.mock('../../lib/history/autosave', () => ({ clearDocumentSession: vi.fn() })); vi.mock('../../lib/active-document-source', () => ({ clearActiveSource: clear, rememberActiveSource: remember })); -vi.mock('../../lib/onlyoffice-editor', () => ({ getReadonlyMode: () => false })); +vi.mock('../../lib/onlyoffice-editor', async () => ({ + createEditorInstance: mount, + loadEditorApi: async () => {}, + getReadonlyMode: (await import('../../lib/onlyoffice/readonly')).getReadonlyMode, +})); import { openDocumentFromUrl, openLocalFile, onCreateNew } from '../../lib/document'; import { getDocmentObj } from '@ranuts/shared/store'; +import { getReadonlyMode } from '../../lib/onlyoffice/readonly'; const response = (headers: Record = {}) => new Response('document bytes', { headers }); beforeEach(() => { - convert.mockClear(); + convert.mockReset().mockResolvedValue(undefined); + mount.mockReset().mockResolvedValue(undefined); start.mockClear(); clear.mockClear(); remember.mockClear(); @@ -150,3 +157,169 @@ describe('remote open limits and currency', () => { } }); }); + +// Real document/converter/mount ownership runs here with controlled vendor and +// persistence boundaries. Early metadata publication breaks these regressions. +describe('mounted document metadata', () => { + beforeEach(async () => { + vi.useFakeTimers(); + vi.stubGlobal('alert', vi.fn()); + vi.stubGlobal('DocsAPI', { + DocEditor: class { + constructor( + _container: string, + public config: { document: { title: string } }, + ) {} + destroyEditor() {} + }, + }); + const converter = await vi.importActual('../../lib/converter'); + const editor = await vi.importActual('../../lib/onlyoffice-editor'); + convert.mockImplementation(converter.handleDocumentOperation); + mount.mockImplementation(editor.createEditorInstance); + }); + afterEach(() => { + delete window.editor; + vi.clearAllTimers(); + vi.useRealTimers(); + }); + + async function finishMount(opening: Promise): Promise { + await vi.advanceTimersByTimeAsync(1_000); + await opening; + } + + async function originalDocument() { + const file = new File(['original'], 'Original.txt'); + await finishMount(openLocalFile(file, { readonly: true })); + return { metadata: getDocmentObj(), editor: window.editor }; + } + + it('preserves the original object identity and readonly mode when local bytes cannot be read', async () => { + const original = await originalDocument(); + const file = new File(['replacement'], 'Unreadable.xlsx'); + vi.spyOn(file, 'arrayBuffer').mockRejectedValue(new DOMException('Backing file changed', 'NotReadableError')); + await expect(openLocalFile(file)).rejects.toMatchObject({ name: 'NotReadableError' }); + expect(window.editor).toBe(original.editor); + expect(getDocmentObj()).toBe(original.metadata); + expect(getReadonlyMode()).toBe(true); + }); + + it('keeps the original identity while replacement bytes are pending, then commits the mounted file', async () => { + const original = await originalDocument(); + const file = new File(['replacement'], 'Replacement.txt'); + let finish!: (bytes: ArrayBuffer) => void; + vi.spyOn(file, 'arrayBuffer').mockImplementation( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const opening = openLocalFile(file); + await vi.advanceTimersByTimeAsync(0); + expect(getDocmentObj()).toBe(original.metadata); + expect(window.editor).toBe(original.editor); + expect(getReadonlyMode()).toBe(true); + finish(new Uint8Array([1, 2, 3]).buffer); + await finishMount(opening); + expect(getDocmentObj()).toEqual({ fileName: 'Replacement.txt', file }); + expect(window.editor).not.toBe(original.editor); + expect(getReadonlyMode()).toBe(false); + }); + + it('does not let an older read failure overwrite a newer successful owner', async () => { + await originalDocument(); + const file = new File(['old attempt'], 'Old.xlsx'); + let fail!: (error: Error) => void; + vi.spyOn(file, 'arrayBuffer').mockImplementation( + () => + new Promise((_resolve, reject) => { + fail = reject; + }), + ); + const pending = openLocalFile(file); + const failed = expect(pending).rejects.toMatchObject({ name: 'NotReadableError' }); + await vi.advanceTimersByTimeAsync(0); + await finishMount(openLocalFile(new File(['winner'], 'Winner.txt'))); + const winner = getDocmentObj(); + const editor = window.editor; + fail(new DOMException('Backing file changed', 'NotReadableError')); + await failed; + expect(getDocmentObj()).toBe(winner); + expect(getDocmentObj().fileName).toBe('Winner.txt'); + expect(window.editor).toBe(editor); + }); + + it.each(['local', 'url', 'new'])('publishes %s metadata only after its mount succeeds', async (kind) => { + const original = await originalDocument(); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response())); + const file = new File(['replacement'], 'Local.txt'); + const opening = + kind === 'local' + ? openLocalFile(file) + : kind === 'url' + ? openDocumentFromUrl('https://files.example/Remote.txt') + : onCreateNew('.docx'); + await vi.advanceTimersByTimeAsync(0); + expect(getDocmentObj()).toBe(original.metadata); + await finishMount(opening); + const expectedName = kind === 'local' ? 'Local.txt' : kind === 'url' ? 'Remote.txt' : 'Untitled.docx'; + expect(getDocmentObj().fileName).toBe(expectedName); + const editor = window.editor as unknown as { config: { document: { title: string } } }; + expect(editor.config.document.title).toBe(expectedName); + if (kind === 'new') expect(getDocmentObj().file).toBeUndefined(); + else expect(getDocmentObj().file?.name).toBe(kind === 'local' ? 'Local.txt' : 'Remote.txt'); + }); + + it('exposes the mounted source to listeners when native document readiness arrives', async () => { + await originalDocument(); + let readySource: ReturnType | undefined; + const onReady = () => { + readySource = getDocmentObj(); + }; + window.addEventListener('document:content-ready', onReady); + vi.stubGlobal('DocsAPI', { + DocEditor: class { + constructor(_container: string, config: { events: { onDocumentReady: () => void } }) { + // The native API dispatches readiness from an iframe message task. + setTimeout(() => config.events.onDocumentReady(), 0); + } + destroyEditor() {} + }, + }); + const file = new File(['replacement'], 'Ready.txt'); + try { + await finishMount(openLocalFile(file)); + expect(readySource).toBe(getDocmentObj()); + expect(readySource).toEqual({ fileName: 'Ready.txt', file }); + } finally { + window.removeEventListener('document:content-ready', onReady); + (await import('../../lib/onlyoffice/viewport')).cleanupViewportFollow(); + } + }); + + it('commits a replacement already in teardown when a newer URL download fails', async () => { + await originalDocument(); + const file = new File(['replacement'], 'Mounted.txt'); + const opening = openLocalFile(file); + await vi.advanceTimersByTimeAsync(0); + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('network failed'))); + await expect(openDocumentFromUrl('https://files.example/Failed.txt')).rejects.toThrow('network failed'); + await finishMount(opening); + expect(getDocmentObj()).toEqual({ fileName: 'Mounted.txt', file }); + const editor = window.editor as unknown as { config: { document: { title: string } } }; + expect(editor.config.document.title).toBe('Mounted.txt'); + expect(getReadonlyMode()).toBe(false); + }); + + it.each(['url', 'new'])('preserves the mounted identity after a %s mount fails before replacement', async (kind) => { + const original = await originalDocument(); + mount.mockRejectedValueOnce(new Error('mount refused')); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response())); + const opening = kind === 'url' ? openDocumentFromUrl('https://files.example/Remote.txt') : onCreateNew('.docx'); + await expect(opening).rejects.toThrow('mount refused'); + expect(getDocmentObj()).toBe(original.metadata); + expect(window.editor).toBe(original.editor); + expect(getReadonlyMode()).toBe(true); + }); +}); diff --git a/test/unit/document-task.test.ts b/test/unit/document-task.test.ts index fa0ba7630..5fa666890 100644 --- a/test/unit/document-task.test.ts +++ b/test/unit/document-task.test.ts @@ -327,3 +327,48 @@ it.each([ } }, ); + +it.each(['length', 'max_tokens', 'content_filter'])( + 'never converts a %s schema tool response into an executable native call', + async (stopReason) => { + const execute = vi.fn(async () => ({ changed: true })); + const result = await runAgent( + createSchemaTaskProvider( + { + name: 'local', + isReady: () => true, + chat: vi.fn(), + generateJSON: async () => ({ ...response({ kind: 'tool', tool: 'write', input: {} }), stopReason }), + }, + () => ({ kind: 'word' }), + ), + 'write', + { + tools: { write: { name: 'write', description: 'write', inputSchema: {}, readOnlyHint: false, execute } }, + }, + ); + expect(result.incompleteReason).toBe(stopReason === 'length' ? 'max_tokens' : stopReason); + expect(execute).not.toHaveBeenCalled(); + expect(result.messages).toEqual([{ role: 'user', content: 'write' }]); + }, +); +it('retains only the decoded answer preview after a schema stream reaches its token limit', async () => { + const partial = '{"kind":"answer","text":"Partial decoded answer'; + const result = await runAgent( + createSchemaTaskProvider( + { + name: 'local', + isReady: () => true, + chat: vi.fn(), + generateJSONStream: async (_messages, _schema, delta) => { + delta(partial); + return { ...response(null), text: partial, stopReason: 'max_tokens' }; + }, + }, + () => ({ kind: 'word' }), + ), + 'question', + ); + expect(result).toMatchObject({ incompleteReason: 'max_tokens', text: 'Partial decoded answer' }); + expect(result.messages.at(-1)).toEqual({ role: 'assistant', content: 'Partial decoded answer', interrupted: true }); +}); diff --git a/test/unit/embed-api.test.ts b/test/unit/embed-api.test.ts index 820377be4..36f8d98a5 100644 --- a/test/unit/embed-api.test.ts +++ b/test/unit/embed-api.test.ts @@ -90,6 +90,49 @@ describe('embed-api', () => { delete (window as any).editor; }); + it('preserves the mounted metadata when an embedded file fails before replacement', async () => { + const store = await import('@ranuts/shared/store'); + store.setDocmentObj({ fileName: 'Original.docx', file: new File(['original'], 'Original.docx') }); + const original = store.getDocmentObj(); + window.history.replaceState(null, '', '/editor?embed=1'); + const { initEmbedApi } = await import('../../lib/embed-api'); + initEmbedApi(); + mockHandleDocumentOperation.mockRejectedValueOnce(new DOMException('Unreadable file', 'NotReadableError')); + await dispatchMessage({ + id: 'failed-metadata', + type: 'document:open-file', + payload: { file: new File(['bad'], 'Bad.xlsx') }, + }); + expectMessagePosted(postMessageSpy, 'document:error', 'failed-metadata'); + expect(store.getDocmentObj()).toBe(original); + }); + + it('publishes embedded metadata after a successful mount', async () => { + const store = await import('@ranuts/shared/store'); + store.setDocmentObj({ fileName: 'Original.docx', file: new File(['original'], 'Original.docx') }); + const original = store.getDocmentObj(); + window.history.replaceState(null, '', '/editor?embed=1'); + const { initEmbedApi } = await import('../../lib/embed-api'); + initEmbedApi(); + let finish!: () => void; + mockHandleDocumentOperation.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const file = new File(['replacement'], 'Replacement.xlsx'); + await dispatchMessage({ id: 'pending-metadata', type: 'document:open-file', payload: { file } }); + try { + expect(store.getDocmentObj()).toBe(original); + } finally { + finish(); + await new Promise((resolve) => setTimeout(resolve, 0)); + } + expectMessagePosted(postMessageSpy, 'document:opened', 'pending-metadata'); + expect(store.getDocmentObj()).toEqual({ fileName: 'Replacement.xlsx', file }); + }); + describe('detectEmbedMode via URL params', () => { it.each([ ['?embed=', true], diff --git a/test/unit/onlyoffice-editor.test.ts b/test/unit/onlyoffice-editor.test.ts index df0798db7..16eeb7365 100644 --- a/test/unit/onlyoffice-editor.test.ts +++ b/test/unit/onlyoffice-editor.test.ts @@ -198,6 +198,9 @@ describe('onlyoffice-editor', () => { }, asc_CDownloadOptions: FakeDownloadOptions, }; + win.AscCommon = { + x2t: { convertFromBin: async () => ({ binary: new Uint8Array([1]) }), downloadFile: vi.fn() }, + }; return { iframe, requestedFileTypes }; } diff --git a/test/unit/open-ownership.test.ts b/test/unit/open-ownership.test.ts index 9e61a0661..5a579aaf1 100644 --- a/test/unit/open-ownership.test.ts +++ b/test/unit/open-ownership.test.ts @@ -20,6 +20,7 @@ vi.mock('../../lib/onlyoffice-editor', () => ({ getReadonlyMode: () => false })) import { openLocalFile, openDocumentFromUrl } from '../../lib/document'; import { events } from '../../lib/events'; import { initEmbedApi } from '../../lib/embed-api'; +import { getDocmentObj, setDocmentObj } from '@ranuts/shared/store'; const chunk = { chunkIndex: 0, data: '', lastModified: 0, name: 'desktop.docx', size: 1, totalChunks: 1, type: '' }; beforeEach(() => { @@ -98,3 +99,39 @@ describe('open ownership across entry points', () => { await failed; }); }); + +describe('desktop mounted metadata', () => { + it('preserves the mounted metadata when desktop conversion fails', async () => { + setDocmentObj({ fileName: 'Original.docx', file: new File(['original'], 'Original.docx') }); + const original = getDocmentObj(); + convert.mockRejectedValueOnce(new DOMException('Unreadable file', 'NotReadableError')); + vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + await events.RENDER_OFFICE(chunk); + expect(getDocmentObj()).toBe(original); + } finally { + vi.restoreAllMocks(); + } + }); + it('publishes desktop metadata after conversion mounts the document', async () => { + setDocmentObj({ fileName: 'Original.docx', file: new File(['original'], 'Original.docx') }); + const original = getDocmentObj(); + let finish!: () => void; + convert.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const opening = events.RENDER_OFFICE(chunk); + await vi.waitFor(() => expect(convert).toHaveBeenCalled()); + try { + expect(getDocmentObj()).toBe(original); + } finally { + finish(); + await opening; + } + expect(getDocmentObj().fileName).toBe('desktop.docx'); + expect(getDocmentObj().file?.name).toBe('desktop.docx'); + }); +}); diff --git a/test/unit/playwright-install.test.ts b/test/unit/playwright-install.test.ts new file mode 100644 index 000000000..11fd6a2df --- /dev/null +++ b/test/unit/playwright-install.test.ts @@ -0,0 +1,88 @@ +import { spawnSync } from 'node:child_process'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const script = resolve(__dirname, '../../.github/scripts/install-playwright.sh'); + +// Execute the real shell branches without touching apt or installing browsers. +// Only privileged/external effects are replaced; the script owns selection, +// retries, exit status and arguments. +function install(browsers: string[], options: { cached?: boolean; force?: boolean; fail?: boolean } = {}) { + return spawnSync( + 'bash', + [ + '-c', + ` + sudo() { if [ "$1" = tee ]; then cat >/dev/null; fi; return 0; } + timeout() { printf 'INSTALL %s\\n' "$*"; return "$INSTALL_RESULT"; } + sleep() { return 0; } + task_install_script=$1 + shift + source "$task_install_script" + `, + 'playwright-install-test', + script, + ...browsers, + ], + { + encoding: 'utf8', + env: { + ...process.env, + BROWSERS_CACHED: options.cached === false ? 'false' : 'true', + PLAYWRIGHT_INSTALL_DEPS: options.force ? 'true' : '', + PLAYWRIGHT_INSTALL_ATTEMPTS: '2', + PLAYWRIGHT_INSTALL_TIMEOUT: '7', + INSTALL_RESULT: options.fail ? '1' : '0', + }, + }, + ); +} + +describe('Playwright browser cache and runner system dependencies', () => { + it.each([{ browsers: [] }, { browsers: ['chromium'] }])( + 'keeps the cached Chromium fast path for $browsers', + ({ browsers }) => { + const result = install(browsers); + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).not.toContain('INSTALL '); + }, + ); + + it.each([['webkit'], ['firefox'], ['chromium', 'webkit'], ['webkit', 'firefox']])( + 'installs required system dependencies for cached %j', + (...browsers) => { + const result = install(browsers); + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain( + `INSTALL --kill-after=30s 7s pnpm exec playwright install-deps ${browsers.join(' ')}`, + ); + expect(result.stdout).not.toContain('install --with-deps'); + }, + ); + + it.each([['webkit'], ['firefox'], ['chromium', 'webkit']])( + 'fails after bounded retries when cached %j dependencies cannot be installed', + (...browsers) => { + const result = install(browsers, { fail: true }); + expect(result.status, result.stderr).toBe(1); + expect(result.stdout.match(/INSTALL /g)).toHaveLength(2); + expect(result.stdout).toContain('::error::playwright install-deps'); + expect(result.stdout).not.toContain('continuing on the cached browsers'); + }, + ); + + it('preserves the explicit advisory Chromium dependency refresh', () => { + const result = install(['chromium'], { force: true, fail: true }); + expect(result.status, result.stderr).toBe(0); + expect(result.stdout.match(/INSTALL /g)).toHaveLength(2); + expect(result.stdout).toContain('install-deps chromium'); + expect(result.stdout).toContain('continuing on the cached browsers'); + }); + + it('still requires the full installation on a cold cache', () => { + const result = install(['webkit'], { cached: false, fail: true }); + expect(result.status, result.stderr).toBe(1); + expect(result.stdout.match(/INSTALL /g)).toHaveLength(2); + expect(result.stdout).toContain('install --with-deps webkit'); + }); +}); diff --git a/test/unit/save-request-ownership.test.ts b/test/unit/save-request-ownership.test.ts new file mode 100644 index 000000000..9ef4866cf --- /dev/null +++ b/test/unit/save-request-ownership.test.ts @@ -0,0 +1,189 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { setDocmentObj } from '@ranuts/shared/store'; +import { cancelDocumentOpen } from '../../lib/document-open'; +import { installServerlessSaveSemantics } from '../../lib/onlyoffice/guards/serverless-save'; +import { markDocumentContentReady, resetOpenState } from '../../lib/onlyoffice/open-state'; +import { setReadonlyState } from '../../lib/onlyoffice/readonly'; +import { requestSaveDocument, setDiskWriter, SAVE_REQUEST_TIMEOUT_MS } from '../../lib/onlyoffice/save-stream'; + +let frame: HTMLIFrameElement; +let conversions: Array<{ finish: (value: { binary: Uint8Array }) => void }>; +let writes: File[]; +let api: { asc_Save: (auto?: boolean) => void; asc_DownloadAs: () => void; isLongAction: () => boolean }; + +beforeEach(() => { + vi.useFakeTimers(); + resetOpenState(); + setReadonlyState(false); + history.replaceState(null, '', '/editor'); + setDocmentObj({ fileName: 'A.docx', file: new File(['original A'], 'A.docx') }); + frame = document.createElement('iframe'); + frame.name = 'frameEditor'; + document.body.append(frame); + conversions = []; + writes = []; + const win = frame.contentWindow!; + const converter = { + convertFromBin: () => new Promise<{ binary: Uint8Array }>((finish) => conversions.push({ finish })), + downloadFile: (binary: Uint8Array, fileName = 'A.docx') => { + window.dispatchEvent( + new MessageEvent('message', { + source: win, + origin: location.origin, + data: { type: 'onlyoffice-file-stream', fileType: fileName.split('.').pop(), buffer: binary.buffer }, + }), + ); + }, + }; + api = { + asc_Save: () => {}, + isLongAction: () => false, + asc_DownloadAs: () => { + if (!api.isLongAction()) void converter.convertFromBin().then(({ binary }) => converter.downloadFile(binary)); + }, + }; + Object.assign(win, { + AscCommon: { x2t: converter }, + Asc: { + editor: { ...api, documentFormatSave: 65, isLoadFullApi: true, isDocumentLoadComplete: true }, + asc_CDownloadOptions: class { + fileType: number; + constructor(fileType: number) { + this.fileType = fileType; + } + }, + }, + }); + api = (win as any).Asc.editor; + window.editor = { downloadAs: vi.fn() } as any; + installServerlessSaveSemantics(win); + setDiskWriter(async (file) => { + writes.push(file); + return 'written'; + }); +}); + +afterEach(async () => { + await vi.runAllTimersAsync(); + setDiskWriter(null); + frame.remove(); + window.editor = undefined; + vi.useRealTimers(); +}); + +it.each(['replace', 'close'])('rejects a readiness-waiting save immediately on %s', async (operation) => { + let failure: Error | undefined; + void requestSaveDocument('DOCX').catch((error) => { + failure = error; + }); + if (operation === 'replace') { + setDocmentObj({ fileName: 'B.docx' }); + window.editor = { downloadAs: vi.fn() } as any; + resetOpenState(); + } else cancelDocumentOpen(); + await vi.advanceTimersByTimeAsync(0); + expect(failure?.name).toBe('AbortError'); + markDocumentContentReady(); + await vi.advanceTimersByTimeAsync(150_000); + expect(conversions).toHaveLength(0); +}); + +it('never returns a replacement original from the opt-in timeout fallback', async () => { + const result = requestSaveDocument('DOCX', { returnOriginalOnTimeout: true }).catch((error: Error) => error); + setDocmentObj({ fileName: 'B.docx', file: new File(['B'], 'B.docx') }); + window.editor = { downloadAs: vi.fn() } as any; + await vi.advanceTimersByTimeAsync(8000); + expect(await result).toMatchObject({ name: 'AbortError' }); +}); + +it('keeps the same-document opt-in original fallback available', async () => { + const result = requestSaveDocument('DOCX', { returnOriginalOnTimeout: true }); + await vi.advanceTimersByTimeAsync(8000); + expect(await result).toMatchObject({ name: 'A.docx', size: 10 }); +}); + +it.each(['snapshot first', 'manual first'])('preserves the manual disk save when %s finishes', async (order) => { + markDocumentContentReady(); + const snapshot = requestSaveDocument('DOCX'); + await vi.advanceTimersByTimeAsync(0); + api.asc_Save(false); + expect(conversions).toHaveLength(2); + for (const index of order === 'snapshot first' ? [0, 1] : [1, 0]) { + conversions[index].finish({ binary: new Uint8Array(index === 0 ? [1] : [2, 2]) }); + await vi.advanceTimersByTimeAsync(0); + } + expect(await snapshot).toMatchObject({ name: 'A.docx', size: 1 }); + expect(writes.map((file) => [file.name, file.size])).toEqual([['A.docx', 2]]); +}); + +it('does not let an expired conversion satisfy a later programmatic request', async () => { + markDocumentContentReady(); + const expired = requestSaveDocument('DOCX').catch((error: Error) => error); + await vi.advanceTimersByTimeAsync(SAVE_REQUEST_TIMEOUT_MS); + expect(await expired).toMatchObject({ message: expect.stringContaining('timed out') }); + const next = requestSaveDocument('DOCX'); + await vi.advanceTimersByTimeAsync(0); + conversions[0].finish({ binary: new Uint8Array([1]) }); + conversions[1].finish({ binary: new Uint8Array([2, 2]) }); + await vi.advanceTimersByTimeAsync(0); + expect(await next).toMatchObject({ name: 'A.docx', size: 2 }); + expect(writes).toHaveLength(0); +}); + +it('waits for stream tracking before starting a cold editor export', async () => { + const common = (frame.contentWindow as any).AscCommon; + const converter = common.x2t; + delete common.x2t; + markDocumentContentReady(); + const snapshot = requestSaveDocument('DOCX').catch((error: Error) => error); + await vi.advanceTimersByTimeAsync(0); + expect(conversions).toHaveLength(0); + common.x2t = converter; + await vi.advanceTimersByTimeAsync(500); + expect(conversions).toHaveLength(1); + conversions[0].finish({ binary: new Uint8Array([1]) }); + await vi.advanceTimersByTimeAsync(0); + expect(await snapshot).toMatchObject({ name: 'A.docx', size: 1 }); + expect(writes).toHaveLength(0); +}); + +it('keeps a PDF request attached to native CheckPrint resumption while manual saves remain independent', async () => { + const source = readFileSync('public/sdkjs/word/sdk-all.js', 'utf8'); + const body = /this\.CheckPrint=(function[\s\S]*?);this\.ToRenderer2=/.exec(source)![1]; + const checkPrint = new Function('Asc', `return (${body});`)({ c_oAscAsyncActionType: { BlockInteraction: 1 } }); + const native = api as any; + const drawing: any = { + m_oWordControl: { m_oLogicDocument: {}, ReaderModeCurrent: 0, m_oApi: native }, + m_bIsDocumentCalculating: true, + }; + native.sync_StartAction = vi.fn(); + native.sync_EndAction = vi.fn(); + const converter = (frame.contentWindow as any).AscCommon.x2t; + native.downloadAs = (action: number, options: { fileType: number }) => { + if (options.fileType === 513 && checkPrint.call(drawing, [action, options])) return; + void converter.convertFromBin().then(({ binary }: { binary: Uint8Array }) => { + converter.downloadFile(binary, options.fileType === 513 ? 'A.pdf' : 'A.docx'); + }); + }; + native.asc_DownloadAs = (options: { fileType: number }) => native.downloadAs(0, options); + markDocumentContentReady(); + let outcome: File | Error | undefined; + void requestSaveDocument('PDF').then( + (file) => (outcome = file), + (error: Error) => (outcome = error), + ); + await vi.advanceTimersByTimeAsync(0); + expect(conversions).toHaveLength(0); + expect(drawing.m_arPrintingWaitEndRecalculate).toHaveLength(2); + native.asc_Save(false); + conversions[0].finish({ binary: new Uint8Array([2, 2]) }); + await vi.advanceTimersByTimeAsync(0); + drawing.m_bIsDocumentCalculating = false; + // This is the SDK's OnEndRecalculate resumption with its preserved options. + native.downloadAs.apply(native, drawing.m_arPrintingWaitEndRecalculate); + conversions[1].finish({ binary: new Uint8Array([1]) }); + await vi.advanceTimersByTimeAsync(0); + expect(outcome).toMatchObject({ name: 'A.pdf', size: 1 }); + expect(writes.map((file) => [file.name, file.size])).toEqual([['A.docx', 2]]); +}); diff --git a/test/unit/sw-boot-precache.test.ts b/test/unit/sw-boot-precache.test.ts index 6eada8884..add410f87 100644 --- a/test/unit/sw-boot-precache.test.ts +++ b/test/unit/sw-boot-precache.test.ts @@ -31,6 +31,7 @@ it.each([ URL, Promise, Date, + Response, self: { location: { origin }, addEventListener: (name: string, listener: (event: any) => void) => listeners.set(name, listener), @@ -48,7 +49,12 @@ it.each([ respondWith: (value: Promise) => (response = value), waitUntil: () => {}, }); - expect(await response).toBe(expected ? asset : undefined); + const result = await response; + if (expected) expect(result).toBe(asset); + else { + expect(result).toBeInstanceOf(Response); + expect(result).toMatchObject({ type: 'error', status: 0 }); + } }); it('caches vendor bootstrap script and style tags without evaluating inline scripts', async () => { diff --git a/test/unit/sw-isolation-response.test.ts b/test/unit/sw-isolation-response.test.ts index 8559f269f..0c9089742 100644 --- a/test/unit/sw-isolation-response.test.ts +++ b/test/unit/sw-isolation-response.test.ts @@ -5,7 +5,7 @@ import { expect, it } from 'vitest'; async function cachedResponse( isolated: boolean, path: string, - original: Response, + original: Response | undefined, shell?: Response | Error, retryShell?: Response, ) { @@ -188,3 +188,21 @@ it.each(['?new=docx&locale=en', '?file=private%2Fdocument.docx&embed=1'])( expect(response.headers.get('location')).toBe('https://editor.example/editor' + query); }, ); + +it.each([false, true])( + 'returns a network error for an uncached offline hashed asset: isolation %s', + async (isolated) => { + const response = await cachedResponse(isolated, '/assets/optional-runtime-missing.js', undefined); + expect(response).toBeInstanceOf(Response); + expect(response.type).toBe('error'); + expect(response.status).toBe(0); + }, +); + +it('keeps an existing hashed asset available when offline revalidation fails', async () => { + const original = new Response('cached runtime', { headers: { 'content-type': 'application/javascript' } }); + const response = await cachedResponse(false, '/assets/prepared-runtime.js', original); + expect(response).toBe(original); + expect(response.status).toBe(200); + expect(await response.text()).toBe('cached runtime'); +}); diff --git a/test/unit/workflow-contract.test.ts b/test/unit/workflow-contract.test.ts index f4e64cbeb..e35b07e02 100644 --- a/test/unit/workflow-contract.test.ts +++ b/test/unit/workflow-contract.test.ts @@ -1,5 +1,7 @@ import { readdirSync, readFileSync } from 'node:fs'; import { resolve } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { createRequire } from 'node:module'; import { describe, expect, it } from 'vitest'; /** @@ -42,6 +44,35 @@ function parseJobs(src: string): Array<{ name: string; body: string }> { return jobs; } +/** Read the step-level command/environment contract without depending on a flag's placement. */ +function parseSteps(body: string): Array<{ name: string; run?: string; env: Record }> { + const steps: Array<{ name: string; run?: string; env: Record }> = []; + let inEnv = false; + for (const line of body.split('\n')) { + const header = line.match(/^ {6}- name: (.+)$/); + if (header) { + steps.push({ name: header[1], env: {} }); + inEnv = false; + continue; + } + const step = steps.at(-1); + if (!step) continue; + if (/^ {8}env:/.test(line)) { + inEnv = true; + continue; + } + const variable = inEnv && line.match(/^ {10}([A-Z_][A-Z0-9_]*): (.+)$/); + if (variable) { + step.env[variable[1]] = variable[2]; + continue; + } + if (/^ {8}\S/.test(line)) inEnv = false; + const run = line.match(/^ {8}run: (.+)$/); + if (run) step.run = run[1]; + } + return steps; +} + it('finds every workflow (a bad glob would make the checks below vacuous)', () => { expect(workflows.length).toBeGreaterThanOrEqual(8); expect(workflows.every(({ src }) => parseJobs(src).length > 0)).toBe(true); @@ -89,30 +120,15 @@ describe('the Playwright install script', () => { expect(script).toMatch(/for attempt in \$\(seq 1 "\$attempts"\)/); }); - it('does not call apt at all once the browsers came from the cache', () => { - // Read off a healthy run: every library Chromium needs already reports - // "is already the newest version" on the hosted image, and the only thing - // install-deps installs is 21 MB of fonts that nothing here renders - // through (the editor draws with its own vendored catalog). That call was - // a network dependency on Ubuntu's mirrors in the hot path of every job, - // buying nothing and stalling regularly. - const cachedBranch = script.match(/BROWSERS_CACHED:-\}" = "true" \]; then\n([\s\S]*?)\nelse\n/); - expect(cachedBranch).not.toBeNull(); - const body = cachedBranch![1]; - // The branch has to leave before it can reach a playwright invocation. - expect(body).toMatch(/^\s*exit 0$/m); - expect(body.search(/^\s*exit 0$/m)).toBeLessThan(body.search(/command=\(/)); - }); - it('keeps the apt config write off the path that never reaches apt', () => { // Ordering, not decoration: a skip that still sudo-writes an apt config is // a skip that can still fail for apt's reasons. expect(script.indexOf('skipping the apt font install')).toBeLessThan(script.indexOf('99-ci-timeouts')); }); - it('lets a stalled apt through once the browsers came from the cache', () => { - // A cache hit means the binaries are already there and apt could only be - // adding system libraries the runner image already ships. Treating its + it('keeps the explicit Chromium dependency refresh advisory', () => { + // The optional Chromium refresh only adds libraries already present on + // the hosted image; required WebKit/Firefox dependencies are tested separately. Treating its // stall as fatal turned a bad Ubuntu mirror into a red pull request -- // twice on one run, and sharding tripled the number of jobs exposed. expect(script).toMatch(/deps_are_advisory=true/); @@ -189,21 +205,24 @@ describe('.github/workflows/ci.yml', () => { expect(ci).toMatch(/cancel-in-progress: \$\{\{ github\.event_name == 'pull_request' \}\}/); }); - const sharded = ciJobs.filter(({ body }) => body.includes('--shard=')); + const sharded = ciJobs.filter(({ body }) => parseSteps(body).some((step) => step.env.E2E_SHARD)); it('shards every E2E suite (the three of them are the whole cost of the run)', () => { expect(sharded.map(({ name }) => name)).toEqual(['e2e-shard', 'e2e-pages-shard', 'e2e-docker-shard']); }); it.each(sharded)('job $name splits into exactly as many shards as it declares', ({ body }) => { - // A matrix of three against `--shard=N/4` silently drops a quarter of the - // suite and still reports green -- the failure mode is invisible, so the - // two numbers are pinned to each other here. + // Missing an index or using the wrong denominator silently drops tests. + // The selector belongs to the executing step, so it reaches the CLI/config. const matrix = body.match(/shard: \[([^\]]+)\]/); - const denominator = body.match(/--shard=\$\{\{ matrix\.shard \}\}\/(\d+)/); + const execution = parseSteps(body).filter((step) => step.env.E2E_SHARD); expect(matrix).not.toBeNull(); + expect(execution).toHaveLength(1); + const denominator = execution[0].env.E2E_SHARD.match(/^\$\{\{\s*matrix\.shard\s*\}\}\/(\d+)$/); expect(denominator).not.toBeNull(); - expect(matrix![1].split(',').length).toBe(Number(denominator![1])); + const count = Number(denominator![1]); + expect(matrix![1].split(',').map(Number)).toEqual(Array.from({ length: count }, (_, index) => index + 1)); + expect(execution[0].run).toBeTruthy(); }); it.each([ @@ -231,7 +250,11 @@ describe('.github/workflows/ci.yml', () => { // while a whole class of assertion stops running. const e2e = ciJobs.find(({ name }) => name === 'e2e-shard'); expect(e2e).toBeDefined(); - expect(e2e!.body).toMatch(/--shard=\$\{\{ matrix\.shard \}\}\/3 --grep-invert @serial/); + const parallel = parseSteps(e2e!.body).find((step) => step.env.E2E_SHARD)!; + const command = parallel.run!.split(/\s+/); + expect(command.slice(0, 4)).toEqual(['pnpm', 'exec', 'playwright', 'test']); + expect(command[command.indexOf('--grep-invert') + 1]).toBe('@serial'); + expect(command).not.toContain('--grep'); const serial = ciJobs.find(({ name }) => name === 'e2e-serial'); expect(serial?.body).toMatch(/playwright test --grep @serial --workers=1/); expect(e2e!.body).not.toMatch(/playwright test --grep @serial/); @@ -258,8 +281,13 @@ describe('.github/workflows/ci.yml', () => { // floor: every shard then runs the whole suite, three times over, and the // run stays green while the sharding does nothing at all. Measured, not // assumed -- see docs/explorations/2026-08-19-ci-e2e-sharding.md. - expect(ci).toMatch(/run: sh \.\/bin\/test-e2e-docker\.sh --shard=/); - expect(ci).not.toMatch(/pnpm run test:e2e:docker.*--shard/); + const docker = ciJobs.find(({ name }) => name === 'e2e-docker-shard')!; + const execution = parseSteps(docker.body).find((step) => step.env.E2E_SHARD)!; + expect(execution.run!.split(/\s+/)).toEqual(['sh', './bin/test-e2e-docker.sh']); + expect(execution.env.E2E_SHARD).toMatch(/^\$\{\{\s*matrix\.shard\s*\}\}\/3$/); + expect(parseSteps(docker.body).some((step) => /^pnpm run test:e2e:docker(?:\s|$)/.test(step.run || ''))).toBe( + false, + ); }); }); @@ -357,8 +385,85 @@ describe('bin/test-e2e-docker.sh', () => { */ describe('.github/workflows/nightly-corpus.yml', () => { const src = readFileSync(resolve(ROOT, '.github/workflows/nightly-corpus.yml'), 'utf8'); + 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', () => { + 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); + const step = parseSteps(summary.body).find(({ env }) => env.RESULT)!; + expect(step?.env.RESULT).toBe('${{ needs.browsers-shard.result }}'); + expect(step?.run).toBeTruthy(); + 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); + } + }); + + it('selects exactly the matrix engine through the real Playwright CLI for both passes', () => { + 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')); + for (const browser of ['webkit', 'firefox']) { + for (const [index, command] of commands.entries()) { + const tokens = command + .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' }, + ); + 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]); + } + } + }, 20_000); + it('skips a device-emulating spec by project, not by browser name', () => { // `devices['Pixel 5']` carries `defaultBrowserType: 'chromium'`, which wins // over the project's browser -- so under the webkit/firefox projects @@ -389,7 +494,8 @@ 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"/); - expect(src).toMatch(/-c playwright\.browsers\.config\.ts --grep @serial --workers=1/); + const commands = parseSteps(jobs.find(({ name }) => name === 'browsers-shard')?.body || '').map(({ run }) => run); + expect(commands.some((run) => run?.includes('--grep @serial') && run.includes('--workers=1'))).toBe(true); }); it('runs each half of the night only when it has something to say', () => { @@ -398,7 +504,7 @@ describe('.github/workflows/nightly-corpus.yml', () => { // one cheap gate job so a quiet night still leaves a run explaining itself. const jobs = parseJobs(src); expect(jobs.map(({ name }) => name)).toContain('gate'); - for (const name of ['browsers', 'budgets']) { + for (const name of ['browsers-shard', 'budgets']) { const job = jobs.find((candidate) => candidate.name === name); expect(job, name).toBeDefined(); expect(job!.body, name).toMatch(/^ {4}if: needs\.gate\.outputs\.browsers == 'true'$/m);