diff --git a/.github/scripts/check_companion_release_tag_absent.py b/.github/scripts/check_companion_release_tag_absent.py index dbd77d039..626b0dcba 100644 --- a/.github/scripts/check_companion_release_tag_absent.py +++ b/.github/scripts/check_companion_release_tag_absent.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python3 - from __future__ import annotations import argparse diff --git a/.github/scripts/cleanup_companion_releases.py b/.github/scripts/cleanup_companion_releases.py index 9b7cdc97f..1bd9838ec 100644 --- a/.github/scripts/cleanup_companion_releases.py +++ b/.github/scripts/cleanup_companion_releases.py @@ -1,4 +1,3 @@ -#!/usr/bin/env python3 """Delete obsolete Companion GitHub Releases after validating every feed marker.""" from __future__ import annotations diff --git a/.github/scripts/cleanup_test_container_versions.py b/.github/scripts/cleanup_test_container_versions.py index bfc3fb48a..249ffeb91 100644 --- a/.github/scripts/cleanup_test_container_versions.py +++ b/.github/scripts/cleanup_test_container_versions.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python3 - from __future__ import annotations import argparse diff --git a/.github/scripts/companion_build_required.py b/.github/scripts/companion_build_required.py index e77ad234a..2a3b27e13 100644 --- a/.github/scripts/companion_build_required.py +++ b/.github/scripts/companion_build_required.py @@ -1,4 +1,3 @@ -#!/usr/bin/env python3 """Decide whether a pull request needs the expensive companion package build.""" from __future__ import annotations diff --git a/.github/scripts/manage_companion_artifact_manifest.py b/.github/scripts/manage_companion_artifact_manifest.py index f227dc175..60c9206d9 100644 --- a/.github/scripts/manage_companion_artifact_manifest.py +++ b/.github/scripts/manage_companion_artifact_manifest.py @@ -1,4 +1,3 @@ -#!/usr/bin/env python3 """Create and verify immutable Companion package artifact manifests.""" from __future__ import annotations diff --git a/.github/scripts/prepare_release_candidate.py b/.github/scripts/prepare_release_candidate.py index 05139a821..51a9ccd6a 100644 --- a/.github/scripts/prepare_release_candidate.py +++ b/.github/scripts/prepare_release_candidate.py @@ -1,4 +1,3 @@ -#!/usr/bin/env python3 """Reserve or resolve the immutable source commit for a publishable build.""" from __future__ import annotations diff --git a/.github/scripts/promote_companion_release.py b/.github/scripts/promote_companion_release.py index 8fe8c060d..1b13aa440 100644 --- a/.github/scripts/promote_companion_release.py +++ b/.github/scripts/promote_companion_release.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python3 - from __future__ import annotations import argparse @@ -176,6 +174,8 @@ def request_asset_bytes(asset: dict, token: str | None = None) -> bytes: with urllib.request.urlopen(request) as response: return response.read() except urllib.error.URLError as error: + if isinstance(error, urllib.error.HTTPError): + error.close() if ( not is_retryable_asset_download_error(error) or attempt == ASSET_DOWNLOAD_MAX_ATTEMPTS - 1 diff --git a/.github/scripts/resolve_companion_release_state.py b/.github/scripts/resolve_companion_release_state.py index 627443ebb..187e61fb8 100644 --- a/.github/scripts/resolve_companion_release_state.py +++ b/.github/scripts/resolve_companion_release_state.py @@ -1,4 +1,3 @@ -#!/usr/bin/env python3 """Resolve the immutable publication state for a Companion release tag.""" from __future__ import annotations diff --git a/.github/scripts/reuse_release_ci_tests.py b/.github/scripts/reuse_release_ci_tests.py index 7447daa06..a173a9981 100644 --- a/.github/scripts/reuse_release_ci_tests.py +++ b/.github/scripts/reuse_release_ci_tests.py @@ -1,4 +1,3 @@ -#!/usr/bin/env python3 """Reuse successful source checks from CI on the exact release commit.""" from __future__ import annotations @@ -15,6 +14,9 @@ CI_WORKFLOW = "test.yml" API_TIMEOUT_SECONDS = 10 JOBS_PER_PAGE = 100 +FRONTEND_REQUIRED_STEPS = frozenset( + {"Run browser correctness contracts", "Verify frontend type correctness"} +) def github_get(url: str, token: str) -> dict[str, Any]: @@ -63,10 +65,7 @@ def reusable_jobs(api_url: str, repository: str, sha: str, token: str) -> set[st successful: set[str] = set() page = 1 while True: - jobs_url = ( - f"{api_url}/repos/{repository}/actions/runs/{run_id}/jobs?" - f"filter=latest&per_page={JOBS_PER_PAGE}&page={page}" - ) + jobs_url = f"{api_url}/repos/{repository}/actions/runs/{run_id}/jobs?filter=latest&per_page={JOBS_PER_PAGE}&page={page}" jobs = github_get(jobs_url, token).get("jobs") if not isinstance(jobs, list): raise ValueError("CI workflow jobs response is missing jobs") @@ -74,6 +73,19 @@ def reusable_jobs(api_url: str, repository: str, sha: str, token: str) -> set[st if not isinstance(job, dict): raise ValueError("CI workflow job is not an object") if job.get("name") in COMPONENTS and job.get("conclusion") == "success": + if job["name"] == "frontend": + steps = job.get("steps", []) + if not isinstance(steps, list): + raise ValueError("Frontend CI job steps are not a list") + successful_steps = { + step.get("name") + for step in steps + if isinstance(step, dict) + and step.get("conclusion") == "success" + and isinstance(step.get("name"), str) + } + if not FRONTEND_REQUIRED_STEPS <= successful_steps: + continue successful.add(job["name"]) if len(jobs) < JOBS_PER_PAGE: return successful diff --git a/.github/scripts/should_promote_beta_tag.py b/.github/scripts/should_promote_beta_tag.py index bb3afdb70..17efa1be5 100644 --- a/.github/scripts/should_promote_beta_tag.py +++ b/.github/scripts/should_promote_beta_tag.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python3 - from __future__ import annotations import argparse diff --git a/.github/scripts/validate_release_scope.py b/.github/scripts/validate_release_scope.py index 7d715d596..b240af84c 100644 --- a/.github/scripts/validate_release_scope.py +++ b/.github/scripts/validate_release_scope.py @@ -1,4 +1,3 @@ -#!/usr/bin/env python3 """Validate a public Sambee release's component-promotion scope.""" from __future__ import annotations diff --git a/.github/workflows/devcontainer-validate.yml b/.github/workflows/devcontainer-validate.yml index bf2a4d0d2..9d1cc7bf0 100644 --- a/.github/workflows/devcontainer-validate.yml +++ b/.github/workflows/devcontainer-validate.yml @@ -33,7 +33,7 @@ permissions: jobs: prepare: - runs-on: ubuntu-24.04 + runs-on: ubuntu-latest outputs: os_packages_refresh_key: ${{ steps.metadata.outputs.os_packages_refresh_key }} steps: @@ -44,7 +44,7 @@ jobs: validate-devcontainer: needs: prepare - runs-on: ubuntu-24.04 + runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/.github/workflows/docker-image-preview-publish.yml b/.github/workflows/docker-image-preview-publish.yml index b68394690..c724d5638 100644 --- a/.github/workflows/docker-image-preview-publish.yml +++ b/.github/workflows/docker-image-preview-publish.yml @@ -222,14 +222,36 @@ jobs: if: steps.source-ci.outputs.frontend != 'true' run: cd frontend && npm ci --prefer-offline - - name: Run frontend type checks + - name: Verify frontend type correctness if: steps.source-ci.outputs.frontend != 'true' - run: cd frontend && npx tsc --noEmit + id: frontend-types + run: cd frontend && npm run typecheck - name: Run frontend tests if: steps.source-ci.outputs.frontend != 'true' run: cd frontend && npm run test:ci + - name: Build production frontend for browser checks + if: steps.source-ci.outputs.frontend != 'true' + run: cd frontend && npm run build + + - name: Install browser dependencies + if: steps.source-ci.outputs.frontend != 'true' + run: cd frontend && npm run test:e2e:install -- --with-deps + + - name: Run browser correctness contracts + if: steps.source-ci.outputs.frontend != 'true' + run: cd frontend && npm run test:browser:ci + + - name: Upload browser correctness reports + if: ${{ !cancelled() && steps.source-ci.outputs.frontend != 'true' }} + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: release-browser-correctness + path: frontend/test-results/ci/ + retention-days: 7 + if-no-files-found: warn + build-and-validate-platforms: needs: [prepare, validate-tests] if: ${{ needs.prepare.outputs.publication_state == 'build' }} @@ -243,10 +265,10 @@ jobs: include: - docker_platform: linux/amd64 platform_id: amd64 - runner_label: ubuntu-24.04 + runner_label: ubuntu-latest - docker_platform: linux/arm64 platform_id: arm64 - runner_label: ubuntu-24.04-arm + runner_label: ubuntu-26.04-arm steps: - name: Checkout source code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -564,10 +586,10 @@ jobs: include: - docker_platform: linux/amd64 platform_id: amd64 - runner_label: ubuntu-24.04 + runner_label: ubuntu-latest - docker_platform: linux/arm64 platform_id: arm64 - runner_label: ubuntu-24.04-arm + runner_label: ubuntu-26.04-arm steps: - name: Checkout source code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/.github/workflows/docker-image-publish.yml b/.github/workflows/docker-image-publish.yml index a9a67beab..406c1db42 100644 --- a/.github/workflows/docker-image-publish.yml +++ b/.github/workflows/docker-image-publish.yml @@ -23,7 +23,7 @@ permissions: jobs: prepare: - runs-on: ubuntu-24.04 + runs-on: ubuntu-latest outputs: checkout_ref: ${{ steps.metadata.outputs.checkout_ref }} release_type: ${{ steps.metadata.outputs.release_type }} diff --git a/.github/workflows/docker-image-security-scan.yml b/.github/workflows/docker-image-security-scan.yml index 58c4304a3..5d52ab485 100644 --- a/.github/workflows/docker-image-security-scan.yml +++ b/.github/workflows/docker-image-security-scan.yml @@ -17,7 +17,7 @@ permissions: jobs: prepare: - runs-on: ubuntu-24.04 + runs-on: ubuntu-latest outputs: created: ${{ steps.metadata.outputs.created }} os_packages_refresh_key: ${{ steps.metadata.outputs.os_packages_refresh_key }} @@ -39,10 +39,10 @@ jobs: include: - docker_platform: linux/amd64 platform_id: amd64 - runner_label: ubuntu-24.04 + runner_label: ubuntu-latest - docker_platform: linux/arm64 platform_id: arm64 - runner_label: ubuntu-24.04-arm + runner_label: ubuntu-26.04-arm steps: - name: Checkout main uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/.github/workflows/docker-image-validate.yml b/.github/workflows/docker-image-validate.yml index 9525c460e..f3b90a719 100644 --- a/.github/workflows/docker-image-validate.yml +++ b/.github/workflows/docker-image-validate.yml @@ -81,7 +81,7 @@ permissions: jobs: prepare: - runs-on: ubuntu-24.04 + runs-on: ubuntu-latest outputs: created: ${{ steps.metadata.outputs.created }} os_packages_refresh_key: ${{ steps.metadata.outputs.os_packages_refresh_key }} @@ -103,10 +103,10 @@ jobs: include: - docker_platform: linux/amd64 platform_id: amd64 - runner_label: ubuntu-24.04 + runner_label: ubuntu-latest - docker_platform: linux/arm64 platform_id: arm64 - runner_label: ubuntu-24.04-arm + runner_label: ubuntu-26.04-arm steps: - name: Checkout code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 5e7b16960..9f4eb00b3 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -10,6 +10,10 @@ on: branches: [ main ] workflow_dispatch: +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: # ───────────────────────────────────────────────────────────────────── # Detect which components changed @@ -42,6 +46,8 @@ jobs: } backend_extras = { "Dockerfile", + ".github/workflows/lint.yml", + "scripts/test", "scripts/verify-backend-lockfiles.py", "scripts/verify-python-runtime-image.py", ".github/actions/setup-runtime-python/action.yml", @@ -117,7 +123,7 @@ jobs: else: raise SystemExit(f"Unsupported event for change detection: {event_name}") - backend = any(name.startswith("backend/") or name in backend_extras for name in filenames) + backend = any(name.startswith("backend/") or name.endswith(".py") or name in backend_extras for name in filenames) frontend = any(name.startswith("frontend/") or name in frontend_extras for name in filenames) companion = any(name.startswith("companion/") or name in companion_extras for name in filenames) @@ -152,6 +158,9 @@ jobs: - name: Ruff check run: cd backend && ruff check app tests/test_verify_backend_lockfiles_script.py ../scripts/verify-backend-lockfiles.py + - name: Ruff executable-script check + run: python -m ruff check --select EXE . .github + - name: Ruff format check run: cd backend && ruff format --check app tests/test_verify_backend_lockfiles_script.py ../scripts/verify-backend-lockfiles.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4195e9b57..ce60c01b3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,6 +10,10 @@ on: branches: [ main ] workflow_dispatch: +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: # ────────────────────────────────────────────── # Detect which components changed @@ -76,7 +80,7 @@ jobs: backend: needs: changes if: needs.changes.outputs.backend == 'true' || github.event_name == 'workflow_dispatch' - runs-on: ubuntu-24.04 + runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -148,6 +152,7 @@ jobs: needs: changes if: needs.changes.outputs.frontend == 'true' || github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest + timeout-minutes: 20 steps: - name: Checkout code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -176,12 +181,31 @@ jobs: - name: Install dependencies run: cd frontend && npm ci --prefer-offline - - name: TypeScript type check - run: cd frontend && npx tsc --noEmit + - name: Verify frontend type correctness + id: frontend-types + run: cd frontend && npm run typecheck - name: Run tests run: cd frontend && npm run test:ci + - name: Build production frontend + run: cd frontend && npm run build + + - name: Install browser dependencies + run: cd frontend && npm run test:e2e:install -- --with-deps + + - name: Run browser correctness contracts + run: cd frontend && npm run test:browser:ci + + - name: Upload browser correctness reports + if: ${{ !cancelled() }} + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: frontend-browser-correctness + path: frontend/test-results/ci/ + retention-days: 7 + if-no-files-found: warn + # ────────────────────────────────────────────── # Companion: Node.js + Rust + Tauri tests # ────────────────────────────────────────────── @@ -241,17 +265,12 @@ jobs: needs: [changes, backend, frontend, companion, website-docs] runs-on: ubuntu-latest steps: + - name: Checkout gate script + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Check results + env: + NEEDS_JSON: ${{ toJSON(needs) }} + EVENT_NAME: ${{ github.event_name }} run: | - if [[ "${{ needs.changes.result }}" != "success" ]]; then - echo "Change detection failed or was cancelled: ${{ needs.changes.result }}" - exit 1 - fi - results="${{ needs.backend.result }} ${{ needs.frontend.result }} ${{ needs.companion.result }} ${{ needs.website-docs.result }}" - for r in $results; do - if [[ "$r" != "success" && "$r" != "skipped" ]]; then - echo "Job failed or was cancelled: $r" - exit 1 - fi - done - echo "All tests passed or were skipped." + python3 scripts/check_ci_test_gate.py diff --git a/.vscode/settings.json b/.vscode/settings.json index 4e7a4da06..30020f020 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -107,116 +107,7 @@ "js/ts.tsdk.path": "frontend/node_modules/typescript/lib", "js/ts.tsdk.promptToUseWorkspaceVersion": true, "js/ts.suggestionActions.enabled": false, - // GitHub Copilot Auto-Approve Commands - // Notes: - // - Regexes apply to subcommands individually, e.g.: sub-command-A && sub-command-B - // - If matchCommandLine is true, the match is applied to the entire command line - // - This should only be used with start and end anchors, e.g. /^git status$/ - // - Since AI often varies the exact command line, matchCommandLine is not useful. - "chat.tools.terminal.autoApprove": { - // Local inspection - "basename": true, - "cat": true, - "cd": true, - "cut": true, - "date": true, - "df": true, - "dirname": true, - "du": true, - "echo": true, - "env": true, - "exit": true, - "file": true, - "grep": true, - "head": true, - "jq": true, - "ls": true, - "lsof": true, - "pgrep": true, - "printenv": true, - "ps": true, - "pwd": true, - "sort": true, - "stat": true, - "tail": true, - "tree": true, - "type": true, - "uniq": true, - "uptime": true, - "wc": true, - "which": true, - "who": true, - // Read-only Git inspection - "/^git(?:\\s+--no-pager)?\\s+(?:status|diff|log|show|branch|blame|rev-parse|ls-files|ls-remote|ls-tree|describe|tag|remote)\\b/": true, - // HTTP GET/HEAD with common non-mutating curl options. - // POST, PUT, DELETE, forms, uploads, output files, and custom - // request methods do not match and therefore require approval. - "/^curl\\s+(?:(?:-[fsSLI]+|--(?:fail|fail-with-body|silent|show-error|location|head|compressed))\\s+)*(?:['\\\"]?https?:\\/\\/[^\\s'\\\"]+['\\\"]?)(?:\\s+['\\\"]?https?:\\/\\/[^\\s'\\\"]+['\\\"]?)*$/": true, - // wget may inspect a URL or write the response only to stdout. - "/^wget\\s+(?:-qO-|--spider)\\s+['\\\"]?https?:\\/\\/[^\\s'\\\"]+['\\\"]?$/": true, - // DNS and reachability queries - "/^(dig|host|nslookup|ping)\\b/": true, - // Read-only package registry queries - "/^cargo\\s+search\\b/": true, - "/^pip3?\\s+index\\s+versions\\b/": true, - // Python tests and static analysis - "mypy": true, - "pytest": true, - // Virtualenv Python - "/^((\\.(\\.)?\\/|\\/workspace\\/)?backend\\/|\\.\\/)?\\.venv\\/bin\\/python\\s+-c\\s+\\'import\\s+/": true, - "/^((\\.(\\.)?\\/|\\/workspace\\/)?backend\\/|\\.\\/)?\\.venv\\/bin\\/python\\s+-m\\s+(compileall|json.tool|mypy|py_compile|pytest|ruff)\\b/": true, - "ruff": true, - "/^python3 -m (json.tool|unittest)\\b/": true, - // Frontend tests and static analysis - "npm test": true, - "npx vitest": true, - "npx tsc": true, - "npx biome": true, - "/^((\\.(\\.)?\\/|\\/workspace\\/)?node_modules\\/|\\.\\/)?\\.bin\\/biome\\s+(check|format)\\b/": true, - "/^((\\.(\\.)?\\/|\\/workspace\\/)?node_modules\\/|\\.\\/)?\\.bin\\/tsc\\b/": true, - // Frontend validation and builds - "/^npm(\\s+--prefix\\s+\\/workspace\\/frontend)?\\s+run\\s+(?:build|check|docs\\:validate|format|info|lint|redirects\\:generate|search|test(\\:.+)?|typecheck|view)\\b/": true, - // Rust validation and read-only inspection - "rustfmt": true, - "/^cargo\\s+(?:test|check|clippy|fmt|metadata|tree|doc)\\b/": true, - // Known repository validation scripts - "/^(?:\\.(\\.)?\\/)?scripts\\/(?:lint|test|format)(?:\\s|$)/": true, - "/^(?:\\.(\\.)?\\/)?scripts\\/(?:test-archive-topology-conformance)(?:\\s|$)/": true, - "/^python3\\s+scripts\\/(?:generate-managed-redirects|update-docs-derived-artifacts|verify-archive-single-worker)\\.py(?:\\s|$)/": true, - "/^python3\\s+(?:website\\/)?scripts\\/(?:docs-report|docs-editor|validate-docs-content)\\.py(?:\\s|$)/": true, - // Always require approval for destructive or privileged tools - "chmod": false, - "chown": false, - "dd": false, - "del": false, - "docker": false, - "kill": false, - "mkfs": false, - "mount": false, - "podman": false, - "rm": false, - "rmdir": false, - "sudo": false, - "su": false, - "umount": false, - // Require approval for remote access and transfer tools - "ftp": false, - "rsync": false, - "scp": false, - "sftp": false, - "ssh": false, - // Require approval for package installation or arbitrary execution - "apt": false, - "apt-get": false, - "corepack": false, - "pnpm": false, - "yarn": false, - // Require approval for Git writes and working-tree changes - "/^git(?:\\s+--no-pager)?\\s+(?:add|apply|bisect|checkout|cherry-pick|clean|clone|commit|fetch|merge|mv|pull|push|rebase|reset|restore|revert|rm|stash|switch|worktree)\\b/": false, - // Require approval for dependency and publishing operations - "/^npm\\s+(?:ci|install|uninstall|remove|update|publish|exec)\\b/": false, - "/^cargo\\s+(?:add|clean|fix|install|login|logout|new|init|package|publish|remove|run|update|watch)\\b/": false - }, + // Color customization "workbench.colorCustomizations": { "activityBar.activeBackground": "#F4C430", "activityBar.background": "#F4C430", @@ -243,5 +134,7 @@ "statusBar.debuggingBackground": "#f4c430", "statusBar.debuggingForeground": "#15202b" }, - "peacock.remoteColor": "#F4C430" + // Copilot and Chat Settings: allow automatic approval and terminal file writes (this is a devcontainer) + "chat.permissions.default": "autoApprove", + "chat.tools.terminal.blockDetectedFileWrites": "never" } diff --git a/.vscode/tasks.json b/.vscode/tasks.json index e6f35d5a6..5d339f7cd 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -138,7 +138,7 @@ { "label": "Frontend: Type Check + Lint", "type": "shell", - "command": "npx tsc --noEmit 2>&1 && echo '✓ TypeScript type check passed' && npm run lint 2>&1 && echo '✓ Biome lint passed' && echo '' && echo '══════════════════════════════════' && echo ' All frontend checks passed ✓' && echo '══════════════════════════════════'", + "command": "npm run typecheck 2>&1 && echo '✓ TypeScript type check passed' && npm run lint 2>&1 && echo '✓ Biome lint passed' && echo '' && echo '══════════════════════════════════' && echo ' All frontend checks passed ✓' && echo '══════════════════════════════════'", "options": { "cwd": "${workspaceFolder}/frontend" }, diff --git a/TODO.md b/TODO.md index 96a25b56b..fc5814e63 100644 --- a/TODO.md +++ b/TODO.md @@ -1,5 +1,14 @@ # TODO +## Bugs + +- Search in Markdown editor: + - Search terms aren't added to the search history + +## To improve soon + +- Vite production-build warning: one or more generated JavaScript chunks exceed 500 kB before gzip compression. + ## Misc. commands - Add a command to open the current file list location in a new browser tab (which keyboard shortcut to assign? Ctrl+(Shift)+Tab is needed by the browser) diff --git a/VERSION b/VERSION index 82bd22f9c..3d45b5c65 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.0.13 +2.0.14 diff --git a/backend/app/api/themes.py b/backend/app/api/themes.py index ab313d04d..0dec198f3 100644 --- a/backend/app/api/themes.py +++ b/backend/app/api/themes.py @@ -29,6 +29,7 @@ "text": ("primary", "secondary"), "action": ("selected",), "components": { + "syntax": ("keyword", "string", "number", "comment", "type", "function", "operator", "punctuation"), "link": ("main", "hover"), "search": ("otherMatch", "currentMatch"), "pdfViewer": ("viewerBackground", "toolbarBackground", "toolbarText"), @@ -71,6 +72,8 @@ def _validate_colors(value: Any, shape: Any, path: str = "") -> None: if not isinstance(value, dict): raise ValueError(f"{path} must contain all color roles") for key, child_shape in shape.items(): + if path == "components" and key == "syntax" and key not in value: + continue if key not in value: raise ValueError(f"Missing color role: {path}.{key}".lstrip(".")) _validate_colors(value[key], child_shape, f"{path}.{key}".lstrip(".")) diff --git a/backend/app/api/viewer.py b/backend/app/api/viewer.py index 5d763cc03..ddcb5374c 100644 --- a/backend/app/api/viewer.py +++ b/backend/app/api/viewer.py @@ -243,7 +243,7 @@ async def stream_archive_member( inspection_projection = presentation.project_member(inspection_member) if inspection_projection.delivery == "preview_unavailable": raise HTTPException( - status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, detail="Archive member exceeds the inline preview size limit" + status_code=status.HTTP_413_CONTENT_TOO_LARGE, detail="Archive member exceeds the inline preview size limit" ) member_name = inspection_member.path.rsplit("/", 1)[-1] @@ -528,7 +528,7 @@ async def create_normalized_pdf_response_for_source( max_source_size = get_integer_setting_value(SystemSettingKey.PDF_NORMALIZER_MAX_SOURCE_SIZE_BYTES) if metadata_revision.size is not None and metadata_revision.size > max_source_size: raise HTTPException( - status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, + status_code=status.HTTP_413_CONTENT_TOO_LARGE, detail="This PDF exceeds the configured compatibility-processing size limit.", ) @@ -585,7 +585,7 @@ async def create_normalized_pdf_response_for_source( pdf_bytes, revision = await read_source() if len(pdf_bytes) > max_source_size: raise HTTPException( - status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, + status_code=status.HTTP_413_CONTENT_TOO_LARGE, detail="This PDF exceeds the configured compatibility-processing size limit.", ) @@ -783,7 +783,7 @@ async def create_converted_image_response( file_size_mb = exc.file_size / (1024 * 1024) max_file_size_mb = exc.max_file_size / (1024 * 1024) raise HTTPException( - status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, + status_code=status.HTTP_413_CONTENT_TOO_LARGE, detail={ "code": "image_preview_too_large", "message": f"This image is too large to preview ({file_size_mb:.0f} MB; maximum {max_file_size_mb:.0f} MB).", diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 010dc1130..12e0784f9 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -9,7 +9,6 @@ addopts = [ "--strict-config", "--tb=short", "-q", - "--disable-warnings", ] log_cli = false log_level = "ERROR" @@ -22,8 +21,7 @@ markers = [ "security: Security vulnerability tests", ] filterwarnings = [ - # Ignore resource warnings from test cleanup (handled in fixtures) - "ignore::ResourceWarning", + "error", # Starlette 1.6.0's TestClient still references AnyIO's deprecated alias. "ignore:The anyio\\.abc\\.BlockingPortal alias is deprecated, use anyio\\.from_thread\\.BlockingPortal instead\\.:DeprecationWarning:starlette\\.testclient", ] @@ -55,7 +53,7 @@ indent-style = "space" [tool.ruff.lint] # Only enable essential rules (no extended plugins) -select = ["E4", "E7", "E9", "F", "I"] +select = ["E4", "E7", "E9", "EXE", "F", "I"] ignore = [ "E501", # line too long handled by formatter/tests ] diff --git a/backend/tests/test_archive_extraction.py b/backend/tests/test_archive_extraction.py index 46371bde0..f383c7a72 100644 --- a/backend/tests/test_archive_extraction.py +++ b/backend/tests/test_archive_extraction.py @@ -851,7 +851,8 @@ async def test_live_source_selects_the_last_duplicate_record() -> None: output = io.BytesIO() with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED) as archive: archive.writestr("duplicate.txt", "first") - archive.writestr("duplicate.txt", "last") + with pytest.warns(UserWarning, match=r"^Duplicate name: 'duplicate\.txt'$"): + archive.writestr("duplicate.txt", "last") backend = MemoryExtractionBackend(output.getvalue()) source_session = LiveSourceSession(ZipReader(backend.reader, len(backend.archive))) diff --git a/backend/tests/test_archive_zip_reader.py b/backend/tests/test_archive_zip_reader.py index 4d46ac81a..623ca187b 100644 --- a/backend/tests/test_archive_zip_reader.py +++ b/backend/tests/test_archive_zip_reader.py @@ -75,7 +75,8 @@ def _duplicate_name_zip_bytes() -> bytes: buffer = io.BytesIO() with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_STORED) as archive: archive.writestr("duplicate.txt", b"first") - archive.writestr("duplicate.txt", b"second entry") + with pytest.warns(UserWarning, match=r"^Duplicate name: 'duplicate\.txt'$"): + archive.writestr("duplicate.txt", b"second entry") return buffer.getvalue() diff --git a/backend/tests/test_ci_change_detector.py b/backend/tests/test_ci_change_detector.py index ebca1e2a3..7fe6fb09d 100644 --- a/backend/tests/test_ci_change_detector.py +++ b/backend/tests/test_ci_change_detector.py @@ -1,15 +1,19 @@ """Regression tests for CI component selection.""" +import ast import json +import re import sys from pathlib import Path from typing import Any import pytest +import yaml sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "scripts")) import ci_change_detector +from check_ci_test_gate import COMPONENT_JOBS, gate_errors from ci_change_detector import ARCHIVE_TOPOLOGY_PATHS, FULL_TREE_DIFF_BASE, classify_paths @@ -51,6 +55,94 @@ def test_non_topology_component_changes_remain_selective() -> None: } +@pytest.mark.parametrize("path", sorted(ci_change_detector.FRONTEND_EXTRAS)) +def test_frontend_ci_infrastructure_runs_frontend_checks(path: str) -> None: + assert classify_paths([path])["frontend"] + + +def test_workflows_use_latest_x64_and_supported_arm_ubuntu_labels() -> None: + workflows = Path(__file__).resolve().parents[2] / ".github/workflows" + allowed = {"ubuntu-latest", "ubuntu-26.04-arm"} + for workflow in workflows.glob("*.yml"): + source = workflow.read_text() + assert isinstance(yaml.safe_load(source)["jobs"], dict), workflow.name + labels = set(re.findall(r"ubuntu-[\w.-]+", source)) + assert labels <= allowed, f"{workflow.name}: unexpected Ubuntu labels {labels - allowed}" + + +@pytest.mark.parametrize( + ("path", "requires_lint"), + [ + ("backend/app/main.py", True), + (".github/scripts/promote_companion_release.py", True), + ("website/scripts/validate-docs-content.py", True), + ("scripts/ci_change_detector.py", True), + (".github/workflows/lint.yml", True), + ("scripts/test", True), + ("frontend/src/App.tsx", False), + ], +) +def test_python_and_lint_infrastructure_changes_schedule_backend_lint(path: str, requires_lint: bool) -> None: + workflow_path = Path(__file__).resolve().parents[2] / ".github/workflows/lint.yml" + workflow = yaml.safe_load(workflow_path.read_text()) + detector = ast.parse(workflow["jobs"]["changes"]["steps"][0]["run"].split("\n", 1)[1].rsplit("\nPY", 1)[0]) + selection = ast.Module( + body=[ + statement + for statement in detector.body + if isinstance(statement, ast.Assign) + and any(isinstance(target, ast.Name) and target.id in {"backend_extras", "backend"} for target in statement.targets) + ], + type_ignores=[], + ) + namespace: dict[str, Any] = {"filenames": [path]} + exec(compile(selection, str(workflow_path), "exec"), namespace) + assert namespace["backend"] is requires_lint + + +def test_executable_script_lint_is_required_in_ci_and_local_validation() -> None: + workspace = Path(__file__).resolve().parents[2] + workflow = yaml.safe_load((workspace / ".github/workflows/lint.yml").read_text()) + steps = workflow["jobs"]["lint-backend"]["steps"] + executable_check = next(step for step in steps if step.get("name") == "Ruff executable-script check") + assert executable_check["run"] == "python -m ruff check --select EXE . .github" + assert not executable_check.get("continue-on-error", False) + local_runner = (workspace / "scripts/test").read_text() + assert 'run_check "Ruff executable-script checks passed" "Ruff executable-script checks failed"' in local_runner + assert '"$BACKEND_PYTHON" -m ruff check --select EXE "$REPO_ROOT" "$REPO_ROOT/.github"' in local_runner + + +def test_gate_requires_selected_components_and_allows_intentional_skips() -> None: + needs = {"changes": {"result": "success", "outputs": {component: "false" for component in COMPONENT_JOBS}}} + needs.update({job: {"result": "skipped"} for job in COMPONENT_JOBS.values()}) + assert gate_errors(needs, "pull_request") == [] + needs["changes"]["outputs"]["frontend"] = "true" + assert gate_errors(needs, "pull_request") == ["Required frontend job did not succeed: skipped"] + needs["frontend"]["result"] = "success" + assert gate_errors(needs, "pull_request") == [] + assert gate_errors(needs, "workflow_dispatch") + + +@pytest.mark.parametrize("name", ["test.yml", "lint.yml"]) +def test_only_superseded_pr_checks_are_cancelled(name: str) -> None: + source = Path(__file__).resolve().parents[2] / ".github/workflows" / name + concurrency = yaml.safe_load(source.read_text())["concurrency"] + assert "github.event.pull_request.number || github.run_id" in concurrency["group"] + assert concurrency["cancel-in-progress"] == "${{ github.event_name == 'pull_request' }}" + + +@pytest.mark.parametrize("result", ["failure", "cancelled", "skipped", None]) +def test_gate_rejects_failed_change_detection(result: str | None) -> None: + assert gate_errors({"changes": {"result": result}}, "push") + + +def test_gate_rejects_missing_outputs_and_unselected_failures() -> None: + assert gate_errors({"changes": {"result": "success"}}, "push") + needs = {"changes": {"result": "success", "outputs": {component: "false" for component in COMPONENT_JOBS}}} + needs.update({job: {"result": "cancelled"} for job in COMPONENT_JOBS.values()}) + assert len(gate_errors(needs, "push")) == len(COMPONENT_JOBS) + + @pytest.mark.parametrize( "path", [ diff --git a/backend/tests/test_native_markdown_pdf_check.py b/backend/tests/test_native_markdown_pdf_check.py new file mode 100644 index 000000000..b8febeafa --- /dev/null +++ b/backend/tests/test_native_markdown_pdf_check.py @@ -0,0 +1,116 @@ +import base64 +import json +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path +from types import SimpleNamespace + +import pytest + +SCRIPT = Path(__file__).parents[2] / "scripts/check_native_markdown_pdf.py" +SPEC = spec_from_file_location("check_native_markdown_pdf", SCRIPT) +assert SPEC and SPEC.loader +MODULE = module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) + +DOCUMENT = "Document start\nconst native = 42;\nDocument tail" + + +def make_report() -> dict: + results = [] + for fixture in ("multiline", "long-line", "nested"): + for width in (1543, 390): + evidence = {"fixture": fixture, "width": width, "engine": "chromium", "renderedText": DOCUMENT} + results.append( + { + "attachments": [ + {"name": "native-constraints.json", "body": base64.b64encode(json.dumps(evidence).encode()).decode()}, + {"name": "native.pdf", "contentType": "application/pdf", "body": base64.b64encode(b"PDF fixture").decode()}, + ] + } + ) + return {"stats": {"expected": 12, "unexpected": 0, "skipped": 0, "flaky": 0}, "suites": results} + + +@pytest.mark.parametrize("missing", [False, True]) +def test_checks_pdf_document_content(monkeypatch, missing: bool) -> None: + text = "Document tail" if missing else DOCUMENT + monkeypatch.setattr(MODULE, "PdfReader", lambda stream: SimpleNamespace(pages=[SimpleNamespace(extract_text=lambda: text)])) + results = MODULE.inspect_report(make_report()) + assert len(results) == 6 + assert all(result["status"] == ("fail" if missing else "pass") for result in results) + assert all(result["missing_lines"] == (2 if missing else 0) for result in results) + + +@pytest.mark.parametrize("failure", [None, "missing", "duplicate", "unexpected", "skipped", "flaky"]) +def test_cli_requires_complete_passing_evidence(tmp_path: Path, monkeypatch, failure: str | None) -> None: + monkeypatch.setattr(MODULE, "PdfReader", lambda stream: SimpleNamespace(pages=[SimpleNamespace(extract_text=lambda: DOCUMENT)])) + report = make_report() + if failure == "missing": + report["suites"].pop() + elif failure == "duplicate": + report["suites"].append(report["suites"][0]) + elif failure is not None: + report["stats"][failure] = 1 + path = tmp_path / "report.json" + path.write_text(json.dumps(report)) + monkeypatch.setattr("sys.argv", [str(SCRIPT), str(path)]) + assert MODULE.main() == (0 if failure is None else 1) + + +def test_cli_rejects_invalid_pdf(tmp_path: Path, monkeypatch, capsys) -> None: + path = tmp_path / "report.json" + path.write_text(json.dumps(make_report())) + monkeypatch.setattr("sys.argv", [str(SCRIPT), str(path)]) + assert MODULE.main() == 1 + assert "Regenerate the Playwright JSON report" in capsys.readouterr().err + + +def test_requires_one_pdf_per_chromium_case() -> None: + report = make_report() + report["suites"][0]["attachments"].pop() + with pytest.raises(ValueError, match="exactly one PDF"): + MODULE.inspect_report(report) + + +def test_rejects_duplicate_contract_attachments() -> None: + report = make_report() + attachments = report["suites"][0]["attachments"] + attachments.append(attachments[0]) + with pytest.raises(ValueError, match="exactly one contract"): + MODULE.inspect_report(report) + + +def test_cli_rejects_missing_contract(tmp_path: Path, monkeypatch) -> None: + monkeypatch.setattr(MODULE, "PdfReader", lambda stream: SimpleNamespace(pages=[SimpleNamespace(extract_text=lambda: DOCUMENT)])) + report = make_report() + report["suites"][0]["attachments"].pop(0) + path = tmp_path / "report.json" + path.write_text(json.dumps(report)) + monkeypatch.setattr("sys.argv", [str(SCRIPT), str(path)]) + assert MODULE.main() == 1 + + +@pytest.mark.parametrize("failure", [None, "duplicate-mode", "missing-image"]) +def test_independent_print_matrix_requires_both_modes_and_images(tmp_path: Path, monkeypatch, failure: str | None) -> None: + report = make_report() + original = report["suites"] + report["suites"] = [] + report["stats"]["expected"] = 24 + for mode in ("light", "dark"): + for result in json.loads(json.dumps(original)): + attachment = result["attachments"][0] + evidence = json.loads(base64.b64decode(attachment["body"])) + evidence.update(mode="light" if failure == "duplicate-mode" else mode, requiresImages=True) + attachment.update(name="markdown-print.json", body=base64.b64encode(json.dumps(evidence).encode()).decode()) + report["suites"].append(result) + monkeypatch.setattr( + MODULE, + "PdfReader", + lambda stream: SimpleNamespace( + pages=[SimpleNamespace(images=[] if failure == "missing-image" else [object()], extract_text=lambda: DOCUMENT)] + ), + ) + path = tmp_path / "printing.json" + path.write_text(json.dumps(report)) + monkeypatch.setattr("sys.argv", [str(SCRIPT), str(path), "--printing"]) + assert MODULE.main() == (0 if failure is None else 1) diff --git a/backend/tests/test_promote_companion_release_script.py b/backend/tests/test_promote_companion_release_script.py index d95fb1b97..5945c7bcb 100644 --- a/backend/tests/test_promote_companion_release_script.py +++ b/backend/tests/test_promote_companion_release_script.py @@ -145,16 +145,52 @@ def urlopen(_request: object) -> io.BytesIO: assert retry_delays == [MODULE.ASSET_DOWNLOAD_INITIAL_RETRY_DELAY_SECONDS] +@pytest.mark.parametrize("eventually_succeeds", [True, False]) +def test_request_asset_bytes_closes_http_errors_before_retrying(monkeypatch: pytest.MonkeyPatch, eventually_succeeds: bool) -> None: + asset = {"name": "manifest.json", "url": "https://api.github.test/asset"} + error_responses: list[io.BytesIO] = [] + retry_delays: list[int] = [] + + def urlopen(_request: object) -> io.BytesIO: + if eventually_succeeds and error_responses: + return io.BytesIO(b"asset contents") + error_response = io.BytesIO(b"temporarily unavailable") + error_responses.append(error_response) + raise urllib.error.HTTPError(asset["url"], 503, "Service Unavailable", Message(), error_response) + + def retry_sleep(delay: int) -> None: + assert error_responses[-1].closed + retry_delays.append(delay) + + monkeypatch.setattr(MODULE.urllib.request, "urlopen", urlopen) + monkeypatch.setattr(MODULE.time, "sleep", retry_sleep) + + if eventually_succeeds: + assert MODULE.request_asset_bytes(asset, "release-token") == b"asset contents" + assert len(error_responses) == 1 + else: + with pytest.raises(SystemExit): + MODULE.request_asset_bytes(asset, "release-token") + assert len(error_responses) == MODULE.ASSET_DOWNLOAD_MAX_ATTEMPTS + + assert all(response.closed for response in error_responses) + assert retry_delays == [ + MODULE.ASSET_DOWNLOAD_INITIAL_RETRY_DELAY_SECONDS * (2**attempt) + for attempt in range(len(error_responses) - (not eventually_succeeds)) + ] + + def test_request_asset_bytes_does_not_retry_permanent_http_failure( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: asset = {"name": "manifest.json", "url": "https://api.github.test/asset"} request_count = 0 + error_response = io.BytesIO(b"not found") def urlopen(_request: object) -> io.BytesIO: nonlocal request_count request_count += 1 - raise urllib.error.HTTPError(asset["url"], 404, "Not Found", Message(), io.BytesIO(b"not found")) + raise urllib.error.HTTPError(asset["url"], 404, "Not Found", Message(), error_response) monkeypatch.setattr(MODULE.urllib.request, "urlopen", urlopen) monkeypatch.setattr( @@ -167,6 +203,7 @@ def urlopen(_request: object) -> io.BytesIO: MODULE.request_asset_bytes(asset, "release-token") assert request_count == 1 + assert error_response.closed assert "after 1 attempt(s)" in capsys.readouterr().err diff --git a/backend/tests/test_release_workflow_configuration.py b/backend/tests/test_release_workflow_configuration.py index 88367fc12..1a27c9163 100644 --- a/backend/tests/test_release_workflow_configuration.py +++ b/backend/tests/test_release_workflow_configuration.py @@ -80,8 +80,11 @@ def test_docker_release_reuses_only_verified_source_ci_jobs() -> None: frontend_steps = ( "Set up Node.js", "Install frontend dependencies", - "Run frontend type checks", + "Verify frontend type correctness", "Run frontend tests", + "Build production frontend for browser checks", + "Install browser dependencies", + "Run browser correctness contracts", ) for component, names in (("backend", backend_steps), ("frontend", frontend_steps)): for name in names: diff --git a/backend/tests/test_reuse_release_ci_tests.py b/backend/tests/test_reuse_release_ci_tests.py index 9e6b28aeb..528b8786d 100644 --- a/backend/tests/test_reuse_release_ci_tests.py +++ b/backend/tests/test_reuse_release_ci_tests.py @@ -6,6 +6,7 @@ from typing import Any import pytest +import yaml sys.path.insert(0, str(Path(__file__).resolve().parents[2] / ".github/scripts")) @@ -20,6 +21,15 @@ "status": "completed", "conclusion": "success", } +FRONTEND_JOB = { + "name": "frontend", + "conclusion": "success", + "steps": [{"name": name, "conclusion": "success"} for name in reuse_ci.FRONTEND_REQUIRED_STEPS], +} +FRONTEND_COMMANDS = { + "Run browser correctness contracts": "cd frontend && npm run test:browser:ci", + "Verify frontend type correctness": "cd frontend && npm run typecheck", +} def run_lookup(monkeypatch: pytest.MonkeyPatch, responses: list[dict[str, Any]]) -> set[str]: @@ -43,7 +53,7 @@ def test_reuse_both_successful_jobs(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch, [ {"workflow_runs": [RUN]}, - {"jobs": [{"name": "backend", "conclusion": "success"}, {"name": "frontend", "conclusion": "success"}]}, + {"jobs": [{"name": "backend", "conclusion": "success"}, FRONTEND_JOB]}, ], ) == {"backend", "frontend"} @@ -53,11 +63,51 @@ def test_skipped_or_failed_jobs_are_not_reused(monkeypatch: pytest.MonkeyPatch) monkeypatch, [ {"workflow_runs": [RUN]}, - {"jobs": [{"name": "backend", "conclusion": "skipped"}, {"name": "frontend", "conclusion": "success"}]}, + {"jobs": [{"name": "backend", "conclusion": "skipped"}, FRONTEND_JOB]}, ], ) == {"frontend"} +@pytest.mark.parametrize("conclusion", ["skipped", "failure", "cancelled", None]) +@pytest.mark.parametrize("required_step", FRONTEND_COMMANDS) +def test_frontend_reuse_requires_each_successful_gate(monkeypatch: pytest.MonkeyPatch, conclusion: str | None, required_step: str) -> None: + job = { + **FRONTEND_JOB, + "steps": [{"name": name, "conclusion": conclusion if name == required_step else "success"} for name in FRONTEND_COMMANDS], + } + assert run_lookup(monkeypatch, [{"workflow_runs": [RUN]}, {"jobs": [job]}]) == set() + + +@pytest.mark.parametrize("conclusion", ["success", "failure"]) +def test_legacy_advisory_type_check_is_not_release_evidence(monkeypatch: pytest.MonkeyPatch, conclusion: str) -> None: + job = { + **FRONTEND_JOB, + "steps": [ + {"name": "Run browser correctness contracts", "conclusion": "success"}, + {"name": "TypeScript type check", "conclusion": conclusion}, + ], + } + assert run_lookup(monkeypatch, [{"workflow_runs": [RUN]}, {"jobs": [job]}]) == set() + + +def test_legacy_frontend_job_without_browser_checks_is_not_reused(monkeypatch: pytest.MonkeyPatch) -> None: + assert run_lookup(monkeypatch, [{"workflow_runs": [RUN]}, {"jobs": [{"name": "frontend", "conclusion": "success"}]}]) == set() + + +@pytest.mark.parametrize("name,job", [("test.yml", "frontend"), ("docker-image-preview-publish.yml", "validate-tests")]) +def test_ci_and_release_require_the_same_browser_contract(name: str, job: str) -> None: + assert reuse_ci.FRONTEND_REQUIRED_STEPS == FRONTEND_COMMANDS.keys() + source = Path(__file__).resolve().parents[2] / ".github/workflows" / name + steps = yaml.safe_load(source.read_text())["jobs"][job]["steps"] + browser_steps = [step for step in steps if step.get("name") in reuse_ci.FRONTEND_REQUIRED_STEPS] + assert len(browser_steps) == len(reuse_ci.FRONTEND_REQUIRED_STEPS) + assert all(step["run"] == FRONTEND_COMMANDS[step["name"]] and not step.get("continue-on-error") for step in browser_steps) + commands = [step.get("run") for step in steps] + assert "cd frontend && npm run build" in commands + assert "cd frontend && npm run test:e2e:install -- --with-deps" in commands + assert any(step.get("with", {}).get("path") == "frontend/test-results/ci/" and step["with"]["retention-days"] == 7 for step in steps) + + @pytest.mark.parametrize( "runs", [ diff --git a/backend/tests/test_user_settings.py b/backend/tests/test_user_settings.py index 6f6348c62..6f052768b 100644 --- a/backend/tests/test_user_settings.py +++ b/backend/tests/test_user_settings.py @@ -1,6 +1,7 @@ import json from types import SimpleNamespace +import pytest from fastapi.testclient import TestClient from sqlmodel import Session, select @@ -21,6 +22,38 @@ def colors(shape: object) -> object: return {"name": "Test theme", "description": "Test", "mode": "dark", **colors(COLOR_SHAPE)} +def test_legacy_theme_preserves_absent_syntax_group(client: TestClient, auth_headers_user: dict[str, str]) -> None: + definition = complete_theme_definition() + del definition["components"]["syntax"] + response = client.post("/api/themes", headers=auth_headers_user, json={"scope": "user", "definition": definition}) + assert response.status_code == 201, response.text + assert "syntax" not in response.json()["definition"]["components"] + listing = client.get("/api/themes", headers=auth_headers_user) + assert "syntax" not in listing.json()["themes"][0]["definition"]["components"] + + +@pytest.mark.parametrize("syntax", [None, {}, {"keyword": "#123456"}, {"unknown": "#123456"}, "#123456"]) +def test_supplied_syntax_requires_complete_palette(client: TestClient, auth_headers_user: dict[str, str], syntax: object) -> None: + definition = complete_theme_definition() + definition["components"]["syntax"] = syntax + response = client.post("/api/themes", headers=auth_headers_user, json={"scope": "user", "definition": definition}) + assert response.status_code == 422, response.text + assert "syntax" in response.json()["detail"] + + +def test_syntax_palette_round_trip_and_strict_values(client: TestClient, auth_headers_user: dict[str, str]) -> None: + definition = complete_theme_definition() + definition["components"]["syntax"]["keyword"] = "#aabbccdd" + response = client.post("/api/themes", headers=auth_headers_user, json={"scope": "user", "definition": definition}) + assert response.status_code == 201, response.text + assert response.json()["definition"]["components"]["syntax"]["keyword"] == "#AABBCCDD" + definition["components"]["syntax"]["keyword"] = "red" + assert client.post("/api/themes", headers=auth_headers_user, json={"scope": "user", "definition": definition}).status_code == 422 + definition["components"]["syntax"]["keyword"] = "#aabbccdd" + definition["components"]["syntax"]["unknown"] = "#123456" + assert client.post("/api/themes", headers=auth_headers_user, json={"scope": "user", "definition": definition}).status_code == 422 + + def test_theme_rows_permissions_versions_and_deletion( client: TestClient, auth_headers_user: dict[str, str], auth_headers_admin: dict[str, str] ) -> None: diff --git a/companion/package-lock.json b/companion/package-lock.json index ac981e842..617082bcf 100644 --- a/companion/package-lock.json +++ b/companion/package-lock.json @@ -1,12 +1,12 @@ { "name": "sambee-companion", - "version": "2.0.13", + "version": "2.0.14", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sambee-companion", - "version": "2.0.13", + "version": "2.0.14", "dependencies": { "@tauri-apps/api": "^2.12.4", "@tauri-apps/plugin-autostart": "^2.7.0", diff --git a/companion/package.json b/companion/package.json index 8fb3d0b05..6a33ee07c 100644 --- a/companion/package.json +++ b/companion/package.json @@ -1,6 +1,6 @@ { "name": "sambee-companion", - "version": "2.0.13", + "version": "2.0.14", "private": true, "type": "module", "scripts": { diff --git a/companion/src-tauri/Cargo.lock b/companion/src-tauri/Cargo.lock index a28885788..a976f8056 100644 --- a/companion/src-tauri/Cargo.lock +++ b/companion/src-tauri/Cargo.lock @@ -3768,7 +3768,7 @@ checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "sambee-companion" -version = "2.0.13" +version = "2.0.14" dependencies = [ "axum", "base64 0.22.1", diff --git a/companion/src-tauri/Cargo.toml b/companion/src-tauri/Cargo.toml index 784ef486c..f48d3d17f 100644 --- a/companion/src-tauri/Cargo.toml +++ b/companion/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sambee-companion" -version = "2.0.13" +version = "2.0.14" edition = "2021" description = "Sambee Companion — opens files from Sambee in native desktop editors" authors = [] diff --git a/companion/src-tauri/tauri.conf.json b/companion/src-tauri/tauri.conf.json index aa574b2e5..f7f1e1b50 100644 --- a/companion/src-tauri/tauri.conf.json +++ b/companion/src-tauri/tauri.conf.json @@ -2,7 +2,7 @@ "$schema": "https://raw.githubusercontent.com/nicegui/tauri-v2-schema/refs/heads/main/tauri.conf.json.schema", "productName": "Sambee Companion", "identifier": "app.sambee.companion", - "version": "2.0.13", + "version": "2.0.14", "build": { "beforeDevCommand": "npm run dev", "devUrl": "http://localhost:1420", diff --git a/frontend/e2e/markdown-viewer.spec.ts b/frontend/e2e/markdown-viewer.spec.ts index f1977a8e0..f79b56d57 100644 --- a/frontend/e2e/markdown-viewer.spec.ts +++ b/frontend/e2e/markdown-viewer.spec.ts @@ -1,9 +1,54 @@ -import { expect, test, type Locator, type Page, type Route } from "@playwright/test"; +import { expect, test, type BrowserContext, type Locator, type Page, type Route, type TestInfo } from "@playwright/test"; +import { replaceAll as nativeReplaceAll, SearchQuery, search, setSearchQuery } from "@codemirror/search"; +import { EditorState, type TransactionSpec } from "@codemirror/state"; +import type { EditorView } from "@codemirror/view"; +import { readFile } from "node:fs/promises"; +import { createServer } from "node:http"; +import { extname, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { StoredTheme } from "../src/services/api"; +import { editableDefinition } from "../src/theme/themeDefinition"; +import { builtInThemes } from "../src/theme/themes"; +import { MARKDOWN_HEADING_ID_PREFIX } from "../src/utils/markdownHeadingLinks"; +import { SOURCE_SEARCH_CHUNK_UNITS } from "../src/components/Editor/sourceRegexSearchProtocol"; +import { evaluateSourceResponsivenessBudgets, evaluateSourceTypingBudgets, evaluateViewerReadinessBudgets, evaluateViewerTimingBudgets, VIEWER_TIMING_TARGETS_MS, type ViewerTimingSamples } from "./viewer-performance-budgets"; const DEMO_CONNECTION_ID = "85610f49-ab40-4d96-8750-ddab3e8e8764"; const DEMO_PATH = "note.md"; const DEMO_DIRECTORY = "Reports"; const DEMO_ARCHIVE = "sample.zip"; +const DENSE_SYNTAX_LINE_COUNT = 6_000; +const DENSE_EXPANSION_REPLACEMENT_UNITS = 128; +const DENSE_EXPANSION_OUTPUT_UNITS = 780_000; +const SOURCE_TYPING_PAYLOAD = "abcdefghijklmnopqrst0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrst012345"; +const SOURCE_TYPING_KEY_DELAY_MS = 10; +const PROSE_ENVELOPE_PARAGRAPHS = 4_000; +const TABLE_ENVELOPE_ROWS = 2_400; +const TABLE_EDIT_PAYLOAD = "table edit"; +const TABLE_EDIT_CASES = [ + { name: "first", row: 1, action: "type" }, + { name: "middle", row: TABLE_ENVELOPE_ROWS / 2, action: "type" }, + { name: "last", row: TABLE_ENVELOPE_ROWS, action: "type" }, + { name: "line-break", row: TABLE_ENVELOPE_ROWS / 2, action: "line-break" }, + { name: "composition", row: TABLE_ENVELOPE_ROWS / 2, action: "composition" }, + { name: "close-flush", row: TABLE_ENVELOPE_ROWS / 2, action: "close" }, + { name: "composition-close", row: TABLE_ENVELOPE_ROWS / 2, action: "composition-close" }, +] as const; +const SCROLL_PROBE_STEPS = 60; +const NOMINAL_60HZ_SCROLL_TARGETS_MS = { p95: 16.7, maximum: 33.3 } as const; +const THEME_STATE_LINE_COUNT = 60; +const THEME_STATE_SCROLL = { top: 180, left: 36 }; +const MIN_TEXT_CONTRAST_RATIO = 4.5; +const CACHE_PROOF_ORIGIN = "http://localhost:3000"; +const CACHE_PROOF_ASSET_MAX_AGE_SECONDS = 31_536_000; +const PRODUCTION_ASSET_CONTENT_TYPES: Record = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".json": "application/json", ".woff2": "font/woff2" }; +const SYNTAX_PUBLICATION_FIXTURES = [ + { name: "dense", blocks: 1, lines: DENSE_SYNTAX_LINE_COUNT }, + { name: "near-safeguard", blocks: 1, lines: 11_111 }, + { name: "host-envelope", blocks: 4, lines: DENSE_SYNTAX_LINE_COUNT }, + { name: "many-blocks", blocks: 600, lines: 101 }, + { name: "long-line", blocks: 1, lines: 6_000 }, +] as const; const LINE_BREAK_MARKDOWN = "alpha\n\n| Col 1 | Col 2 |\n| --- | --- |\n| A1 | B1 |\n| A2 | B2 |\n\nomega\n"; const SEARCH_MARKDOWN = "alpha\n\n| Col 1 | Col 2 |\n| --- | --- |\n| alpha | B1 |\n| A2 | alpha |\n\nomega alpha\n"; @@ -20,6 +65,11 @@ const SCROLLED_SELECTION_MARKDOWN = [ interface MockMarkdownViewerApiOptions { initialMarkdown: string; + filePath?: string; + additionalFilePaths?: readonly string[]; + themeId?: () => string; + wordWrapEnabled?: boolean; + userRole?: "user" | "admin"; includeDirectory?: boolean; includeZip?: boolean; existingFilePaths?: readonly string[]; @@ -27,7 +77,11 @@ interface MockMarkdownViewerApiOptions { onUploadBody?: (body: string, setCurrentMarkdown: (markdown: string) => void) => void; } -async function fulfillJson(route: Route, json: unknown, status = 200): Promise { +type ViewerApiRoute = Pick & { + request(): Pick, "url" | "method" | "postDataBuffer">; +}; + +async function fulfillJson(route: ViewerApiRoute, json: unknown, status = 200): Promise { await route.fulfill({ status, contentType: "application/json", @@ -35,10 +89,19 @@ async function fulfillJson(route: Route, json: unknown, status = 200): Promise { +async function mockMarkdownViewerApi(page: Page, options: MockMarkdownViewerApiOptions): Promise { + const handler = createMarkdownViewerApiHandler(options); + await page.route("**/api/**", async route => { + const request = route.request(); + if (options.onTransferStream && new URL(request.url()).pathname === `/api/browse/${DEMO_CONNECTION_ID}/transfer-stream` && request.method() === "POST") return options.onTransferStream(route); + await handler(route); + }); +} + +function createMarkdownViewerApiHandler({ initialMarkdown, filePath = DEMO_PATH, additionalFilePaths = [], themeId = () => "sambee-light", wordWrapEnabled, userRole = "admin", includeDirectory, includeZip, existingFilePaths, onUploadBody }: MockMarkdownViewerApiOptions): (route: ViewerApiRoute) => Promise { let currentMarkdown = initialMarkdown; - await page.route("**/api/**", async (route) => { + return async (route) => { const request = route.request(); const url = new URL(request.url()); const pathname = url.pathname; @@ -63,15 +126,41 @@ async function mockMarkdownViewerApi(page: Page, { initialMarkdown, includeDirec await fulfillJson(route, { id: "user-1", username: "demo-admin", - role: "admin", + role: userRole, is_active: true, }); return; } + if (pathname === "/api/auth/oidc/refresh" && request.method() === "POST") { + await fulfillJson(route, { access_token: "ci-browser-token", token_type: "bearer", username: "demo-admin", user_id: "user-1", role: "admin" }); + return; + } + + if (pathname === "/api/auth/account" && request.method() === "GET") { + await fulfillJson(route, { + id: "user-1", username: "demo-admin", role: userRole, is_active: true, + must_change_password: false, expires_at: null, created_at: "2026-04-12T12:00:00Z", + has_local_password: true, identity_source: "local", password_change_available: true, + password_change_required: false, browser_session_management_available: false, + oidc_provider_name: null, current_session: null, + }); + return; + } + + if (pathname === "/api/themes" && request.method() === "GET") { + await fulfillJson(route, { themes: [], site_default_id: "sambee-light" }); + return; + } + + if (pathname === "/api/browse/recent-files" && request.method() === "POST") { + await fulfillJson(route, null); + return; + } + if (pathname.endsWith("/auth/me/settings") && request.method() === "GET") { await fulfillJson(route, { - appearance: { theme_id: "sambee-light", custom_themes: [] }, + appearance: { theme_id: themeId(), custom_themes: [] }, localization: { language: "browser", regional_locale: "browser" }, browser: { quick_nav_include_dot_directories: false, @@ -81,14 +170,14 @@ async function mockMarkdownViewerApi(page: Page, { initialMarkdown, includeDirec selected_connection_id: null, viewer_associations: {}, }, - text_editor: { max_file_size_bytes: 52428800, word_wrap_enabled: null }, + text_editor: { max_file_size_bytes: 52428800, word_wrap_enabled: wordWrapEnabled ?? null }, }); return; } if (pathname.endsWith("/auth/me/settings") && request.method() === "PUT") { await fulfillJson(route, { - appearance: { theme_id: "sambee-light", custom_themes: [] }, + appearance: { theme_id: themeId(), custom_themes: [] }, localization: { language: "browser", regional_locale: "browser" }, browser: { quick_nav_include_dot_directories: false, @@ -98,7 +187,7 @@ async function mockMarkdownViewerApi(page: Page, { initialMarkdown, includeDirec selected_connection_id: null, viewer_associations: {}, }, - text_editor: { max_file_size_bytes: 52428800, word_wrap_enabled: null }, + text_editor: { max_file_size_bytes: 52428800, word_wrap_enabled: wordWrapEnabled ?? null }, }); return; } @@ -138,15 +227,15 @@ async function mockMarkdownViewerApi(page: Page, { initialMarkdown, includeDirec await fulfillJson(route, { path: "/", items: [ - { - name: DEMO_PATH, - path: DEMO_PATH, + ...[filePath, ...additionalFilePaths].map((entryPath) => ({ + name: entryPath, + path: entryPath, type: "file", is_readable: true, size: currentMarkdown.length, - mime_type: "text/markdown", + mime_type: entryPath.endsWith(".md") ? "text/markdown" : "text/plain", modified_at: "2026-04-12T12:00:00Z", - }, + })), ...(includeDirectory ? [{ name: DEMO_DIRECTORY, path: DEMO_DIRECTORY, type: "directory", is_readable: true, modified_at: "2026-04-12T12:00:00Z" }] : []), @@ -203,7 +292,7 @@ async function mockMarkdownViewerApi(page: Page, { initialMarkdown, includeDirec if (pathname === `/api/browse/${DEMO_CONNECTION_ID}/lock` && request.method() === "POST") { await fulfillJson(route, { lock_id: "lock-1", - file_path: DEMO_PATH, + file_path: filePath, locked_by: "demo-admin", locked_at: "2026-04-12T12:00:00Z", lock_capability: "test-lock-capability", @@ -231,18 +320,76 @@ async function mockMarkdownViewerApi(page: Page, { initialMarkdown, includeDirec await route.fulfill({ status: 200, contentType: "application/json", - body: JSON.stringify({ path: DEMO_PATH }), + body: JSON.stringify({ path: filePath }), }); return; } - if (pathname === `/api/browse/${DEMO_CONNECTION_ID}/transfer-stream` && request.method() === "POST" && onTransferStream) { - await onTransferStream(route); - return; - } - await fulfillJson(route, { detail: `Unhandled mocked route: ${request.method()} ${pathname}` }, 404); + }; +} + +async function createCacheableViewerProxy(options: MockMarkdownViewerApiOptions) { + const handler = createMarkdownViewerApiHandler(options); + const dist = resolve(fileURLToPath(new URL("../dist/", import.meta.url))); + const requests: string[] = []; + const errors: string[] = []; + const uploads: { body: Buffer; contentType: string }[] = []; + const server = createServer((request, response) => { + void (async () => { + const url = new URL(request.url ?? "/", CACHE_PROOF_ORIGIN); + if (url.origin !== CACHE_PROOF_ORIGIN) { + response.writeHead(403).end("Only the approved demo origin is supported"); + return; + } + requests.push(url.pathname); + if (url.pathname.startsWith("/api/")) { + const chunks: Buffer[] = []; + for await (const chunk of request) chunks.push(Buffer.from(chunk)); + const body = Buffer.concat(chunks); + if (url.pathname.endsWith("/upload") && request.method === "POST") { + uploads.push({ body, contentType: request.headers["content-type"] ?? "" }); + } + await handler({ + request: () => ({ url: () => url.href, method: () => request.method ?? "GET", postDataBuffer: () => body }), + fulfill: async (result = {}) => { + if (result.path || result.response) throw new Error("Cache proxy fixtures require an explicit body"); + const headers = { ...result.headers, "Cache-Control": "no-store", ...(result.contentType ? { "Content-Type": result.contentType } : {}) }; + response.writeHead(result.status ?? 200, headers); + response.end(result.json === undefined ? result.body : JSON.stringify(result.json)); + }, + }); + return; + } + const path = url.pathname === "/browse/smb/demo" ? resolve(dist, "index.html") : resolve(dist, `.${url.pathname}`); + if (!path.startsWith(`${dist}${sep}`)) { + response.writeHead(403).end("Asset path is outside the production build"); + return; + } + try { + const body = await readFile(path); + response.writeHead(200, { "Content-Type": PRODUCTION_ASSET_CONTENT_TYPES[extname(path)] ?? "application/octet-stream", "Cache-Control": extname(path) === ".html" ? "no-store" : `public, max-age=${CACHE_PROOF_ASSET_MAX_AGE_SECONDS}, immutable` }); + response.end(body); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + response.writeHead(404).end("Production asset not found"); + } + })().catch(error => { + errors.push(error instanceof Error ? error.message : String(error)); + if (!response.headersSent) response.writeHead(500); + response.end("Cache proof server failed; inspect its recorded errors"); + }); }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { server.off("error", reject); resolve(); }); + }); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Cache proof proxy has no TCP address"); + return { + url: `http://127.0.0.1:${address.port}`, requests, errors, uploads, + close: () => new Promise((resolve, reject) => { server.close(error => error ? reject(error) : resolve()); server.closeAllConnections(); }), + }; } async function openMarkdownViewer(page: Page): Promise { @@ -250,6 +397,1944 @@ async function openMarkdownViewer(page: Page): Promise { await page.getByRole("button", { name: `File: ${DEMO_PATH}` }).click(); } +async function serveProductionAssets(page: Page, transformJavaScript?: (source: string, pathname: string) => string): Promise { + const dist = resolve(fileURLToPath(new URL("../dist/", import.meta.url))); + await page.route("http://localhost:3000/**", async (route) => { + const request = route.request(); + const pathname = new URL(request.url()).pathname; + if (pathname.startsWith("/api/")) return route.fallback(); + const path = request.isNavigationRequest() ? resolve(dist, "index.html") : resolve(dist, `.${pathname}`); + if (!path.startsWith(`${dist}${sep}`)) return route.abort(); + try { + const body = await readFile(path); + await route.fulfill({ body: transformJavaScript && extname(path) === ".js" ? transformJavaScript(body.toString("utf8"), pathname) : body, contentType: PRODUCTION_ASSET_CONTENT_TYPES[extname(path)] ?? "application/octet-stream" }); + } catch { + await route.fulfill({ status: 404, body: "Production asset not found" }); + } + }); +} + +async function captureViewerScheduling(page: Page, testInfo: TestInfo, action: () => Promise): Promise { + if (process.env.SAMBEE_E2E_TRACE !== "1" || testInfo.project.name !== "chromium") return action(); + if (process.env.SAMBEE_E2E_MEMORY === "1") throw new Error("Run scheduling traces separately from controlled-GC memory trials"); + const session = await page.context().newCDPSession(page); + let started = false; + try { + await session.send("Tracing.start", { categories: "-*,devtools.timeline,v8,blink.user_timing,toplevel,renderer.scheduler,disabled-by-default-devtools.timeline.frame,disabled-by-default-v8.cpu_profiler", transferMode: "ReturnAsStream" }); + started = true; + await page.evaluate(() => performance.mark("sambee:viewer-trace:start")); + await action(); + } finally { + try { + if (started) { + const completed = new Promise<{ stream?: string }>((resolve) => session.once("Tracing.tracingComplete", resolve)); + await session.send("Tracing.end"); + const { stream } = await completed; + if (!stream) throw new Error("Viewer scheduling trace returned no readable stream"); + const chunks: string[] = []; + try { + for (;;) { + const response = await session.send("IO.read", { handle: stream }); + chunks.push(response.base64Encoded ? Buffer.from(response.data, "base64").toString("utf8") : response.data); + if (response.eof) break; + } + } finally { + await session.send("IO.close", { handle: stream }); + } + await testInfo.attach("viewer-scheduling-trace", { body: chunks.join(""), contentType: "application/json" }); + } + } finally { + await session.detach(); + } + } +} + +test.describe("production shared syntax correctness", () => { + test.skip(process.env.SAMBEE_E2E_PRODUCTION !== "1", "Requires the production build and approved demo route"); + const fixtures = [ + { label: "js", filename: "sample.js", code: 'obj.key = [true, null, /x/, "\\n"];' }, + { label: "ts", filename: "sample.ts", code: 'const greet = (value: string): string => value; // end' }, + { label: "py", filename: "sample.py", code: 'print(True, None) # end' }, + { label: "sql", filename: "sample.sql", code: "SELECT count(*) FROM users WHERE id = 1; -- end" }, + { label: "sh", filename: "sample.sh", code: 'echo "hello" # end' }, + { label: "ps1", filename: "sample.ps1", code: '$sites = Get-Content $siteUrlFile; # end' }, + { label: "json", filename: "sample.json", code: '{"enabled": true, "value": null}' }, + { label: "c", filename: "sample.c", code: '#include // end' }, + { label: "mssql", filename: null, code: "SELECT TOP 1 [account] FROM [users] WHERE [id] = @id; -- end" }, + { label: "mariadb", filename: null, code: "SELECT `account` FROM `users` LIMIT 1; # end" }, + ]; + const editorTokens = (editor: Locator, text: string) => editor.locator(".cm-line").evaluateAll((lines, source) => { + const line = lines.find((element) => element.textContent === source); + if (!line) return []; + return [...line.querySelectorAll('span[class*="sambee-syntax-"]')].flatMap((span) => { + const prefix = document.createRange(); + prefix.setStart(line, 0); + prefix.setEnd(span, 0); + const from = prefix.toString().length; + return [...span.classList].filter((name) => name.startsWith("sambee-syntax-")).map((name) => ({ + from, to: from + (span.textContent?.length ?? 0), text: span.textContent ?? "", category: name.slice("sambee-syntax-".length), + })); + }).sort((left, right) => left.from - right.from || left.category.localeCompare(right.category)); + }, text); + + for (const width of [1543, 390]) { + for (const fixture of fixtures) { + test(`${fixture.label} across application surfaces at ${width}px`, async ({ page }) => { + await page.setViewportSize({ width, height: 900 }); + await serveProductionAssets(page); + let native: Awaited> | undefined; + if (fixture.filename) { + await mockMarkdownViewerApi(page, { initialMarkdown: fixture.code, filePath: fixture.filename, additionalFilePaths: [DEMO_PATH], existingFilePaths: [fixture.filename, DEMO_PATH] }); + await page.goto("/browse/smb/demo"); + await page.getByRole("button", { name: "Later", exact: true }).click(); + await page.getByRole("button").filter({ has: page.getByText(fixture.filename, { exact: true }) }).click(); + const editor = page.locator(".sambee-text-editor"); + await expect(editor.locator(".cm-content")).toHaveAttribute("contenteditable", "false"); + await expect.poll(async () => (await editorTokens(editor, fixture.code)).length).toBeGreaterThan(0); + native = await editorTokens(editor, fixture.code); + await page.getByRole("button", { name: "Edit", exact: true }).click(); + await expect(editor.locator(".cm-content")).toHaveAttribute("contenteditable", "true"); + await expect.poll(() => editorTokens(editor, fixture.code)).toEqual(native); + await page.getByRole("button", { name: "Close", exact: true }).click(); + } + + const markdown = `# Source\n\nPlain paragraph\n\n\`\`\`${fixture.label}\n${fixture.code}\n\`\`\`\n\n\`\`\`unknown-fence\n*not prose*\n\`\`\`\n`; + await mockMarkdownViewerApi(page, { initialMarkdown: markdown, existingFilePaths: [DEMO_PATH] }); + await page.goto("/browse/smb/demo"); + await page.getByRole("button", { name: "Later", exact: true }).click(); + await page.getByRole("button").filter({ has: page.getByText(DEMO_PATH, { exact: true }) }).click(); + const root = page.locator('[data-markdown-search-root="true"]'); + const code = root.locator("pre code").first(); + await expect(code).toHaveText(`${fixture.code}\n`); + const node = await code.evaluateHandle((element) => element.firstChild); + const previewTokens = () => code.evaluate((element) => [...CSS.highlights].flatMap(([name, highlight]) => { + if (!/^markdown-syntax-\d+-/.test(name)) return []; + return [...highlight].filter((range) => element.contains(range.startContainer)).map((range) => ({ + from: range.startOffset, to: range.endOffset, text: range.toString(), category: name.replace(/^markdown-syntax-\d+-/, ""), + })); + }).sort((left, right) => left.from - right.from || left.category.localeCompare(right.category))); + await expect.poll(async () => (await previewTokens()).length).toBeGreaterThan(0); + const preview = await previewTokens(); + await code.evaluate((element) => { const pre = element.closest("pre")!; pre.scrollLeft = pre.scrollWidth; }); + await expect.poll(async () => Math.max(...(await previewTokens()).map((token) => token.to))).toBe(fixture.code.length); + preview.push(...await previewTokens()); + expect(await code.evaluate((element, original) => element.firstChild === original, node)).toBe(true); + await node.dispose(); + expect(await root.locator("pre code").last().evaluate((element) => [...CSS.highlights.values()].some((highlight) => + [...highlight].some((range) => element.contains(range.startContainer))))).toBe(false); + await page.getByRole("button", { name: "Edit", exact: true }).click(); + const source = page.getByRole("textbox", { name: "Markdown editor" }); + await expect(source).toHaveAttribute("contenteditable", "true"); + await expect.poll(async () => (await editorTokens(source, fixture.code)).some((token) => token.to === fixture.code.length)).toBe(true); + const expected = await editorTokens(source, fixture.code); + const requiredCategories: Record = { + js: ["property", "boolean", "null", "regexp", "escape"], + py: ["boolean", "null"], sh: ["builtin"], ps1: ["builtin", "identifier"], + json: ["property", "boolean", "null"], c: ["metadata"], + }; + expect(expected.map(token => token.category)).toEqual(expect.arrayContaining(requiredCategories[fixture.label] ?? [])); + if (native) expect(expected).toEqual(native); + for (const fragment of preview) { + expect(expected.some((token) => token.category === fragment.category && token.from <= fragment.from && token.to >= fragment.to)).toBe(true); + expect(fragment.text).toBe(fixture.code.slice(fragment.from, fragment.to)); + } + for (const token of expected) { + let covered = token.from; + for (const fragment of preview.filter((fragment) => fragment.category === token.category && fragment.to > token.from && fragment.from < token.to).sort((left, right) => left.from - right.from)) { + expect(fragment.from).toBeLessThanOrEqual(covered); + covered = Math.max(covered, fragment.to); + } + expect(covered).toBe(token.to); + } + await expect(source.locator(".sambee-syntax-heading").filter({ hasText: "Source" })).toHaveText(" Source"); + expect(await editorTokens(source, "Plain paragraph")).toEqual([]); + expect(await editorTokens(source, "*not prose*")).toEqual([]); + await page.getByRole("button", { name: "Close", exact: true }).click(); + }); + } + } +}); + +test.describe("production nested syntax failures", () => { + test.skip(process.env.SAMBEE_E2E_PRODUCTION !== "1", "Requires the production build and approved demo route"); + for (const width of [1543, 390]) { + for (const phase of ["load", "start", "advance", "pending-load"] as const) { + for (const failedFirst of [true, false]) { + test(`${phase}, failed first ${failedFirst}, at ${width}px`, async ({ page }) => { + await page.setViewportSize({ width, height: 900 }); + let injectFailure = true; + const pendingMarker = "Injected pending Python grammar load"; + let pendingLoad = false; + page.on("console", message => { if (message.text() === pendingMarker) pendingLoad = true; }); + await serveProductionAssets(page, (source, pathname) => injectFailure ? `${source}\n +import * as syntaxFaultModule from ${JSON.stringify(pathname)}; +if (typeof WorkerGlobalScope !== "undefined" && globalThis instanceof WorkerGlobalScope && typeof syntaxFaultModule.python === "function") { + if (${JSON.stringify(phase)} === "pending-load") { + console.debug(${JSON.stringify(pendingMarker)}); + await new Promise(() => {}); + } + const parser = syntaxFaultModule.python().language.parser; + const originalStart = parser.startParse; + if (${JSON.stringify(phase)} === "load") throw new Error("Injected Python grammar load failure"); + parser.startParse = function (...args) { + this.startParse = originalStart; + if (${JSON.stringify(phase)} === "start") throw new Error("Injected Python parser startup failure"); + const partial = originalStart.apply(this, args); + partial.advance = () => { throw new Error("Injected Python parser advance failure"); }; + return partial; + }; +}` : source); + const failed = "def failed(): pass"; + const sibling = "const sibling = 1;"; + const healthy = "def healthy(): pass"; + const failedFence = `\`\`\`python\n${failed}\n\`\`\``; + const healthyFence = `\`\`\`js\n${sibling}\n\`\`\``; + let nested = `# Healthy heading\n\n${(failedFirst ? [failedFence, healthyFence] : [healthyFence, failedFence]).join("\n\n")}\n\n\`\`\`python\n${healthy}\n\`\`\``; + for (const size of [4, 5, 6]) nested = `${"`".repeat(size)}markdown\n${nested}\n${"`".repeat(size)}`; + const markdown = `\`\`\`\`\`\`\`markdown\n${nested}\n\`\`\`\`\`\`\`\n\n\`\`\`js\nconst independent = 1;\n\`\`\``; + await mockMarkdownViewerApi(page, { initialMarkdown: markdown }); + await page.goto("/browse/smb/demo"); + await page.getByRole("button", { name: "Later", exact: true }).click(); + const open = () => page.getByRole("button").filter({ has: page.getByText(DEMO_PATH, { exact: true }) }).click(); + await open(); + const root = page.locator('[data-markdown-search-root="true"]'); + const code = root.locator("pre code").first(); + await expect(code).toHaveText(`${nested}\n`); + const original = await code.evaluateHandle(element => element.firstChild); + const tokens = () => code.evaluate(element => [...CSS.highlights].flatMap(([name, highlight]) => { + if (!/^markdown-syntax-\d+-/.test(name)) return []; + return [...highlight].filter(range => element.contains(range.startContainer)).map(range => ({ from: range.startOffset, to: range.endOffset, text: range.toString(), category: name.replace(/^markdown-syntax-\d+-/, "") })); + })); + const from = nested.indexOf(failed); + if (phase === "pending-load") { + await expect.poll(() => pendingLoad).toBe(true); + expect(page.workers()).toHaveLength(1); + } else { + await expect.poll(tokens).toEqual(expect.arrayContaining([ + expect.objectContaining({ text: " Healthy heading", category: "heading" }), + expect.objectContaining({ from: nested.indexOf(sibling), text: "const", category: "keyword" }), + ])); + expect((await tokens()).some(token => token.from < from + failed.length && token.to > from)).toBe(false); + if (phase !== "load") expect(await tokens()).toEqual(expect.arrayContaining([expect.objectContaining({ from: nested.indexOf(healthy), text: "def", category: "keyword" })])); + await expect.poll(() => root.locator("pre code").last().evaluate(element => [...CSS.highlights].some(([name, highlight]) => name.endsWith("-keyword") && [...highlight].some(range => element.contains(range.startContainer) && range.toString() === "const")))).toBe(true); + } + expect(await code.evaluate((element, original) => element.firstChild === original, original)).toBe(true); + await original.dispose(); + await page.getByRole("button", { name: "Search", exact: true }).click(); + await page.getByPlaceholder("Search", { exact: true }).fill(failed); + await expect(page.getByRole("button", { name: "Next match", exact: true }).locator("..").getByText("1 / 1", { exact: true })).toBeVisible(); + await page.getByRole("button", { name: "Close", exact: true }).click(); + await expect(root).toHaveCount(0); + await expect.poll(() => page.workers().length).toBe(0); + await expect.poll(() => page.evaluate(() => [...CSS.highlights.keys()].filter(name => name.startsWith("markdown-syntax-")).length)).toBe(0); + injectFailure = false; + await open(); + await expect(code).toHaveText(`${nested}\n`); + await expect.poll(tokens).toEqual(expect.arrayContaining([expect.objectContaining({ from, text: "def", category: "keyword" })])); + await page.getByRole("button", { name: "Close", exact: true }).click(); + await expect(root).toHaveCount(0); + await expect.poll(() => page.workers().length).toBe(0); + }); + } + } + test(`nesting-depth safeguard preserves independent blocks at ${width}px`, async ({ page }) => { + await page.setViewportSize({ width, height: 900 }); + await serveProductionAssets(page); + let nested = "```js\nconst guarded = 1;\n```"; + for (const size of [4, 5, 6, 7]) nested = `${"`".repeat(size)}markdown\n${nested}\n${"`".repeat(size)}`; + await mockMarkdownViewerApi(page, { initialMarkdown: `\`\`\`\`\`\`\`\`markdown\n${nested}\n\`\`\`\`\`\`\`\`\n\n\`\`\`js\nconst independent = 1;\n\`\`\`` }); + await page.goto("/browse/smb/demo"); + await page.getByRole("button", { name: "Later", exact: true }).click(); + await page.getByRole("button").filter({ has: page.getByText(DEMO_PATH, { exact: true }) }).click(); + const root = page.locator('[data-markdown-search-root="true"]'); + const code = root.locator("pre code"); + await expect(code.first()).toHaveText(`${nested}\n`); + await expect.poll(() => code.last().evaluate(element => [...CSS.highlights].some(([name, highlight]) => name.endsWith("-keyword") && [...highlight].some(range => element.contains(range.startContainer) && range.toString() === "const")))).toBe(true); + expect(await code.first().evaluate(element => [...CSS.highlights.values()].some(highlight => [...highlight].some(range => element.contains(range.startContainer))))).toBe(false); + await page.getByRole("button", { name: "Close", exact: true }).click(); + await expect(root).toHaveCount(0); + await expect.poll(() => page.workers().length).toBe(0); + await expect.poll(() => page.evaluate(() => [...CSS.highlights.keys()].filter(name => name.startsWith("markdown-syntax-")).length)).toBe(0); + }); + } +}); + +test.describe("production shared syntax theme identity", () => { + test.skip(process.env.SAMBEE_E2E_PRODUCTION !== "1", "Requires the production build and approved demo route"); + for (const width of [1543, 390]) { + for (const kind of ["text", "markdown"] as const) { + for (const editing of [false, true]) { + test(`${kind} ${editing ? "edit" : "view"} live colors and browser state at ${width}px`, async ({ page }, testInfo) => { + await page.setViewportSize({ width, height: 900 }); + const grammarGate = "sambee-e2e-theme-grammar"; + const pendingContexts = new Set(); + page.on("console", message => { + if (message.text().startsWith(grammarGate)) pendingContexts.add(message.text().slice(grammarGate.length)); + }); + await serveProductionAssets(page, source => source.replace(/\.then\((\w+)=>\1\.javascript\(\)\)/g, `.then(async grammar => { + const gate = new BroadcastChannel(${JSON.stringify(grammarGate)}); + await new Promise(resolve => { + gate.onmessage = () => { gate.close(); resolve(); }; + console.debug(${JSON.stringify(grammarGate)} + (typeof document === "undefined" ? "worker" : "window")); + }); + return grammar.javascript(); + })`)); + await page.addInitScript(() => { + const OriginalWorker = window.Worker; + const requests: string[] = []; + const preparations: string[] = []; + new PerformanceObserver(list => { + for (const entry of list.getEntries()) { + if (/^sambee:preview-preparation:(?:render-parse|render-prepared-tree|render-transform|collection-walk)$/.test(entry.name)) preparations.push(entry.name); + } + }).observe({ entryTypes: ["measure"] }); + Object.assign(window, { themeWorkerRequests: requests, themePreparationStages: preparations }); + window.Worker = class extends OriginalWorker { + postMessage(message: unknown, transfer?: Transferable[]) { + requests.push(`${(message as { type?: string }).type}`); + super.postMessage(message, transfer ?? []); + } + }; + }); + const code = Array.from({ length: THEME_STATE_LINE_COUNT }, (_, index) => `const alpha = "hello ${index} ${"wide ".repeat(100)}"; let beta = 1;`).join("\n"); + let themeId = "sambee-light"; + const custom = structuredClone(builtInThemes.find((theme) => theme.id === "sambee-dark")!); + custom.id = "syntax-alpha"; + custom.name = "Syntax alpha"; + custom.components!.syntax!.keyword = "#c8f5d9bb"; + const themes = [...builtInThemes.filter((theme) => theme.id === "sambee-light" || theme.id === "sambee-dark"), custom]; + const filePath = kind === "text" ? "sample.js" : DEMO_PATH; + const [version, gitCommit] = await Promise.all([ + readFile(new URL("../../VERSION", import.meta.url), "utf8"), + readFile(new URL("../../GIT_COMMIT", import.meta.url), "utf8"), + ]); + const installThemeRoutes = async () => { + await page.route("**/api/themes", (route) => fulfillJson(route, { + site_default_id: "sambee-light", + themes: [{ id: custom.id, definition: custom, scope: "user", version: 1 }], + })); + await page.route("**/api/version", (route) => fulfillJson(route, { + version: version.trim(), git_commit: gitCommit.trim(), build_time: "2026-10-22T00:00:00Z", + })); + }; + await mockMarkdownViewerApi(page, { + initialMarkdown: kind === "text" ? code : `\`\`\`js\n${code}\n\`\`\``, filePath, + existingFilePaths: [filePath], themeId: () => themeId, wordWrapEnabled: false, + }); + await installThemeRoutes(); + await page.addLocatorHandler(page.getByRole("dialog", { name: "Update available" }), async (dialog) => { + await dialog.getByRole("button", { name: "Later", exact: true }).click(); + }); + await page.goto("/browse/smb/demo"); + const file = page.getByRole("button", { name: filePath, exact: true }).or(page.getByRole("button", { name: `File: ${filePath}`, exact: true })); + await file.click(); + if (editing) await page.getByRole("button", { name: "Edit", exact: true }).click(); + + const checkThemes = async (surface: Locator, preview: boolean) => { + const hasSyntax = () => surface.evaluate((element, isPreview) => isPreview + ? [...CSS.highlights].some(([name, highlight]) => name.endsWith("-keyword") && [...highlight].some((range) => element.contains(range.startContainer))) + : !!element.querySelector(".sambee-syntax-keyword"), preview); + await expect.poll(() => pendingContexts.has(preview ? "worker" : "window")).toBe(true); + expect(await hasSyntax()).toBe(false); + const searchInput = preview ? page.getByPlaceholder("Search", { exact: true }) : page.getByRole("textbox", { name: "Find", exact: true }); + if (!(await searchInput.isVisible())) await page.getByRole("button", { name: "Search", exact: true }).click(); + await searchInput.fill("const"); + const searchControls = preview ? page.getByRole("button", { name: "Next match", exact: true }).locator("..") : page.getByTestId("code-mirror-find-replace-popover"); + await expect(searchControls.getByText(new RegExp(`^[01] / ${THEME_STATE_LINE_COUNT}$`), { exact: true })).toBeVisible(); + await page.getByRole("button", { name: "Next match", exact: true }).click(); + await surface.focus(); + const editable = await surface.getAttribute("contenteditable") === "true"; + if (editable) { + await surface.press("Control+Home"); + if (kind === "markdown") { await surface.press("ArrowDown"); await surface.press("Home"); } + for (let index = 0; index < "const".length; index += 1) await surface.press("Shift+ArrowRight"); + } + await surface.evaluate((element, { preview, editable }) => { + if (!editable) { + const range = document.createRange(); + const walker = document.createTreeWalker(element.querySelector(preview ? "code" : ".cm-line")!, NodeFilter.SHOW_TEXT); + let node = walker.nextNode(); + while (node && !node.textContent!.includes("const")) node = walker.nextNode(); + const from = node!.textContent!.indexOf("const"); + range.setStart(node!, from); + range.setEnd(node!, from + "const".length); + window.getSelection()!.removeAllRanges(); + window.getSelection()!.addRange(range); + } + }, { preview, editable }); + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))); + await surface.evaluate((element, { preview, scroll }) => { + const scroller = preview ? element.parentElement! : element.closest(".cm-scroller")!; + scroller.scrollTop = scroll.top; + let horizontal = preview ? element.querySelector("pre")! : scroller; + while (horizontal.parentElement && horizontal.scrollWidth <= horizontal.clientWidth) horizontal = horizontal.parentElement; + horizontal.scrollLeft = scroll.left; + }, { preview, scroll: THEME_STATE_SCROLL }); + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))); + const browserState = () => surface.evaluate((element, preview) => { + const selection = window.getSelection()!; + const offset = (node: Node | null, offset: number) => { + if (!node || !element.contains(node)) return null; + const range = document.createRange(); + range.selectNodeContents(element); + range.setEnd(node, offset); + return range.toString().length; + }; + const scroller = preview ? element.parentElement! : element.closest(".cm-scroller")!; + let horizontal = preview ? element.querySelector("pre")! : scroller; + while (horizontal.parentElement && horizontal.scrollWidth <= horizontal.clientWidth) horizontal = horizontal.parentElement; + return { + selected: selection.toString(), anchor: offset(selection.anchorNode, selection.anchorOffset), head: offset(selection.focusNode, selection.focusOffset), + top: scroller.scrollTop, left: horizontal.scrollLeft, + active: preview ? [...CSS.highlights].filter(([name]) => name.startsWith("markdown-search-") && name.endsWith("-current")).flatMap(([, ranges]) => [...ranges].map(range => ({ text: range.toString(), from: offset(range.startContainer, range.startOffset), to: offset(range.endContainer, range.endOffset) }))) + : [...element.querySelectorAll(".cm-searchMatch-selected, .sambee-passive-search-match-selected")].map(match => ({ text: match.textContent })), + requests: [...(window as unknown as { themeWorkerRequests: string[] }).themeWorkerRequests], + renders: [...(window as unknown as { themePreparationStages: string[] }).themePreparationStages], + }; + }, preview); + const initialState = await browserState(); + expect(initialState.selected).toBe("const"); + expect(initialState.top).toBeGreaterThan(0); + expect(initialState.left).toBeGreaterThan(0); + expect(initialState.active).toHaveLength(1); + const rootBeforeGrammar = await surface.elementHandle(); + const textBeforeGrammar = await surface.textContent(); + const previewNode = preview ? await surface.evaluateHandle(element => element.querySelector("code")!.firstChild) : undefined; + await page.evaluate(channel => { + const gate = new BroadcastChannel(channel); + gate.postMessage("release"); + gate.close(); + }, grammarGate); + await expect.poll(hasSyntax).toBe(true); + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))); + const afterGrammar = await browserState(); + await testInfo.attach("late-grammar-state.json", { body: JSON.stringify({ before: initialState, after: afterGrammar }), contentType: "application/json" }); + expect({ ...afterGrammar, requests: [] }).toEqual({ ...initialState, requests: [] }); + expect(afterGrammar.requests.filter(type => type === "start" || type === "source")).toEqual(initialState.requests.filter(type => type === "start" || type === "source")); + expect(await surface.evaluate((element, original) => element === original, rootBeforeGrammar)).toBe(true); + expect(await surface.textContent()).toBe(textBeforeGrammar); + if (previewNode) { + expect(await surface.evaluate((element, original) => element.querySelector("code")!.firstChild === original, previewNode)).toBe(true); + await previewNode.dispose(); + } + await rootBeforeGrammar?.dispose(); + const settledState = afterGrammar; + const initialCount = await searchControls.textContent(); + const original = await surface.elementHandle(); + const text = await surface.textContent(); + const selector = preview ? "code" : ".cm-line"; + const node = await surface.evaluateHandle((element, selector) => element.querySelector(selector)!.firstChild, selector); + const initialWorkers = page.workers(); + for (const theme of themes) { + themeId = theme.id; + await page.evaluate(() => window.dispatchEvent(new Event("focus"))); + const expected = theme.components!.syntax!.keyword; + await expect.poll(() => surface.evaluate((element, { preview, expected }) => { + const target = preview ? element.querySelector("code")! : [...element.querySelectorAll(".sambee-syntax-keyword")].find(token => !token.closest(".cm-searchMatch, .sambee-passive-search-match") && !token.querySelector(".cm-searchMatch, .sambee-passive-search-match"))!; + const name = preview ? [...CSS.highlights].find(([name, highlight]) => name.endsWith("-keyword") && [...highlight].some((range) => target.contains(range.startContainer)))?.[0] : undefined; + const reference = document.createElement("span"); + reference.style.color = expected; + document.body.append(reference); + const desired = getComputedStyle(reference).color; + reference.remove(); + return getComputedStyle(target, name ? `::highlight(${name})` : null).color === desired; + }, { preview, expected })).toBe(true); + expect(await browserState()).toEqual(settledState); + expect(await searchControls.textContent()).toBe(initialCount); + const contrast = await surface.evaluate((element, preview) => { + const canvas = document.createElement("canvas"); + canvas.width = canvas.height = 1; + const context = canvas.getContext("2d", { willReadFrequently: true })!; + const parse = (color: string) => { + context.clearRect(0, 0, 1, 1); + context.fillStyle = color; + context.fillRect(0, 0, 1, 1); + return [...context.getImageData(0, 0, 1, 1).data].map((channel, index) => index === 3 ? channel / 255 : channel); + }; + const over = (foreground: number[], background: number[]) => foreground.slice(0, 3).map((channel, index) => channel * foreground[3] + background[index] * (1 - foreground[3])).concat(1); + const background = (target: Element) => { + const ancestors: Element[] = []; + for (let ancestor: Element | null = target; ancestor; ancestor = ancestor.parentElement) ancestors.unshift(ancestor); + return ancestors.reduce((color, ancestor) => over(parse(getComputedStyle(ancestor).backgroundColor), color), [255, 255, 255, 1]); + }; + const luminance = (color: number[]) => color.slice(0, 3).map(channel => channel / 255).map(channel => channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4).reduce((sum, channel, index) => sum + channel * [0.2126, 0.7152, 0.0722][index], 0); + const ratio = (foreground: number[], background: number[]) => { + const values = [luminance(over(foreground, background)), luminance(background)].sort((first, second) => second - first); + return (values[0] + 0.05) / (values[1] + 0.05); + }; + const target = preview ? element.querySelector("code")! : [...element.querySelectorAll(".sambee-syntax-keyword")].find(token => !token.closest(".cm-searchMatch, .sambee-passive-search-match") && !token.querySelector(".cm-searchMatch, .sambee-passive-search-match"))!; + const base = background(target); + const results: { role: string; ratio: number }[] = []; + const syntaxName = preview ? [...CSS.highlights.keys()].find(name => /^markdown-syntax-\d+-keyword$/.test(name)) : undefined; + results.push({ role: "keyword", ratio: ratio(parse(getComputedStyle(target, syntaxName ? `::highlight(${syntaxName})` : null).color), base) }); + const searchTargets = preview + ? [...CSS.highlights.keys()].filter(name => name.startsWith("markdown-search-")).map(name => ({ role: name.endsWith("-current") ? "current-search" : "other-search", target, style: getComputedStyle(target, `::highlight(${name})`) })) + : [...element.querySelectorAll(".cm-searchMatch, .sambee-passive-search-match")].slice(0, 3).map(match => ({ role: match.className.includes("selected") ? "current-search" : "other-search", target: match, style: getComputedStyle(match) })); + for (const match of searchTargets) { + const backdrop = over(parse(match.style.backgroundColor), background(match.target.parentElement!)); + results.push({ role: match.role, ratio: ratio(parse(match.style.color), backdrop) }); + } + const selected = element.querySelector(".sambee-editor-selection-range"); + const selectionTarget = selected ?? target; + const selectionStyle = getComputedStyle(selectionTarget, selected ? null : "::selection"); + const selectionBackground = over(parse(selectionStyle.backgroundColor), background(selectionTarget.parentElement!)); + results.push({ role: "selection", ratio: ratio(parse(selectionStyle.color), selectionBackground) }); + return results; + }, preview); + await testInfo.attach(`${theme.id}-contrast.json`, { body: JSON.stringify({ kind, editing, width, contrast, state: await browserState() }), contentType: "application/json" }); + for (const sample of contrast) expect(sample.ratio, `${kind}/${preview ? "preview" : "source"}/${theme.id}/${sample.role}`).toBeGreaterThanOrEqual(MIN_TEXT_CONTRAST_RATIO); + expect(await surface.evaluate((element, original) => element === original, original)).toBe(true); + expect(await surface.evaluate((element, { node, selector }) => element.querySelector(selector)!.firstChild === node, { node, selector })).toBe(true); + expect(await surface.textContent()).toBe(text); + expect(page.workers()).toEqual(initialWorkers); + expect(await surface.evaluate((element) => { + const probe = document.createElement("span"); + probe.className = "sambee-syntax-keyword"; + probe.style.setProperty("--sambee-syntax-keyword", "rgb(225, 0, 102)"); + element.parentElement!.append(probe); + const color = getComputedStyle(probe).color; + probe.remove(); + return color; + })).toBe("rgb(225, 0, 102)"); + } + await node.dispose(); + await original?.dispose(); + if (preview) await page.getByRole("button", { name: "Close search", exact: true }).click(); + else { + await searchControls.getByRole("button", { name: "Close", exact: true }).click(); + await expect(searchControls).toBeHidden(); + } + }; + + const preview = kind === "markdown" && !editing; + const surface = preview ? page.locator('[data-markdown-search-root="true"]') : page.getByRole("textbox", { name: kind === "text" ? "Text editor" : "Markdown editor" }); + await checkThemes(surface, preview); + await page.getByRole("button", { name: "Close", exact: true }).click(); + if (editing) { + await expect(page.getByRole("button", { name: "Edit", exact: true })).toBeVisible(); + await page.getByRole("button", { name: "Close", exact: true }).click(); + } + await expect(surface).toHaveCount(0); + await expect(page).toHaveURL("/browse/smb/demo"); + await expect(page.getByRole("button", { name: "Edit", exact: true })).toBeHidden(); + await expect(file).toBeVisible(); + await expect.poll(() => page.workers().length).toBe(0); + }); + } + } + } +}); + +test.describe("production syntax theme dialog", () => { + test.skip(process.env.SAMBEE_E2E_PRODUCTION !== "1", "Requires the production build and approved demo route"); + + async function openEditor(page: Page, width: number, mode: "light" | "dark", target: "user" | "site" | "built-in" = "user", isAdmin = true) { + await page.setViewportSize({ width, height: width < 600 ? 600 : 900 }); + await serveProductionAssets(page); + const selected: StoredTheme = { + id: target === "site" ? "dialog-site" : "dialog-personal", scope: target === "site" ? "site" : "user", version: 3, + definition: { ...editableDefinition(builtInThemes.find(theme => theme.mode === mode)!), name: "Dialog theme" }, + }; + const base = builtInThemes.find(theme => theme.mode === mode)!; + const selectedId = target === "built-in" ? base.id : selected.id; + const selectedName = target === "built-in" ? base.name : selected.definition.name; + const storedThemes: StoredTheme[] = target === "built-in" ? [] : [selected]; + const writes: { method: string; path: string; body: unknown }[] = []; + const settingsWrites: unknown[] = []; + let nextFailure: { status: number; detail: string } | undefined; + await mockMarkdownViewerApi(page, { initialMarkdown: "# Theme dialog fixture", themeId: () => selectedId, userRole: isAdmin ? "admin" : "user" }); + const [version, gitCommit] = await Promise.all([ + readFile(new URL("../../VERSION", import.meta.url), "utf8"), + readFile(new URL("../../GIT_COMMIT", import.meta.url), "utf8"), + ]); + await page.route("**/api/version", route => fulfillJson(route, { + version: version.trim(), git_commit: gitCommit.trim(), build_time: "2026-10-22T00:00:00Z", + })); + await page.route("**/api/auth/me/settings", async route => { + if (route.request().method() !== "PUT") return route.fallback(); + const update = route.request().postDataJSON(); + if (update.field === "appearance.theme_id") settingsWrites.push(update); + await route.fallback(); + }); + await page.route("**/api/themes**", async route => { + const request = route.request(); + if (request.method() === "GET") return fulfillJson(route, { site_default_id: "sambee-light", themes: storedThemes }); + const body = request.postDataJSON(); + const path = new URL(request.url()).pathname; + writes.push({ method: request.method(), path, body }); + if (nextFailure) { + const failure = nextFailure; + nextFailure = undefined; + return fulfillJson(route, { detail: failure.detail }, failure.status); + } + if (request.method() === "POST" && path === "/api/themes") { + const created: StoredTheme = { id: `dialog-copy-${writes.length}`, scope: body.scope, version: 1, definition: body.definition }; + storedThemes.push(created); + return fulfillJson(route, created); + } + if (request.method() !== "PUT" || path !== `/api/themes/${selected.id}`) return fulfillJson(route, { detail: "Unexpected theme write" }, 400); + selected.definition = body.definition; + selected.version += 1; + await fulfillJson(route, selected); + }); + await page.addLocatorHandler(page.getByRole("dialog").filter({ has: page.getByRole("heading", { name: "Update available", exact: true }) }), async dialog => { + await dialog.getByRole("button", { name: "Later", exact: true }).click(); + }); + await page.goto("/browse/smb/demo"); + if (width < 600) await page.getByRole("button", { name: "Open menu", exact: true }).click(); + await page.getByRole("button", { name: "Open settings", exact: true }).click(); + if (width < 600) await page.getByRole("list").getByText("Appearance", { exact: true }).click(); + else await page.getByRole("listbox", { name: "Settings categories", exact: true }).getByText("Appearance", { exact: true }).click(); + const selectedRadio = page.getByText(target === "site" ? "Site themes" : target === "user" ? "Your themes" : "Built-in themes", { exact: true }).locator("..").getByRole("radio", { name: selectedName, exact: true }); + await expect(selectedRadio).toBeChecked(); + await page.getByRole("button", { name: "Edit", exact: true }).click(); + const editor = page.getByRole("dialog").filter({ has: page.getByRole("heading", { name: `Edit ${selectedName} (${target === "user" ? "personal" : target})`, exact: true }) }); + await expect(editor).toBeVisible(); + return { editor, selected, selectedName, selectedRadio, storedThemes, writes, settingsWrites, + rejectNextWrite: (status: number, detail: string) => { nextFailure = { status, detail }; }, + }; + } + + for (const width of [1543, 390]) { + for (const mode of ["light", "dark"] as const) { + for (const target of ["user", "site"] as const) { + test(`${target === "user" ? "personal" : "site"} ${mode} syntax cancel, validation and versioned save at ${width}px`, async ({ page }) => { + const { editor, selected, selectedRadio, writes, settingsWrites } = await openEditor(page, width, mode, target, target === "site"); + const original = structuredClone(selected.definition); + const expandSyntax = () => editor.getByRole("button", { name: "Syntax", exact: true }).click(); + await expandSyntax(); + await editor.getByRole("button", { name: "Choose Keyword color", exact: true }).click(); + await expect(page.locator(".react-colorful__alpha")).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(page.locator(".react-colorful__alpha")).toHaveCount(0); + const keyword = editor.getByRole("textbox", { name: "Keyword", exact: true }); + const punctuation = editor.getByRole("textbox", { name: "Punctuation", exact: true }); + await punctuation.scrollIntoViewIfNeeded(); + await expect(punctuation).toBeInViewport(); + const actions = await Promise.all(["Import", "Export", "Cancel", "Save copy", "Save"].map(async name => { + const button = editor.getByRole("button", { name, exact: true }); + await expect(button).toBeInViewport(); + return (await button.boundingBox())!; + })); + const field = (await punctuation.boundingBox())!; + expect(field.y + field.height).toBeLessThanOrEqual(Math.min(...actions.map(action => action.y))); + await keyword.fill("#12345678"); + expect(writes).toEqual([]); + expect(selected.definition).toEqual(original); + await editor.getByRole("button", { name: "Cancel", exact: true }).click(); + await expect(editor).toHaveCount(0); + await page.getByRole("button", { name: "Edit", exact: true }).click(); + const name = editor.getByRole("textbox", { name: "Name", exact: true }); + await name.fill(" "); + await editor.getByRole("button", { name: "Save", exact: true }).click(); + await expect(name).toBeFocused(); + expect(writes).toEqual([]); + await name.fill(original.name); + await expandSyntax(); + await expect(keyword).toHaveValue(original.components!.syntax!.keyword); + await keyword.fill("#bad"); + await editor.getByRole("button", { name: "Save", exact: true }).click(); + await expect(keyword).toBeFocused(); + await expect(keyword).toHaveAttribute("aria-invalid", "true"); + expect(writes).toEqual([]); + await keyword.fill("#12345678"); + await editor.getByRole("button", { name: "Save", exact: true }).click(); + await expect(editor).toHaveCount(0); + expect(writes).toEqual([{ method: "PUT", path: `/api/themes/${selected.id}`, body: { + scope: target, version: 3, + definition: { ...original, components: { ...original.components, syntax: { ...original.components!.syntax!, keyword: "#12345678" } } }, + } }]); + expect(settingsWrites).toEqual([]); + await expect(selectedRadio).toBeChecked(); + }); + } + } + for (const target of ["built-in", "site", "user"] as const) { + for (const isAdmin of [false, true]) { + test(`${isAdmin ? "admin" : "user"} ${target} syntax permissions and copy at ${width}px`, async ({ page }) => { + const { editor, selected, selectedRadio, writes, settingsWrites } = await openEditor(page, width, "light", target, isAdmin); + const original = target === "built-in" ? editableDefinition(builtInThemes[0]!) : structuredClone(selected.definition); + const writable = target === "user" || (target === "site" && isAdmin); + if (writable) await expect(editor.getByRole("button", { name: "Save", exact: true })).toBeEnabled(); + else await expect(editor.getByRole("button", { name: "Save", exact: true })).toBeDisabled(); + await editor.getByRole("button", { name: "Syntax", exact: true }).click(); + const keyword = editor.getByRole("textbox", { name: "Keyword", exact: true }); + await keyword.fill("#bad"); + await editor.getByRole("button", { name: "Core", exact: true }).click(); + await editor.getByRole("button", { name: "Save copy", exact: true }).click(); + await expect(page.getByRole("menuitem", { name: "Your themes", exact: true })).toBeVisible(); + if (isAdmin) await expect(page.getByRole("menuitem", { name: "Site themes", exact: true })).toBeVisible(); + else await expect(page.getByRole("menuitem", { name: "Site themes", exact: true })).toHaveCount(0); + const destination = isAdmin ? "site" : "user"; + await page.getByRole("menuitem", { name: isAdmin ? "Site themes" : "Your themes", exact: true }).click(); + await expect(keyword).toBeFocused(); + await expect(keyword).toHaveAttribute("aria-invalid", "true"); + await expect(editor.getByRole("button", { name: "Syntax", exact: true })).toHaveAttribute("aria-expanded", "true"); + expect(writes).toEqual([]); + await keyword.fill("#12345678"); + await editor.getByRole("button", { name: "Save copy", exact: true }).click(); + await page.keyboard.press("Escape"); + await expect(editor.getByRole("button", { name: "Save copy", exact: true })).toBeFocused(); + await expect(page.getByRole("menuitem", { name: "Your themes", exact: true })).toHaveCount(0); + await expect(editor).toBeVisible(); + await editor.getByRole("button", { name: "Save copy", exact: true }).click(); + await page.getByRole("menuitem", { name: isAdmin ? "Site themes" : "Your themes", exact: true }).click(); + await expect(editor).toHaveCount(0); + const copiedName = target === destination ? `${original.name} (copy)` : original.name; + expect(writes).toEqual([{ method: "POST", path: "/api/themes", body: { + scope: destination, + definition: { ...original, name: copiedName, components: { ...original.components, syntax: { ...original.components!.syntax!, keyword: "#12345678" } } }, + } }]); + expect(settingsWrites).toEqual([]); + await expect(selectedRadio).toBeChecked(); + expect(selected.definition.components!.syntax).toEqual(original.components!.syntax); + }); + } + } + for (const status of [403, 409]) { + test(`${status} save rejection preserves syntax draft at ${width}px`, async ({ page }) => { + const { editor, selected, writes, settingsWrites, rejectNextWrite } = await openEditor(page, width, "dark", status === 409 ? "site" : "user", status === 409); + const original = structuredClone(selected); + const detail = status === 409 ? "Theme changed in another tab. Refresh and try again." : "Theme permission changed. Refresh to check your access."; + rejectNextWrite(status, detail); + await editor.getByRole("button", { name: "Syntax", exact: true }).click(); + const keyword = editor.getByRole("textbox", { name: "Keyword", exact: true }); + await keyword.fill("#abcdef80"); + await editor.getByRole("button", { name: "Save", exact: true }).click(); + await expect(editor.getByRole("alert")).toHaveText(detail); + await expect(keyword).toHaveValue("#ABCDEF80"); + await expect(editor.getByRole("button", { name: "Save", exact: true })).toBeEnabled(); + expect(selected).toEqual(original); + expect(writes).toEqual([{ method: "PUT", path: `/api/themes/${selected.id}`, body: { + scope: original.scope, version: original.version, + definition: { ...original.definition, components: { ...original.definition.components, syntax: { ...original.definition.components!.syntax!, keyword: "#ABCDEF80" } } }, + } }]); + await editor.getByRole("button", { name: "Cancel", exact: true }).click(); + await expect(editor).toHaveCount(0); + expect(writes).toHaveLength(1); + expect(settingsWrites).toEqual([]); + }); + } + } +}); + +interface CiWorkerProbe { + jobs: { kind: string; terminated: boolean; messages: string[] }[]; + errors: string[]; + held: boolean; + release: () => void; +} + +async function installCiWorkerProbe(page: Page, holdNormalization = false): Promise { + await page.addInitScript((holdNext) => { + const probe: CiWorkerProbe = { jobs: [], errors: [], held: false, release: () => {} }; + Object.assign(window, { ciWorkerProbe: probe }); + const NativeWorker = window.Worker; + window.Worker = class extends NativeWorker { + private job: CiWorkerProbe["jobs"][number]; + constructor(url: string | URL, options?: WorkerOptions) { + super(url, options); + const path = String(url); + this.job = { kind: path.includes("markdownCodePreparation.worker") ? "normalization" : path.includes("markdownSyntax.worker") ? "syntax" : path.includes("sourceRegexSearch.worker") ? "regex" : "other", terminated: false, messages: [] }; + probe.jobs.push(this.job); + this.addEventListener("error", event => probe.errors.push(event.message)); + this.addEventListener("messageerror", () => probe.errors.push("Worker message could not be decoded")); + this.addEventListener("message", event => { + this.job.messages.push(event.data?.type); + if (event.data?.type === "error") probe.errors.push(event.data.message); + if (holdNext && this.job.kind === "normalization" && event.data?.type === "complete") { + event.stopImmediatePropagation(); + holdNext = false; + probe.held = true; + probe.release = () => { + probe.held = false; + this.dispatchEvent(new MessageEvent("message", { data: event.data })); + }; + } + }); + } + terminate() { + this.job.terminated = true; + super.terminate(); + } + }; + }, holdNormalization); +} + +const readCiProbe = (page: Page) => page.evaluate(() => { + const probe = (window as typeof window & { ciWorkerProbe: CiWorkerProbe }).ciWorkerProbe; + return { jobs: probe.jobs, errors: probe.errors, held: probe.held }; +}); + +test.describe("production CI correctness", () => { + test.skip(process.env.SAMBEE_E2E_PRODUCTION !== "1", "Requires a normal production build"); + const markdown = "# CI preview\n\n[CI tail](#ci-tail)\n\nA & B and 🚀\n\n```javascript\nconst alpha = 1;\n```\n\n## CI tail\n\nLast paragraph.\n"; + + for (const width of [1543, 390]) { + for (const scenario of ["real workers", "native contracts", "edit and save", "cancel and reopen"] as const) { + test(`CI ${scenario} at ${width}px`, async ({ browser }) => { + const original = scenario === "edit and save" ? "one=1 two=2\n" : markdown; + const proxy = await createCacheableViewerProxy({ initialMarkdown: original, existingFilePaths: [DEMO_PATH], wordWrapEnabled: false }); + let context: BrowserContext | undefined; + try { + context = await browser.newContext({ viewport: { width, height: 900 }, serviceWorkers: "block", proxy: { server: proxy.url, bypass: "<-loopback>" } }); + const page = await context.newPage(); + const errors: string[] = []; + page.on("pageerror", error => errors.push(error.message)); + page.on("response", response => { if (new URL(response.url()).origin === CACHE_PROOF_ORIGIN && response.status() >= 400) errors.push(`${response.status()} ${response.url()}`); }); + await installCiWorkerProbe(page, scenario === "cancel and reopen"); + await page.goto("/browse/smb/demo"); + await page.getByRole("button", { name: "Later", exact: true }).click(); + const file = page.getByRole("button", { name: DEMO_PATH, exact: true }).or(page.getByRole("button", { name: `File: ${DEMO_PATH}`, exact: true })); + await file.click(); + const root = page.locator('[data-markdown-search-root="true"]'); + + if (scenario === "cancel and reopen") { + await expect.poll(async () => (await readCiProbe(page)).held).toBe(true); + await page.getByRole("button", { name: "Close", exact: true }).click(); + await expect.poll(async () => (await readCiProbe(page)).jobs.every(job => job.terminated)).toBe(true); + await page.evaluate(() => (window as typeof window & { ciWorkerProbe: CiWorkerProbe }).ciWorkerProbe.release()); + await expect(root).toHaveCount(0); + await file.click(); + } + + await expect(root).toBeVisible(); + if (scenario !== "edit and save") { + await expect(root.locator("p").nth(1)).toHaveText("A & B and \u{1F680}"); + await expect(root.locator("pre code")).toHaveText("const alpha = 1;\n"); + await expect.poll(async () => (await readCiProbe(page)).jobs.some(job => job.kind === "syntax" && job.messages.includes("ranges") && job.messages.includes("complete"))).toBe(true); + expect((await readCiProbe(page)).jobs.some(job => job.kind === "normalization" && job.messages.includes("normalized-source") && job.messages.includes("tree-chunk") && job.messages.includes("complete"))).toBe(true); + } + + if (scenario === "native contracts") { + const viewport = root.locator(".."); + await expect(viewport).toBeFocused(); + await page.keyboard.press("Tab"); + await expect(root.getByRole("link", { name: "CI tail", exact: true })).toBeFocused(); + await page.keyboard.press("Enter"); + await expect(root.getByRole("heading", { name: "CI tail", exact: true })).toHaveAttribute("id", "sambee-markdown-ci-tail"); + await viewport.focus(); + await page.keyboard.press("Control+a"); + const selected = await page.evaluate(() => window.getSelection()?.toString() ?? ""); + expect(selected).toContain("const alpha = 1;"); + expect(selected).toContain("Last paragraph."); + expect(await root.ariaSnapshot()).toContain('heading "CI preview" [level=1]'); + await page.getByRole("button", { name: "Search", exact: true }).click(); + await page.getByPlaceholder("Search", { exact: true }).fill("alpha"); + await expect(page.getByText("1 / 1", { exact: true })).toBeVisible(); + } + + if (scenario === "edit and save") { + await enterMarkdownEditMode(page); + const editor = page.getByRole("textbox", { name: "Markdown editor" }); + await editor.click(); + await page.keyboard.press("Control+h"); + const controls = page.getByRole("search"); + await controls.getByRole("button", { name: "Use regular expression", exact: true }).click(); + await controls.getByRole("textbox", { name: "Find", exact: true }).fill("(one|two)=(\\d)"); + await controls.getByRole("textbox", { name: "Replace", exact: true }).fill("$2:$1"); + await expect(controls.getByText("0 / 2", { exact: true })).toBeVisible(); + await page.mouse.move(0, 0); + await expect(page.getByRole("tooltip")).toHaveCount(0); + await controls.getByRole("button", { name: "Replace all", exact: true }).click(); + await expect(editor).toContainText("1:one 2:two"); + await controls.getByRole("button", { name: "Close", exact: true }).click(); + await editor.focus(); + await page.keyboard.press("Control+z"); + await expect(editor).toContainText("one=1 two=2"); + await page.keyboard.press("Control+Shift+Z"); + await expect(editor).toContainText("1:one 2:two"); + await page.getByRole("button", { name: "Save", exact: true }).click(); + await expect.poll(() => proxy.uploads.length).toBe(1); + const upload = proxy.uploads[0]; + const form = await new Response(upload.body, { headers: { "Content-Type": upload.contentType } }).formData(); + const files = [...form.values()].filter((value): value is File => typeof value !== "string"); + expect(files).toHaveLength(1); + expect(await files[0].text()).toBe("1:one 2:two\n"); + expect((await readCiProbe(page)).jobs.some(job => job.kind === "regex" && job.messages.includes("complete"))).toBe(true); + await page.getByRole("button", { name: "Close", exact: true }).click(); + await expect(editor).toHaveCount(0); + } + + await page.getByRole("button", { name: "Close", exact: true }).click(); + await expect(root).toHaveCount(0); + await expect.poll(async () => (await readCiProbe(page)).jobs.every(job => job.terminated)).toBe(true); + expect((await readCiProbe(page)).errors).toEqual([]); + expect(errors).toEqual([]); + expect(proxy.errors).toEqual([]); + } finally { + try { + await context?.close(); + } finally { + await proxy.close(); + } + } + }); + } + } +}); + +test.describe("development Markdown worker", () => { + test.skip(process.env.SAMBEE_E2E_DEVELOPMENT !== "1", "Requires the development server and approved demo route"); + test("normalizes and renders entity text without DOM globals in the worker", async ({ page }) => { + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + await page.addLocatorHandler(page.getByRole("dialog", { name: "Update available" }), async (dialog) => { + await dialog.getByRole("button", { name: "Later", exact: true }).click(); + }); + await mockMarkdownViewerApi(page, { initialMarkdown: "# Preview\n\nA & B and 🚀\n\n```js\nconst alpha = 1;\n```", existingFilePaths: [DEMO_PATH] }); + await page.goto("/browse/smb/demo"); + await page.getByRole("button").filter({ has: page.getByText(DEMO_PATH, { exact: true }) }).click(); + const root = page.locator('[data-markdown-search-root="true"]'); + await expect(root.getByRole("heading", { name: "Preview" })).toBeVisible(); + await expect(root.locator("p")).toHaveText("A & B and 🚀"); + await expect(root.locator("pre code")).toHaveText("const alpha = 1;\n"); + await expect(page.getByRole("alert").filter({ hasText: "Server is busy" })).toHaveCount(0); + expect(errors).toEqual([]); + }); +}); + +test.describe("production source regex containment", () => { + test.skip(process.env.SAMBEE_E2E_PRODUCTION !== "1", "Requires the production build and approved demo route"); + for (const width of [1543, 390]) { + test(`native commands and stalled workers at ${width}px`, async ({ page }, testInfo) => { + test.setTimeout(60_000); + await page.setViewportSize({ width, height: 900 }); + await serveProductionAssets(page); + await page.addInitScript(() => { + const OriginalWorker = window.Worker; + const state = { stall: "", observed: "", live: 0, requests: [] as string[], created: 0, sources: 0, navigations: 0, holdNavigation: false, held: false, release: null as (() => void) | null }; + Object.assign(window, { sourceRegexProbe: state }); + window.Worker = class extends OriginalWorker { + private owned = false; + constructor(url: string | URL, options?: WorkerOptions) { + super(url, options); + this.owned = String(url).includes("sourceRegexSearch.worker"); + if (this.owned) { + state.live += 1; + state.created += 1; + this.addEventListener("message", (event) => { + if (!state.holdNavigation || event.data.type !== "complete") return; + event.stopImmediatePropagation(); + state.holdNavigation = false; + state.held = true; + state.release = () => { + state.held = false; + state.release = null; + this.dispatchEvent(new MessageEvent("message", { data: event.data })); + }; + }); + } + } + postMessage(message: unknown, transfer?: Transferable[]) { + const request = message as { type?: string; operation?: string; query?: { search: string } }; + if (this.owned && request.type === "source") state.sources += 1; + if (this.owned && request.type === "navigate") state.navigations += 1; + if (this.owned && request.type === "start") { + state.requests.push(request.operation!); + if (request.operation === state.stall) { + state.observed = state.stall; + state.stall = ""; + request.query!.search = "(a+)+$"; + } + } + super.postMessage(message, transfer ?? []); + } + terminate() { + if (this.owned) { state.live -= 1; this.owned = false; } + super.terminate(); + } + }; + }); + const original = `one=1 two=2\n\n${"a".repeat(128)}!`; + await mockMarkdownViewerApi(page, { initialMarkdown: original, existingFilePaths: [DEMO_PATH] }); + await page.goto("/browse/smb/demo"); + await page.getByRole("button", { name: "Later", exact: true }).click(); + await (width < 600 ? page.getByRole("button", { name: `File: ${DEMO_PATH}`, exact: true }) : page.getByRole("button").filter({ hasText: /^note\.md$/ })).click(); + await page.getByRole("button", { name: "Edit", exact: true }).click(); + const editor = page.getByRole("textbox", { name: "Markdown editor" }); + await expect(editor).toHaveAttribute("contenteditable", "true"); + await editor.focus(); + await page.keyboard.press("Control+h"); + const controls = page.getByTestId("code-mirror-find-replace-popover"); + const find = controls.getByRole("textbox", { name: "Find", exact: true }); + const replace = controls.getByRole("textbox", { name: "Replace", exact: true }); + await controls.getByRole("button", { name: "Use regular expression", exact: true }).click(); + await find.fill("(one|two)=(\\d)"); + await expect(controls.getByText("0 / 2", { exact: true })).toBeVisible(); + await controls.getByRole("button", { name: "Next match", exact: true }).click(); + await expect(controls.getByText("1 / 2", { exact: true })).toBeVisible(); + await controls.getByRole("button", { name: "Next match", exact: true }).click(); + await expect(controls.getByText("2 / 2", { exact: true })).toBeVisible(); + await controls.getByRole("button", { name: "Previous match", exact: true }).click(); + await expect(controls.getByText("1 / 2", { exact: true })).toBeVisible(); + const beforeBurst = await page.evaluate(() => { + const state = (window as unknown as { sourceRegexProbe: { holdNavigation: boolean; created: number; sources: number; navigations: number } }).sourceRegexProbe; + state.holdNavigation = true; + return { created: state.created, sources: state.sources, navigations: state.navigations }; + }); + await controls.getByRole("button", { name: "Next match", exact: true }).click(); + await expect.poll(() => page.evaluate(() => (window as unknown as { sourceRegexProbe: { held: boolean } }).sourceRegexProbe.held)).toBe(true); + await controls.getByRole("button", { name: "Next match", exact: true }).click(); + await controls.getByRole("button", { name: "Previous match", exact: true }).click(); + await controls.getByRole("button", { name: "Next match", exact: true }).click(); + await page.evaluate(() => (window as unknown as { sourceRegexProbe: { release: (() => void) | null } }).sourceRegexProbe.release!()); + await expect.poll(() => page.evaluate(() => { + const { live, created, sources, navigations } = (window as unknown as { sourceRegexProbe: { live: number; created: number; sources: number; navigations: number } }).sourceRegexProbe; + return { live, created, sources, navigations }; + })).toEqual({ live: 0, created: beforeBurst.created + 1, sources: beforeBurst.sources + 1, navigations: beforeBurst.navigations + 3 }); + await expect(controls.getByText("1 / 2", { exact: true })).toBeVisible(); + await replace.fill("$2:$1"); + await expect(controls.getByText("1 / 2", { exact: true })).toBeVisible(); + await controls.getByRole("button", { name: "Replace current match", exact: true }).click(); + await expect(editor).toContainText("1:one two=2"); + await expect(controls.getByText("1 / 1", { exact: true })).toBeVisible(); + await controls.getByRole("button", { name: "Replace all", exact: true }).click(); + await expect(editor).toContainText("1:one 2:two"); + await editor.focus(); + await page.keyboard.press("Control+z"); + await expect(editor).toContainText("1:one two=2"); + await find.fill("a+"); + await expect(controls.getByText(/^[01] \/ 1$/)).toBeVisible(); + const measurements: { operation: string; keyboardMs: number }[] = []; + for (const operation of ["count", "next", "previous", "replace-one", "replace-all"]) { + await page.mouse.move(0, 0); + await expect(page.getByRole("tooltip")).toHaveCount(0); + await page.evaluate((operation) => { + const state = (window as unknown as { sourceRegexProbe: { stall: string; observed: string } }).sourceRegexProbe; + state.stall = operation; state.observed = ""; + }, operation); + if (operation === "count") await find.fill("a{1,}"); + else await controls.getByRole("button", { name: { next: "Next match", previous: "Previous match", "replace-one": "Replace current match", "replace-all": "Replace all" }[operation]!, exact: true }).click(); + await expect.poll(() => page.evaluate(() => (window as unknown as { sourceRegexProbe: { observed: string } }).sourceRegexProbe.observed)).toBe(operation); + await find.focus(); + const before = await find.evaluate((element: HTMLInputElement) => { element.setSelectionRange(element.value.length, element.value.length); return element.selectionStart; }); + const start = Date.now(); + await find.press("ArrowLeft"); + expect(await find.evaluate((element: HTMLInputElement) => element.selectionStart)).toBe(before! - 1); + measurements.push({ operation, keyboardMs: Date.now() - start }); + await expect(controls.getByText("Loading...", { exact: true })).toBeVisible(); + await expect(controls.getByRole("alert")).toContainText("timed out"); + await expect(editor).toContainText("1:one two=2"); + await find.fill("a"); + await expect(controls.getByText(/^[0-9]+ \/ 128$/)).toBeVisible(); + await find.fill("a+"); + await expect(controls.getByText(/^[01] \/ 1$/)).toBeVisible(); + } + await find.fill("(a+)+$"); + await expect(controls.getByText("Loading...", { exact: true })).toBeVisible(); + await find.press("ArrowLeft"); + await expect(controls.getByRole("alert")).toContainText("timed out"); + await find.fill("a+"); + await expect(controls.getByText(/^[01] \/ 1$/)).toBeVisible(); + await expect.poll(() => page.evaluate(() => (window as unknown as { sourceRegexProbe: { live: number } }).sourceRegexProbe.live)).toBe(0); + const commit = await page.evaluate(() => performance.getEntriesByName("sambee:source-search:commit").map((entry) => ({ duration: entry.duration, detail: (entry as PerformanceMeasure).detail }))); + await testInfo.attach("source-regex-containment.json", { body: JSON.stringify({ width, measurements, commit, note: "Real viewer worker termination/restart and keyboard servicing; small-document commit samples, not large replace-all or hardware input-to-paint acceptance." }, null, 2), contentType: "application/json" }); + }); + } +}); + +test("Markdown viewer API fixtures preserve transfer-stream request handling", async ({ page }) => { + let receivedBody: string | undefined; + await mockMarkdownViewerApi(page, { + initialMarkdown: "hello", + onTransferStream: async route => { + const request = route.request(); + expect(await request.headerValue("content-type")).toBe("application/octet-stream"); + receivedBody = request.postDataBuffer()?.toString("utf8"); + await fulfillJson(route, { status: "completed" }); + }, + }); + await page.goto("http://localhost:3000/browse/smb/demo"); + const result = await page.evaluate(async connectionId => { + const response = await fetch(`/api/browse/${connectionId}/transfer-stream?path=fixture.txt`, { + method: "POST", headers: { "Content-Type": "application/octet-stream" }, body: "fixture body", + }); + return { status: response.status, body: await response.json() }; + }, DEMO_CONNECTION_ID); + expect(receivedBody).toBe("fixture body"); + expect(result).toEqual({ status: 200, body: { status: "completed" } }); +}); + +test.describe("production syntax readiness", () => { + test.skip(process.env.SAMBEE_E2E_PRODUCTION !== "1", "Requires the production build and approved demo route"); + test.beforeAll(() => { + if (process.env.SAMBEE_E2E_MEMORY === "1" || process.env.SAMBEE_E2E_TRACE === "1") throw new Error("Readiness acceptance must run without forced GC or diagnostic tracing."); + }); + for (const width of [1543, 390]) { + for (const fixture of SYNTAX_PUBLICATION_FIXTURES) { + test(`unheld ${fixture.name} readiness at ${width}px`, async ({ page: defaultPage, browser }, testInfo) => { + const longLine = fixture.name === "long-line"; + const line = longLine ? "let x=1; " : "let x=1;\n"; + const text = `${line.repeat(fixture.lines)}${longLine ? "\n" : ""}`; + const markdown = Array.from({ length: fixture.blocks }, () => `\`\`\`js\n${text}\`\`\``).join("\n\n"); + const cacheProxy = process.env.SAMBEE_E2E_HTTP_CACHE === "1" ? await createCacheableViewerProxy({ initialMarkdown: markdown }) : undefined; + let cacheContext: BrowserContext | undefined; + try { + cacheContext = cacheProxy ? await browser.newContext({ proxy: { server: cacheProxy.url, bypass: "<-loopback>" }, serviceWorkers: "block" }) : undefined; + const page = cacheContext ? await cacheContext.newPage() : defaultPage; + await page.setViewportSize({ width, height: 900 }); + if (!cacheProxy) await serveProductionAssets(page); + await page.addInitScript(({ line, text: expectedText, lines, blocks, longLine, filename }) => { + const metrics: Record = {}; + const outcomes: { block: number; reason?: string }[] = []; + const syntaxWorkerUrls: string[] = []; + let openedAt: number | undefined; + let firstBlockPreparedAt: number | undefined; + let preparedAt: number | undefined; + let expected: { from: number; to: number; category: string }[] | undefined; + const scheduled = new Set(); + const afterFrames = (metric: string) => { + if (scheduled.has(metric)) return; + scheduled.add(metric); + requestAnimationFrame(() => requestAnimationFrame(() => { + metrics[metric] = performance.now() - openedAt!; + })); + }; + document.addEventListener("click", (event) => { + const button = event.target instanceof Element ? event.target.closest("button") : null; + if (!(button?.getAttribute("aria-label") === `File: ${filename}` || button?.textContent?.trim() === filename)) return; + for (const key of Object.keys(metrics)) delete metrics[key]; + outcomes.length = 0; + scheduled.clear(); + expected = undefined; + firstBlockPreparedAt = undefined; + preparedAt = undefined; + openedAt = performance.now(); + const inspect = () => { + const root = document.querySelector('[data-markdown-search-root="true"]'); + const codes = root?.querySelectorAll("pre > code"); + const code = codes?.[0]; + const search = document.querySelector('button[aria-label="Search"]'); + if (codes?.length === blocks && [...codes].every(element => element.textContent === expectedText) && search && !search.disabled) { + afterFrames("firstUsableText"); + if (!expected) { + expected = []; + const bounds = root!.parentElement!.getBoundingClientRect(); + const preBounds = code.closest("pre")!.getBoundingClientRect(); + const left = Math.max(bounds.left, preBounds.left); + const right = Math.min(bounds.right, preBounds.right); + const node = code.firstChild!; + const tokens = [[0, 3, "keyword"], [4, 5, "identifier"], [5, 6, "operator"], [6, 7, "number"], [7, 8, "punctuation"]] as const; + for (let index = 0; index < lines; index += 1) { + const from = index * line.length; + const range = new Range(); + range.setStart(node, from); + range.setEnd(node, from + line.length - 1); + const rect = range.getBoundingClientRect(); + if (rect.top >= bounds.bottom) break; + if (longLine && rect.left >= right) break; + if (rect.top < bounds.top || rect.bottom > bounds.bottom) continue; + for (const [start, end, category] of tokens) { + if (!longLine) { + expected.push({ from: from + start, to: from + end, category }); + continue; + } + for (let offset = from + start; offset < from + end; offset += 1) { + range.setStart(node, offset); + range.setEnd(node, offset + 1); + const character = range.getBoundingClientRect(); + if (character.left >= left && character.right <= right) expected.push({ from: offset, to: offset + 1, category }); + } + } + } + } + const published = new Map(); + for (const [name, ranges] of CSS.highlights) { + const match = /^markdown-syntax-\d+-(.+)$/.exec(name); + if (match) published.set(match[1], [...ranges] as Range[]); + } + const completeWindow = expected.length > 0 && expected.every(token => (published.get(token.category) ?? []).some(range => + range.startContainer === code.firstChild && range.endContainer === code.firstChild && range.startOffset <= token.from && range.endOffset >= token.to)); + if (completeWindow) { + afterFrames("firstVisibleSyntax"); + if (outcomes.length === blocks) afterFrames("fullSyntaxPreparation"); + } + } + if (Object.keys(metrics).length < 3) requestAnimationFrame(inspect); + }; + requestAnimationFrame(inspect); + }, true); + const OriginalWorker = window.Worker; + window.Worker = class extends OriginalWorker { + constructor(url: string | URL, options?: WorkerOptions) { + super(url, options); + if (String(url).includes("markdownSyntax.worker")) { + syntaxWorkerUrls.push(String(url)); + this.addEventListener("message", (event) => { + const response = event.data as { type?: string; block: number; reason?: string }; + if (response.type === "complete") { + outcomes.push({ block: response.block, reason: response.reason }); + firstBlockPreparedAt ??= performance.now(); + preparedAt = performance.now(); + } + }); + } + } + }; + Object.assign(window, { syntaxReadiness: () => ({ metrics, outcomes, syntaxWorkerUrls: [...syntaxWorkerUrls], expectedVisibleTokens: expected?.length ?? 0, workerFirstBlockMs: firstBlockPreparedAt === undefined || openedAt === undefined ? null : firstBlockPreparedAt - openedAt, workerResultsMs: preparedAt === undefined || openedAt === undefined ? null : preparedAt - openedAt }) }); + }, { line, text, lines: fixture.lines, blocks: fixture.blocks, longLine, filename: DEMO_PATH }); + if (!cacheProxy) await mockMarkdownViewerApi(page, { initialMarkdown: markdown }); + await page.goto("http://localhost:3000/browse/smb/demo"); + await page.getByRole("button", { name: "Later", exact: true }).click(); + let coldSyntaxRequests: string[] = []; + for (const cycle of cacheProxy ? [0, 1] : [0]) { + const beforeOpen = cacheProxy?.requests.length ?? 0; + await (width < 600 ? page.getByRole("button", { name: `File: ${DEMO_PATH}`, exact: true }) : page.getByRole("button").filter({ hasText: /^note\.md$/ })).click(); + const sample = () => page.evaluate(() => (window as unknown as { syntaxReadiness: () => { metrics: Record; outcomes: { block: number; reason?: string }[]; syntaxWorkerUrls: string[]; expectedVisibleTokens: number; workerFirstBlockMs: number | null; workerResultsMs: number | null } }).syntaxReadiness()); + await expect.poll(async () => Object.keys((await sample()).metrics).length).toBe(3); + const report = await sample(); + const budget = evaluateViewerReadinessBudgets(report.metrics); + const networkAssets = cacheProxy?.requests.slice(beforeOpen).filter(path => path.endsWith(".js")) ?? []; + if (cacheProxy && cycle === 0) coldSyntaxRequests = networkAssets; + expect(report.expectedVisibleTokens).toBeGreaterThan(0); + expect(report.outcomes).toEqual(Array.from({ length: fixture.blocks }, (_, block) => ({ block, reason: undefined }))); + expect(Object.values(report.metrics).every(value => Number.isFinite(value) && value >= 0)).toBe(true); + const root = page.locator('[data-markdown-search-root="true"]'); + expect(await root.locator("pre > code").evaluateAll((elements, expected) => elements.every(element => element.textContent === expected), text)).toBe(true); + expect(await root.locator("pre > code").first().evaluate(element => { + const range = new Range(); + range.setStart(element.firstChild!, 0); + range.setEnd(element.firstChild!, 8); + window.getSelection()?.removeAllRanges(); + window.getSelection()?.addRange(range); + return window.getSelection()?.toString(); + })).toBe(line.trimEnd()); + await page.getByRole("button", { name: "Search", exact: true }).click(); + await page.getByPlaceholder("Search", { exact: true }).fill("let x"); + await expect(page.getByRole("button", { name: "Next match", exact: true }).locator("..").getByText(`1 / ${fixture.lines * fixture.blocks}`, { exact: true })).toBeVisible(); + await testInfo.attach("syntax-readiness.json", { body: JSON.stringify({ fixture: fixture.name, width, engine: testInfo.project.name, trial: testInfo.repeatEachIndex, surface: "markdown-preview", cacheState: cacheProxy ? cycle === 0 ? "cold" : "warm-http-fresh-workers" : "routed-no-cache", ...report, budget, enforced: true, networkAssets, coldSyntaxRequests, cacheCondition: cacheProxy ? cycle === 0 ? "Fresh context and fresh syntax worker; ordinary HTTP with an initially empty browser cache, no preloading or request routing." : "Same page after close and zero live workers; fresh syntax worker with browser HTTP cache populated only by the measured first open." : "Fresh context and worker; routed assets disable normal HTTP caching. No cold/warm-cache acceptance claim.", scope: "Unheld file-open through exact text/control readiness and initial visible token coverage plus two frames. Functional selection/search follow timing, not hardware input/paint. Approved readiness limits enforced; no resource or full-rollout acceptance." }, null, 2), contentType: "application/json" }); + await page.getByRole("button", { name: "Close", exact: true }).click(); + await expect(root).toHaveCount(0); + await expect.poll(() => page.workers().length).toBe(0); + await expect.poll(() => page.evaluate(() => [...CSS.highlights.keys()].filter(name => name.startsWith("markdown-syntax-")).length)).toBe(0); + expect.soft(budget.status, JSON.stringify({ cacheState: cacheProxy ? cycle === 0 ? "cold" : "warm-http-fresh-workers" : "routed-no-cache", failures: budget.metrics.filter(metric => metric.status !== "pass") })).toBe("pass"); + expect(report.syntaxWorkerUrls).toHaveLength(cycle + 1); + if (cacheProxy) { + expect(cacheProxy.errors).toEqual([]); + if (cycle === 0) { + expect(coldSyntaxRequests).toContain(new URL(report.syntaxWorkerUrls[0]).pathname); + expect(coldSyntaxRequests.length).toBeGreaterThan(1); + } else { + expect(report.syntaxWorkerUrls[1]).toBe(report.syntaxWorkerUrls[0]); + expect(networkAssets.filter(path => coldSyntaxRequests.includes(path))).toEqual([]); + } + } + } + } finally { + try { await cacheContext?.close(); } finally { await cacheProxy?.close(); } + } + }); + } + } +}); + +test.describe("production native Markdown constraints", () => { + test.skip(process.env.SAMBEE_E2E_PRODUCTION !== "1", "Requires the production build and approved demo route"); + const lineCount = 120; + const longLine = `const nativeLongLine = "${"horizontal native content ".repeat(240)}NATIVE_LONG_END";`; + const fixtures = [ + { name: "multiline", code: Array.from({ length: lineCount }, (_, index) => `const nativeLine${index} = ${index};`).join("\n"), fence: "javascript" }, + { name: "long-line", code: longLine, fence: "javascript" }, + { name: "nested", code: "## Nested native heading\n\nNested native paragraph.\n\n```javascript\nconst nestedNative = 42;\n```\n\nNested native tail.", fence: "markdown" }, + ]; + for (const width of [1543, 390]) { + for (const fixture of fixtures) { + test(`${fixture.name} selection, clipboard, focus and output at ${width}px`, async ({ page }, testInfo) => { + await page.setViewportSize({ width, height: 900 }); + await serveProductionAssets(page); + const markdown = `# Native constraints\n\n[Native tail](#native-tail)\n\nNATIVE_SELECTION_START\n\n\`\`\`\`${fixture.fence}\n${fixture.code}\n\`\`\`\`\n\nNATIVE_SELECTION_END\n\n${Array.from({ length: lineCount }, (_, index) => `Native spacer ${index}.\n`).join("\n")}\n## Native tail\n\nNATIVE_FIND_TAIL\n`; + await mockMarkdownViewerApi(page, { initialMarkdown: markdown, wordWrapEnabled: false }); + await page.goto("/browse/smb/demo"); + await page.getByRole("button", { name: "Later", exact: true }).click(); + await (width < 600 ? page.getByRole("button", { name: `File: ${DEMO_PATH}`, exact: true }) : page.getByRole("button").filter({ hasText: /^note\.md$/ })).click(); + const root = page.locator('[data-markdown-search-root="true"]'); + const viewport = root.locator(".."); + const code = root.locator("pre > code"); + await expect(code).toHaveText(`${fixture.code}\n`); + const renderedText = await root.innerText(); + await expect(viewport).toBeFocused(); + await page.keyboard.press("Tab"); + const link = root.getByRole("link", { name: "Native tail", exact: true }); + await expect(link).toBeFocused(); + const previewNode = await root.elementHandle(); + const routeBefore = new URL(page.url()); + await page.keyboard.press("Enter"); + const target = root.getByRole("heading", { name: "Native tail", exact: true }); + await expect(target).toHaveAttribute("id", `${MARKDOWN_HEADING_ID_PREFIX}native-tail`); + await expect(target).toBeInViewport(); + expect(new URL(page.url()).pathname + new URL(page.url()).search).toBe(routeBefore.pathname + routeBefore.search); + expect(await root.evaluate((element, original) => element === original, previewNode)).toBe(true); + await previewNode?.dispose(); + await viewport.focus(); + await page.keyboard.press("Control+Home"); + await page.keyboard.press("Control+a"); + const selectAll = await page.evaluate(() => window.getSelection()?.toString() ?? ""); + expect(selectAll).toContain("NATIVE_SELECTION_START"); + expect(selectAll).toContain(fixture.code); + expect(selectAll).toContain("NATIVE_FIND_TAIL"); + const selected = await root.evaluate(element => { + const paragraphs = [...element.querySelectorAll("p")]; + const start = paragraphs.find(paragraph => paragraph.textContent === "NATIVE_SELECTION_START")!; + const end = paragraphs.find(paragraph => paragraph.textContent === "NATIVE_SELECTION_END")!; + const range = new Range(); + range.setStart(start.firstChild!, 0); + range.setEnd(end.firstChild!, end.textContent!.length); + window.getSelection()?.removeAllRanges(); + window.getSelection()?.addRange(range); + return window.getSelection()?.toString() ?? ""; + }); + expect(selected).toContain(fixture.code); + expect(selected.startsWith("NATIVE_SELECTION_START")).toBe(true); + expect(selected.endsWith("NATIVE_SELECTION_END")).toBe(true); + await page.keyboard.press("Control+c"); + await viewport.evaluate(element => { + const target = document.createElement("textarea"); + target.setAttribute("aria-label", "Native clipboard probe"); + Object.assign(target.style, { position: "fixed", top: "0", left: "0", width: "1px", height: "1px" }); + element.append(target); + target.focus({ preventScroll: true }); + }); + await page.keyboard.press("Control+v"); + const clipboard = page.getByRole("textbox", { name: "Native clipboard probe", exact: true }); + await expect(clipboard).toHaveValue(selected); + await clipboard.evaluate(element => element.remove()); + await viewport.focus(); + await page.keyboard.press("Control+Home"); + await expect(root.getByRole("heading", { name: "Native constraints", exact: true })).toBeInViewport(); + const find = await page.evaluate(() => { + window.getSelection()?.removeAllRanges(); + return (window as Window & { find: (text: string) => boolean }).find("NATIVE_FIND_TAIL"); + }); + expect(find).toBe(true); + expect(await page.evaluate(() => window.getSelection()?.toString())).toBe("NATIVE_FIND_TAIL"); + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))); + const findReveal = await root.evaluate(element => { + const target = [...element.querySelectorAll("p")].find(paragraph => paragraph.textContent === "NATIVE_FIND_TAIL")!; + const bounds = target.getBoundingClientRect(); + const viewport = element.parentElement!.getBoundingClientRect(); + return { target: bounds.toJSON(), viewport: viewport.toJSON(), scrollTop: element.parentElement!.scrollTop, revealed: bounds.top >= viewport.top && bounds.bottom <= viewport.bottom }; + }); + await testInfo.attach("native-find-reveal.json", { body: JSON.stringify(findReveal, null, 2), contentType: "application/json" }); + if (testInfo.project.name === "firefox") { + expect.soft(findReveal.revealed, "Firefox window.find must reveal the complete match inside the content viewport").toBe(true); + await expect.soft(root.getByText("NATIVE_FIND_TAIL", { exact: true })).toBeInViewport(); + } + if (testInfo.project.name === "chromium") { + const screenState = () => root.evaluate(element => ({ + text: element.textContent, + selection: window.getSelection()?.toString(), + top: element.parentElement!.scrollTop, + left: element.querySelector("pre")!.scrollLeft, + })); + const beforePrint = await screenState(); + const pdf = await page.pdf({ printBackground: true }); + await testInfo.attach(`native-${fixture.name}-${width}.pdf`, { body: pdf, contentType: "application/pdf" }); + await expect.poll(screenState).toEqual(beforePrint); + } + await testInfo.attach("native-constraints.json", { body: JSON.stringify({ fixture: fixture.name, width, engine: testInfo.project.name, renderedText, selectAll, selected, headingTargetRevealed: true, findReveal, findRevealRequired: testInfo.project.name === "firefox", clipboard: "Trusted keyboard copy and paste into a temporary textarea; exact payload checked.", find: "window.find exact selection is required in both engines; reveal is required in Firefox and diagnostic-only in Chromium. Actual browser Find remains separate manual evidence.", print: testInfo.project.name === "chromium" ? "Real headless PDF attached; run scripts.check_native_markdown_pdf separately. PDF generation is not a print acceptance pass." : "Firefox print/PDF remains a manual gate." }, null, 2), contentType: "application/json" }); + }); + } + } +}); + +test.describe("production Markdown printing", () => { + test.skip(process.env.SAMBEE_E2E_PRODUCTION !== "1", "Requires the production build and approved demo route"); + const lineCount = 120; + const fixtures = [ + { name: "multiline", code: Array.from({ length: lineCount }, (_, index) => `const printLine${index} = ${index};`).join("\n"), fence: "javascript" }, + { name: "long-line", code: `const printLongLine = "${"printable horizontal content ".repeat(240)}PRINT_LONG_END";`, fence: "javascript" }, + { name: "nested", code: "## Printed nested heading\n\nNested printed paragraph.\n\n```javascript\nconst nestedPrint = 42;\n```\n\nNested printed tail.", fence: "markdown" }, + ]; + for (const width of [1543, 390]) { + for (const mode of ["light", "dark"] as const) { + for (const fixture of fixtures) { + test(`${fixture.name} ${mode} complete output and state at ${width}px`, async ({ page }, testInfo) => { + await page.setViewportSize({ width, height: 900 }); + await serveProductionAssets(page); + const markdown = `# Printed document\n\nPRINT_DOCUMENT_START\n\n![Printed image](/icons/icon-180.png)\n\n\`\`\`\`${fixture.fence}\n${fixture.code}\n\`\`\`\`\n\n| Printed column | Printed value |\n| --- | --- |\n| PRINT_TABLE_START | ${"LongTableCell".repeat(30)} |\n| PRINT_TABLE_END | Complete table |\n\n${Array.from({ length: lineCount }, (_, index) => `Printed paragraph ${index}.\n`).join("\n")}\n## Printed tail\n\nPRINT_DOCUMENT_END\n`; + await mockMarkdownViewerApi(page, { initialMarkdown: markdown, themeId: () => `sambee-${mode}`, wordWrapEnabled: false }); + await page.goto("/browse/smb/demo"); + await page.getByRole("button", { name: "Later", exact: true }).click(); + await (width < 600 ? page.getByRole("button", { name: `File: ${DEMO_PATH}`, exact: true }) : page.getByRole("button").filter({ hasText: /^note\.md$/ })).click(); + const root = page.locator('[data-markdown-search-root="true"]'); + await expect(root.getByRole("heading", { name: "Printed document", exact: true })).toBeVisible(); + await expect(root.locator("pre > code")).toHaveText(`${fixture.code}\n`); + await expect.poll(() => root.getByRole("img", { name: "Printed image", exact: true }).evaluate(element => (element as HTMLImageElement).naturalWidth)).toBeGreaterThan(0); + await page.evaluate(() => document.fonts.ready); + await page.evaluate(() => { + Object.assign(window, { printCalls: 0 }); + window.print = () => { (window as unknown as { printCalls: number }).printCalls += 1; }; + }); + const printButton = page.getByRole("button", { name: "Print", exact: true }); + await expect(printButton).toHaveAttribute("title", "Print / Save as PDF"); + await printButton.click(); + expect(await page.evaluate(() => (window as unknown as { printCalls: number }).printCalls)).toBe(1); + const renderedText = await root.innerText(); + await page.emulateMedia({ media: "print" }); + await expect(root).toHaveCSS("color", "rgb(0, 0, 0)"); + await expect(root).toHaveCSS("background-color", "rgb(255, 255, 255)"); + await expect(root.locator("pre > code")).toHaveCSS("color", "rgb(0, 0, 0)"); + await expect(root.locator("pre > code")).toHaveCSS("white-space", "pre-wrap"); + await expect(root.locator("pre > code")).toHaveCSS("min-width", "0px"); + await expect(root.locator("pre")).toHaveCSS("overflow", "visible"); + await expect(printButton).not.toBeVisible(); + expect(await root.locator("pre, table, img").evaluateAll(elements => elements.every(element => element.getBoundingClientRect().width <= element.closest('[data-markdown-search-root]')!.getBoundingClientRect().width))).toBe(true); + await page.emulateMedia({ media: null }); + await root.getByText("PRINT_DOCUMENT_END", { exact: true }).scrollIntoViewIfNeeded(); + const node = await root.elementHandle(); + await root.locator("pre").evaluate(element => { element.scrollLeft = element.scrollWidth; }); + await root.getByText("PRINT_DOCUMENT_END", { exact: true }).evaluate(element => { + const range = new Range(); + range.selectNodeContents(element); + window.getSelection()?.removeAllRanges(); + window.getSelection()?.addRange(range); + }); + const screenState = () => root.evaluate(element => ({ text: element.textContent, selection: window.getSelection()?.toString(), top: element.parentElement!.scrollTop, left: element.querySelector("pre")!.scrollLeft, url: location.href })); + const beforePrint = await screenState(); + expect(beforePrint.top).toBeGreaterThan(0); + if (testInfo.project.name === "chromium") { + const pdf = await page.pdf({ printBackground: false, format: "A4", margin: { top: "12mm", bottom: "12mm", left: "12mm", right: "12mm" } }); + await testInfo.attach(`print-${fixture.name}-${mode}-${width}.pdf`, { body: pdf, contentType: "application/pdf" }); + } + await expect.poll(screenState).toEqual(beforePrint); + expect(await root.evaluate((element, original) => element === original, node)).toBe(true); + await node?.dispose(); + await testInfo.attach("markdown-print.json", { body: JSON.stringify({ fixture: fixture.name, width, mode, engine: testInfo.project.name, renderedText, requiresImages: true, scope: testInfo.project.name === "chromium" ? "Real browser PDF; inspect text and visual output separately." : "Print CSS and state restoration only; actual Firefox print output remains a manual check." }, null, 2), contentType: "application/json" }); + }); + } + } + } +}); + +test.describe("production syntax publication", () => { + const collectMemory = process.env.SAMBEE_E2E_MEMORY === "1"; + const enforceBudgets = process.env.SAMBEE_E2E_BUDGETS === "1"; + const diagnostic = collectMemory || process.env.SAMBEE_E2E_TRACE === "1"; + test.skip(process.env.SAMBEE_E2E_PRODUCTION !== "1", "Requires the production build and approved demo route"); + test.beforeAll(() => { + if (enforceBudgets && diagnostic) throw new Error("Timing budget acceptance must run without forced GC or diagnostic tracing."); + }); + const fixtures = SYNTAX_PUBLICATION_FIXTURES; + const viewportRangeLimit = 4_000; + + test("native viewer contracts retain semantic text, keyboard selection, links and print content", async ({ page }, testInfo) => { + const markdown = "# Viewer contract\n\nAlpha **bold** and *emphasis*.\n\n[External](https://example.org/contract) [Relative](child.md) [Hash](#tail) [Unsafe](javascript:alert(1))\n\n- [x] Complete\n- [ ] Pending\n\n| Name | Value |\n| --- | --- |\n| Alpha | beta |\n\n```javascript\nconst contract = 1;\nconsole.log(contract);\n```\n\n\n\n## Tail\n\nOmega contract.\n"; + await serveProductionAssets(page); + await mockMarkdownViewerApi(page, { initialMarkdown: markdown, existingFilePaths: [DEMO_PATH] }); + await page.goto("/browse/smb/demo"); + await page.getByRole("button", { name: "Later", exact: true }).click(); + await page.getByRole("button").filter({ hasText: /^note\.md$/ }).click(); + const root = page.locator('[data-markdown-search-root="true"]'); + await expect(root.getByRole("heading", { name: "Viewer contract", exact: true })).toBeVisible(); + await expect(root.getByRole("table")).toHaveCount(1); + await expect(root.getByRole("checkbox", { checked: true })).toBeDisabled(); + await expect(root.getByRole("checkbox", { checked: false })).toBeDisabled(); + await expect(root.locator("pre code")).toHaveText("const contract = 1;\nconsole.log(contract);\n"); + await expect(root.getByRole("link", { name: "External", exact: true })).toHaveAttribute("href", "https://example.org/contract"); + await expect(root.getByRole("link", { name: "Relative", exact: true })).toHaveAttribute("href", "http://localhost:3000/browse/smb/child.md"); + const expectedHeadingId = "sambee-markdown-tail"; + await expect(root.getByRole("heading", { name: "Tail", exact: true })).toHaveAttribute("id", expectedHeadingId); + await expect(root.getByRole("link", { name: "Hash", exact: true })).toHaveAttribute("href", `#${expectedHeadingId}`); + await expect(root.getByText("Unsafe", { exact: true })).not.toHaveAttribute("href"); + await expect(root.locator("script")).toHaveCount(0); + expect(await page.evaluate(() => "unsafePayload" in globalThis)).toBe(false); + const viewport = root.locator(".."); + await expect(viewport).toBeFocused(); + await viewport.focus(); + await page.keyboard.press("Tab"); + await expect(root.getByRole("link", { name: "External", exact: true })).toBeFocused(); + const text = await root.innerText(); + await viewport.focus(); + await page.keyboard.press("Control+a"); + const selectAll = await page.evaluate(() => window.getSelection()?.toString() ?? ""); + expect(selectAll).toContain("Viewer contract"); + expect(selectAll).toContain("Omega contract."); + const selectedCode = await root.evaluate((element) => { + const range = document.createRange(); + range.selectNodeContents(element.querySelector("pre code")!); + window.getSelection()?.removeAllRanges(); + window.getSelection()?.addRange(range); + document.addEventListener("copy", () => { document.documentElement.dataset.contractCopy = window.getSelection()?.toString() ?? ""; }, { once: true }); + return window.getSelection()?.toString() ?? ""; + }); + expect(selectedCode.trimEnd()).toBe("const contract = 1;\nconsole.log(contract);"); + await page.keyboard.press("Control+c"); + await expect.poll(() => page.evaluate(() => document.documentElement.dataset.contractCopy)).toBe(selectedCode); + const selectedNode = await root.locator("pre code").evaluateHandle((element) => element.firstChild); + await root.locator("pre code").evaluate((element) => (element as HTMLElement).style.setProperty("--sambee-syntax-keyword", "rgb(1, 2, 3)")); + await expect.poll(() => root.locator("pre code").evaluate((element) => { + const name = [...CSS.highlights.keys()].find((name) => /^markdown-(?:syntax|retained)-/.test(name) && name.endsWith("-keyword")); + return name ? getComputedStyle(element, `::highlight(${name})`).color : null; + })).toBe("rgb(1, 2, 3)"); + expect(await root.locator("pre code").evaluate((element, node) => element.firstChild === node, selectedNode)).toBe(true); + expect(await page.evaluate(() => window.getSelection()?.toString())).toBe(selectedCode); + await selectedNode.dispose(); + await root.locator("pre code").evaluate((element) => (element as HTMLElement).style.removeProperty("--sambee-syntax-keyword")); + await page.getByRole("button", { name: "Search", exact: true }).click(); + const searchInput = page.getByPlaceholder("Search", { exact: true }); + await searchInput.fill("contract"); + await expect(page.getByText("1 / 4", { exact: true })).toBeVisible(); + await searchInput.press("Enter"); + await expect(page.getByText("2 / 4", { exact: true })).toBeVisible(); + await expect(searchInput).toBeFocused(); + const semantics = await root.ariaSnapshot(); + expect(semantics).toContain('heading "Viewer contract" [level=1]'); + expect(semantics).toContain('heading "Tail" [level=2]'); + expect(semantics).toContain("table:"); + if (testInfo.project.name === "chromium") { + await page.emulateMedia({ media: "print" }); + try { + await expect(root.getByRole("heading", { name: "Tail", exact: true })).toBeVisible(); + expect(await root.innerText()).toBe(text); + const pdf = await page.pdf(); + expect(pdf.byteLength).toBeGreaterThan(1_000); + await testInfo.attach("viewer-contract.pdf", { body: pdf, contentType: "application/pdf" }); + } finally { + await page.emulateMedia({ media: null }); + } + } + await testInfo.attach("native-viewer-contracts.json", { body: JSON.stringify({ renderer: "react-markdown", text, selectAll, selectedCode, semantics, clipboardScope: "Trusted native copy event and exact selected text; OS clipboard payload and assistive-technology behavior remain manual gates." }, null, 2), contentType: "application/json" }); + await page.getByRole("button", { name: "Close", exact: true }).click(); + await expect(root).toHaveCount(0); + }); + + for (const width of [1543, 390]) { + for (const fixture of fixtures) { + test(`${fixture.name} at ${width}px preserves active search and selection`, async ({ page }, testInfo) => { + test.setTimeout(90_000); + const phases: { name: string; startTime: number; duration: number }[] = []; + const phase = async (name: string, action: () => Promise) => { + const startTime = await page.evaluate(() => performance.now()); + await action(); + const endTime = await page.evaluate(() => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(() => done(performance.now()))))); + phases.push({ name, startTime, duration: endTime - startTime }); + }; + await page.setViewportSize({ width, height: 900 }); + await serveProductionAssets(page); + await page.addInitScript(({ expectedBlocks, expectedMatches, filename }) => { + const OriginalWorker = window.Worker; + const pending: (() => void)[] = []; + let held = true; + let liveWorkers = 0; + const longTasks: { startTime: number; duration: number }[] = []; + const taskGaps: { startTime: number; duration: number }[] = []; + const searchStages: { name: string; startTime: number; duration: number }[] = []; + const syntaxStages: { name: string; startTime: number; duration: number }[] = []; + const preparationStages: { name: string; startTime: number; duration: number }[] = []; + const workerStages: { name: string; block: number; startTime: number; duration: number }[] = []; + const navigationFrames: { action: string; startTime: number; duration: number; firstFrameMs: number }[] = []; + const renderMilestones: { name: string; time: number }[] = []; + const timingSamples: ViewerTimingSamples = {}; + let openedAt: number | undefined; + let navigationClicks = 0; + const measureExactResult = (metric: "firstExactSearch" | "changedQuery" | "lastMatchNavigation", query: string, ordinal: number) => { + const started = performance.now(); + const inspect = () => { + const next = document.querySelector('button[aria-label="Next match"]'); + const count = next?.parentElement?.textContent?.replace(/\s/g, "") ?? ""; + const current = [...CSS.highlights.entries()].find(([name]) => name.startsWith("markdown-search-") && name.endsWith("-current")); + const active = current ? [...current[1]][0] as Range | undefined : undefined; + if (count.includes(`${ordinal}/${expectedMatches}`) && active?.toString() === query) { + requestAnimationFrame(() => requestAnimationFrame(() => { + timingSamples[metric] = performance.now() - started; + })); + } else requestAnimationFrame(inspect); + }; + requestAnimationFrame(inspect); + }; + new PerformanceObserver((entries) => { + for (const entry of entries.getEntries()) { + const resource = entry as PerformanceResourceTiming; + if (/\/api\/viewer\/[^/]+\/file$/.test(new URL(resource.name).pathname)) { + renderMilestones.push({ name: "source-response-end", time: resource.responseEnd }); + } + } + }).observe({ type: "resource", buffered: true }); + const initialCommit = new MutationObserver(() => { + const root = document.querySelector('[data-markdown-search-root="true"]'); + if (root?.querySelectorAll("pre > code").length !== expectedBlocks) return; + initialCommit.disconnect(); + renderMilestones.push({ name: "dom-commit-observed", time: performance.now() }); + requestAnimationFrame(() => { + renderMilestones.push({ name: "first-frame", time: performance.now() }); + requestAnimationFrame(() => { + const completed = performance.now(); + renderMilestones.push({ name: "second-frame", time: completed }); + if (openedAt !== undefined) timingSamples.documentCompletion = completed - openedAt; + }); + }); + }); + initialCommit.observe(document, { childList: true, subtree: true }); + const keyboardFrames: { event: string; startTime: number; duration: number; firstFrameMs: number; eventAgeMs: number; value: string }[] = []; + new PerformanceObserver((entries) => { + for (const entry of entries.getEntries()) { + if (entry.name.startsWith("sambee:preview-search:")) searchStages.push({ name: entry.name, startTime: entry.startTime, duration: entry.duration }); + if (entry.name.startsWith("sambee:preview-syntax:")) syntaxStages.push({ name: entry.name, startTime: entry.startTime, duration: entry.duration }); + if (entry.name.startsWith("sambee:preview-preparation:")) preparationStages.push({ name: entry.name, startTime: entry.startTime, duration: entry.duration }); + } + }).observe({ type: "measure", buffered: true }); + document.addEventListener("click", (event) => { + const button = event.target instanceof Element ? event.target.closest("button") : null; + const action = button?.getAttribute("aria-label"); + if (openedAt === undefined && (action === `File: ${filename}` || button?.textContent?.trim() === filename)) openedAt = performance.now(); + if (action !== "Next match" && action !== "Previous match") return; + navigationClicks += 1; + if (navigationClicks === 3) measureExactResult("lastMatchNavigation", "let x", expectedMatches); + const startTime = performance.now(); + requestAnimationFrame(() => { + const firstFrameMs = performance.now() - startTime; + requestAnimationFrame(() => navigationFrames.push({ action, startTime, firstFrameMs, duration: performance.now() - startTime })); + }); + }, true); + for (const eventName of ["keydown", "input"]) { + document.addEventListener(eventName, (event) => { + const target = event.target; + if (!(target instanceof HTMLInputElement) || target.placeholder !== "Search") return; + const startTime = performance.now(); + const eventAgeMs = Math.max(0, startTime - event.timeStamp); + const value = target.value; + if (eventName === "input" && value === "l") measureExactResult("firstExactSearch", value, 1); + if (eventName === "input" && value === "x=1") measureExactResult("changedQuery", value, 1); + requestAnimationFrame(() => { + const firstFrameMs = performance.now() - startTime; + requestAnimationFrame(() => keyboardFrames.push({ event: eventName, startTime, firstFrameMs, eventAgeMs, duration: performance.now() - startTime, value })); + }); + }, true); + } + const longTasksSupported = PerformanceObserver.supportedEntryTypes.includes("longtask"); + if (longTasksSupported) { + new PerformanceObserver((entries) => { + for (const entry of entries.getEntries()) longTasks.push({ startTime: entry.startTime, duration: entry.duration }); + }).observe({ type: "longtask", buffered: true }); + } + let lastTask = performance.now(); + setInterval(() => { + const now = performance.now(); + taskGaps.push({ startTime: lastTask, duration: now - lastTask }); + lastTask = now; + }, 10); + let heldNormalization = false; + const pendingNormalization: (() => void)[] = []; + Object.assign(window, { + syntaxResources: () => ({ liveWorkers, heldMessages: pending.length, heldNormalizationMessages: pendingNormalization.length }), + holdNormalization: () => { heldNormalization = true; }, + releaseNormalization: () => { heldNormalization = false; for (const send of pendingNormalization.splice(0)) send(); }, + syntaxResponsiveness: () => { + const viewerGaps = taskGaps.filter((gap) => openedAt !== undefined && gap.startTime + gap.duration >= openedAt); + return { longTasksSupported, longTasks, taskGaps: viewerGaps, searchStages, syntaxStages, preparationStages, workerStages, navigationFrames, renderMilestones, keyboardFrames, timingSamples: { ...timingSamples, maximumTimerGap: viewerGaps.length ? Math.max(...viewerGaps.map((gap) => gap.duration)) : null } }; + }, + holdSyntax: () => { held = true; }, + releaseSyntax: () => { + held = false; + for (const send of pending.splice(0)) send(); + }, + }); + window.Worker = class extends OriginalWorker { + private disposed = false; + private syntaxWorker: boolean; + private blocks = new Map(); + constructor(url: string | URL, options?: WorkerOptions) { + super(url, options); + this.syntaxWorker = String(url).includes("markdownSyntax.worker"); + liveWorkers += 1; + this.addEventListener("message", (event) => { + const response = event.data as { type?: string; block?: number }; + if (response.block === undefined) return; + const block = this.blocks.get(response.block); + if (!block) return; + const now = performance.now(); + if ((response.type === "ranges" || response.type === "complete") && block.firstResult === undefined) { + block.firstResult = now; + workerStages.push({ name: "final-source-to-first-result", block: response.block, startTime: block.final ?? block.source, duration: now - (block.final ?? block.source) }); + } + if (response.type === "complete") { + workerStages.push({ name: "source-to-complete", block: response.block, startTime: block.source, duration: now - block.source }); + this.blocks.delete(response.block); + } + }); + } + terminate() { + if (!this.disposed) liveWorkers -= 1; + this.disposed = true; + super.terminate(); + } + postMessage(message: unknown, options?: StructuredSerializeOptions) { + const kind = message as { type?: string }; + const send = () => { + if (this.disposed) return; + const request = message as { type?: string; block?: number; final?: boolean }; + if (this.syntaxWorker && request.type === "source" && request.block !== undefined) { + const block = this.blocks.get(request.block) ?? { source: performance.now() }; + if (request.final) block.final = performance.now(); + this.blocks.set(request.block, block); + } + super.postMessage(message, options); + }; + if (held && this.syntaxWorker && kind.type === "source") pending.push(send); + else if (heldNormalization && !this.syntaxWorker && kind.type === "source") pendingNormalization.push(send); + else send(); + } + }; + }, { expectedBlocks: fixture.blocks, expectedMatches: fixture.lines * fixture.blocks, filename: DEMO_PATH }); + const text = fixture.name === "long-line" ? `${"let x=1; ".repeat(fixture.lines)}\n` : "let x=1;\n".repeat(fixture.lines); + const markdown = Array.from({ length: fixture.blocks }, () => `\`\`\`js\n${text}\`\`\``).join("\n\n"); + await mockMarkdownViewerApi(page, { initialMarkdown: markdown }); + const started = Date.now(); + await page.goto("/browse/smb/demo"); + await page.getByRole("button", { name: "Later", exact: true }).click(); + const heapSession = testInfo.project.name === "chromium" ? await page.context().newCDPSession(page) : null; + await heapSession?.send("Performance.enable"); + if (collectMemory) await heapSession?.send("HeapProfiler.collectGarbage"); + const sampleHeap = async () => heapSession ? await heapSession.send("Runtime.getHeapUsage") : null; + const sampleBrowserMetrics = async () => heapSession ? (await heapSession.send("Performance.getMetrics")).metrics.filter((metric) => ["LayoutDuration", "RecalcStyleDuration", "ScriptDuration", "Nodes"].includes(metric.name)) : null; + const browserMetrics = { beforeOpen: await sampleBrowserMetrics(), afterInitialRender: null as Awaited>, afterComplete: null as Awaited> }; + const memory = { scope: "Chromium renderer heap only; excludes worker heaps and aggregate native/DOM memory. Firefox unsupported. GC runs are separate from responsiveness trials.", controlledGC: collectMemory, browserMetrics, documentStats: null as { elements: number; textNodes: number } | null, beforeOpen: await sampleHeap(), afterComplete: null as Awaited>, afterClose: null as Awaited>, afterCompleteGC: null as Awaited>, afterCloseGC: null as Awaited> }; + const root = page.locator('[data-markdown-search-root="true"]'); + await captureViewerScheduling(page, testInfo, async () => { + await phase("initial-render-through-two-frames", async () => { + await (width < 600 + ? page.getByRole("button", { name: `File: ${DEMO_PATH}`, exact: true }) + : page.getByRole("button").filter({ hasText: /^note\.md$/ })).click(); + await expect(root.locator("pre > code")).toHaveCount(fixture.blocks); + }); + const openMs = Date.now() - started; + browserMetrics.afterInitialRender = await sampleBrowserMetrics(); + await page.getByRole("button", { name: "Search", exact: true }).click(); + const searchControls = page.getByRole("button", { name: "Next match", exact: true }).locator(".."); + const searchStarted = Date.now(); + await phase("first-search-keyboard", async () => { + await page.getByPlaceholder("Search", { exact: true }).pressSequentially("l"); + await expect.poll(() => page.evaluate(() => (window as unknown as { syntaxResponsiveness: () => { timingSamples: ViewerTimingSamples } }).syntaxResponsiveness().timingSamples.firstExactSearch)).toBeGreaterThanOrEqual(0); + await page.getByPlaceholder("Search", { exact: true }).pressSequentially("et x"); + await expect(searchControls.getByText(`1 / ${fixture.lines * fixture.blocks}`, { exact: true })).toBeVisible(); + await expect.poll(() => page.evaluate(() => { + const current = [...CSS.highlights.entries()].find(([name]) => name.startsWith("markdown-search-") && name.endsWith("-current")); + return current ? [...current[1]][0]?.toString() : null; + })).toBe("let x"); + }); + const searchMs = Date.now() - searchStarted; + const before = await root.evaluate((element) => { + const code = element.querySelector("code")!; + const node = document.createTreeWalker(code, NodeFilter.SHOW_TEXT).nextNode()!; + const range = document.createRange(); + range.selectNodeContents(node); + const selection = window.getSelection()!; + selection.removeAllRanges(); + selection.addRange(range); + Object.assign(window, { selectedSyntaxNode: node }); + return { selected: selection.toString(), text: element.textContent }; + }); + const publicationStarted = Date.now(); + await phase("syntax-release-with-active-search", async () => { + await page.evaluate(() => (window as unknown as { releaseSyntax: () => void }).releaseSyntax()); + await expect.poll(() => page.evaluate(() => [...CSS.highlights.entries()].filter(([name]) => /^markdown-(?:syntax|retained)-/.test(name)).reduce((count, [, ranges]) => count + ranges.size, 0))).toBeGreaterThan(0); + }); + const publicationMs = Date.now() - publicationStarted; + const after = await root.evaluate((element) => ({ + text: element.textContent, + selected: window.getSelection()!.toString(), + retainedNode: element.contains((window as unknown as { selectedSyntaxNode: Node }).selectedSyntaxNode), + ranges: [...CSS.highlights.entries()].filter(([name]) => /^markdown-(?:syntax|retained)-/.test(name)).reduce((count, [, ranges]) => count + ranges.size, 0), + tokenSpans: element.querySelectorAll('[class*="sambee-syntax-"]').length, + searchColors: [...CSS.highlights.keys()].filter((name) => name.startsWith("markdown-search-")).map((name) => ({ + name, + background: getComputedStyle(element.querySelector("code")!, `::highlight(${name})`).backgroundColor, + })), + })); + expect(after.text).toBe(before.text); + expect(after.selected).toBe(before.selected); + expect(after.retainedNode).toBe(true); + expect(after.ranges).toBeLessThanOrEqual(viewportRangeLimit); + expect(after.tokenSpans).toBe(0); + expect(after.searchColors).toHaveLength(2); + expect(new Set(after.searchColors.map((entry) => entry.background)).size).toBe(2); + expect(after.searchColors.every((entry) => entry.background && entry.background !== "rgba(0, 0, 0, 0)")).toBe(true); + await expect(root.locator("mark")).toHaveCount(0); + await expect.poll(() => page.evaluate(() => [...CSS.highlights.entries()].filter(([name]) => name.startsWith("markdown-search-") && name.endsWith("-other")).reduce((count, [, ranges]) => count + ranges.size, 0))).toBeGreaterThan(0); + expect(await page.evaluate(() => [...CSS.highlights.entries()].filter(([name]) => name.startsWith("markdown-search-")).reduce((count, [, ranges]) => count + ranges.size, 0))).toBeLessThanOrEqual(1_001); + await expect(searchControls.getByText(`1 / ${fixture.lines * fixture.blocks}`, { exact: true })).toBeVisible(); + await page.evaluate(() => window.getSelection()?.removeAllRanges()); + await phase("next-match", async () => { + await page.getByRole("button", { name: "Next match", exact: true }).click(); + await expect(searchControls.getByText(`2 / ${fixture.lines * fixture.blocks}`, { exact: true })).toBeVisible(); + }); + await phase("previous-match", async () => { + await page.getByRole("button", { name: "Previous match", exact: true }).click(); + await expect(searchControls.getByText(`1 / ${fixture.lines * fixture.blocks}`, { exact: true })).toBeVisible(); + }); + await phase("last-match-wrap", async () => { + await page.getByRole("button", { name: "Previous match", exact: true }).click(); + await expect(searchControls.getByText(`${fixture.lines * fixture.blocks} / ${fixture.lines * fixture.blocks}`, { exact: true })).toBeVisible(); + const destination = await root.evaluate((element) => { + const entry = [...CSS.highlights.entries()].find(([name]) => name.startsWith("markdown-search-") && name.endsWith("-current")); + const range = entry ? [...entry[1]][0] as Range : null; + const rect = range?.getBoundingClientRect(); + const viewport = element.parentElement!.getBoundingClientRect(); + return { text: range?.toString(), visible: !!rect && rect.bottom >= viewport.top && rect.top <= viewport.bottom && rect.right >= viewport.left && rect.left <= viewport.right }; + }); + expect(destination).toEqual({ text: "let x", visible: true }); + }); + await expect.poll(() => page.evaluate(() => (window as unknown as { syntaxResponsiveness: () => { timingSamples: ViewerTimingSamples } }).syntaxResponsiveness().timingSamples.lastMatchNavigation)).toBeGreaterThanOrEqual(0); + if (fixture.name === "long-line") { + await expect.poll(() => page.evaluate(() => { + const current = [...CSS.highlights.entries()].find(([name]) => name.startsWith("markdown-search-") && name.endsWith("-current")); + const active = current ? [...current[1]][0] as Range : null; + if (!active) return false; + return [...CSS.highlights.entries()].filter(([name]) => /^markdown-(?:syntax|retained)-/.test(name)).some(([, ranges]) => [...ranges].some((entry) => { + const range = entry as Range; + return range.startContainer === active.startContainer && range.startOffset <= active.startOffset && range.endOffset > active.startOffset; + })); + })).toBe(true); + expect(await page.evaluate(() => [...CSS.highlights.entries()].filter(([name]) => /^markdown-(?:syntax|retained)-/.test(name)).reduce((total, [, ranges]) => total + ranges.size, 0))).toBeLessThan(500); + } + await phase("scroll-with-active-search", async () => { + await root.evaluate((element) => { + const viewport = element.parentElement!; + viewport.scrollTop = Math.max(0, viewport.scrollTop - viewport.clientHeight); + }); + await expect(page.getByPlaceholder("Search", { exact: true })).toBeVisible(); + if (fixture.name === "many-blocks") { + const scrolling = await root.evaluate(async (element) => { + const viewport = element.parentElement!; + const initialScroll = viewport.scrollTop; + const frame = () => new Promise((resolve) => requestAnimationFrame(() => resolve())); + const visible = (pattern: RegExp) => [...CSS.highlights.entries()] + .filter(([name]) => pattern.test(name)) + .some(([, ranges]) => [...ranges].some((entry) => { + const rect = (entry as Range).getBoundingClientRect(); + const bounds = viewport.getBoundingClientRect(); + return rect.bottom > bounds.top && rect.top < bounds.bottom; + })); + const painted = () => visible(/^markdown-(?:syntax|retained)-/) && visible(/^markdown-search-.*-other$/); + let ready = painted(); + for (let attempt = 0; !ready && attempt < 10; attempt += 1) { + await frame(); + ready = painted(); + } + let searchFrames = 0; + let syntaxFrames = 0; + for (let index = 0; index < 60; index += 1) { + viewport.scrollTop = initialScroll + (index % 2) * 8; + await frame(); + if (visible(/^markdown-search-.*-other$/)) searchFrames += 1; + if (visible(/^markdown-(?:syntax|retained)-/)) syntaxFrames += 1; + } + return { ready, searchFrames, syntaxFrames }; + }); + await testInfo.attach("continuous-scroll", { body: JSON.stringify({ renderer: "react-markdown", ...scrolling }), contentType: "application/json" }); + expect(scrolling.ready).toBe(true); + expect(scrolling.searchFrames).toBe(60); + expect(scrolling.syntaxFrames).toBe(60); + } + }); + await phase("changed-query", async () => { + await page.getByPlaceholder("Search", { exact: true }).fill("x=1"); + await expect(searchControls.getByText(`1 / ${fixture.lines * fixture.blocks}`, { exact: true })).toBeVisible(); + expect(await root.evaluate((element) => element.contains((window as unknown as { selectedSyntaxNode: Node }).selectedSyntaxNode))).toBe(true); + }); + await expect.poll(() => page.evaluate(() => (window as unknown as { syntaxResponsiveness: () => { timingSamples: ViewerTimingSamples } }).syntaxResponsiveness().timingSamples.changedQuery)).toBeGreaterThanOrEqual(0); + await phase("clear-query", async () => { + await page.getByPlaceholder("Search", { exact: true }).fill(""); + await expect(root.locator("mark")).toHaveCount(0); + expect(await page.evaluate(() => [...CSS.highlights.keys()].filter((name) => name.startsWith("markdown-search-")).length)).toBe(0); + }); + const responsiveness = await page.evaluate(() => (window as unknown as { syntaxResponsiveness: () => { + timingSamples: ViewerTimingSamples; + taskGaps: { startTime: number; duration: number }[]; + longTasksSupported: boolean; + longTasks: { startTime: number; duration: number }[]; + searchStages: { name: string; startTime: number; duration: number }[]; + syntaxStages: { name: string; startTime: number; duration: number }[]; + preparationStages: { name: string; startTime: number; duration: number }[]; + workerStages: { name: string; startTime: number; duration: number }[]; + navigationFrames: { action: string; startTime: number; duration: number; firstFrameMs: number }[]; + renderMilestones: { name: string; time: number }[]; + keyboardFrames: { event: string; startTime: number; duration: number; firstFrameMs: number; eventAgeMs: number; value: string }[]; + } }).syntaxResponsiveness()); + const budgetReport = evaluateViewerTimingBudgets(responsiveness.timingSamples, diagnostic); + Object.assign(responsiveness, { measurementVersion: "scoped-controls-v2", traceRequested: process.env.SAMBEE_E2E_TRACE === "1", traceSupported: testInfo.project.name === "chromium" }); + for (const stage of ["capture", "matching", "presentation", "clear", "navigation-lookup", "navigation-publication", "navigation-geometry", "navigation-scroll"]) { + expect(responsiveness.searchStages.some((entry) => entry.name === `sambee:preview-search:${stage}`)).toBe(true); + } + for (const stage of ["task", "viewport-geometry", "block-geometry", "mapping", "offset-geometry", "ranges", "publication"]) { + expect(responsiveness.syntaxStages.some((entry) => entry.name === `sambee:preview-syntax:${stage}`)).toBe(true); + } + for (const stage of [ + "decode-elapsed", + "load-normalization-elapsed", + "collection-walk", "render-prepared-tree", "render-transform", "hast-ready-to-layout-effect", + ]) { + expect(responsiveness.preparationStages.some((entry) => entry.name === `sambee:preview-preparation:${stage}`)).toBe(true); + } + expect(responsiveness.preparationStages.some((entry) => entry.name === "sambee:preview-preparation:collection-parse")).toBe(false); + expect(responsiveness.preparationStages.some((entry) => entry.name === "sambee:preview-preparation:render-parse")).toBe(false); + expect(responsiveness.workerStages.filter((entry) => entry.name === "source-to-complete")).toHaveLength(fixture.blocks); + expect(responsiveness.navigationFrames).toHaveLength(3); + for (const milestone of ["source-response-end", "dom-commit-observed", "first-frame", "second-frame"]) { + expect(responsiveness.renderMilestones.filter((entry) => entry.name === milestone)).toHaveLength(1); + } + expect(responsiveness.searchStages.filter((entry) => entry.name === "sambee:preview-search:capture")).toHaveLength(1); + expect(responsiveness.searchStages.some((entry) => entry.name.endsWith(":markers") || entry.name.endsWith(":slices"))).toBe(false); + expect(responsiveness.keyboardFrames.some((entry) => entry.event === "keydown")).toBe(true); + expect(responsiveness.keyboardFrames.some((entry) => entry.event === "input" && entry.value === "let x")).toBe(true); + for (const entry of [...responsiveness.keyboardFrames, ...responsiveness.navigationFrames]) { + expect(Number.isFinite(entry.firstFrameMs) && entry.firstFrameMs >= 0 && entry.firstFrameMs <= entry.duration).toBe(true); + } + expect(responsiveness.keyboardFrames.every((entry) => Number.isFinite(entry.eventAgeMs) && entry.eventAgeMs >= 0)).toBe(true); + memory.documentStats = await root.evaluate((element) => { + const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT); + let textNodes = 0; + while (walker.nextNode()) textNodes += 1; + return { elements: element.querySelectorAll("*").length, textNodes }; + }); + browserMetrics.afterComplete = await sampleBrowserMetrics(); + memory.afterComplete = await sampleHeap(); + await page.evaluate(() => Reflect.deleteProperty(window, "selectedSyntaxNode")); + if (collectMemory) { + await heapSession?.send("HeapProfiler.collectGarbage"); + memory.afterCompleteGC = await sampleHeap(); + } + await page.getByRole("button", { name: "Close", exact: true }).click(); + await expect(root).toHaveCount(0); + await expect.poll(() => page.evaluate(() => [...CSS.highlights.keys()].filter((name) => /^markdown-(?:syntax|retained)-/.test(name)).length)).toBe(0); + expect(await page.evaluate(() => [...CSS.highlights.keys()].filter((name) => name.startsWith("markdown-search-")).length)).toBe(0); + await expect.poll(() => page.evaluate(() => (window as unknown as { syntaxResources: () => { liveWorkers: number } }).syntaxResources().liveWorkers)).toBe(0); + memory.afterClose = await sampleHeap(); + if (collectMemory) { + await heapSession?.send("HeapProfiler.collectGarbage"); + memory.afterCloseGC = await sampleHeap(); + } + await heapSession?.detach(); + await page.evaluate(() => (window as unknown as { holdNormalization: () => void }).holdNormalization()); + await (width < 600 + ? page.getByRole("button", { name: `File: ${DEMO_PATH}`, exact: true }) + : page.getByRole("button").filter({ hasText: /^note\.md$/ })).click(); + await expect.poll(() => page.evaluate(() => (window as unknown as { syntaxResources: () => { heldNormalizationMessages: number } }).syntaxResources().heldNormalizationMessages)).toBeGreaterThan(0); + await page.getByRole("button", { name: "Close", exact: true }).click(); + await expect.poll(() => page.evaluate(() => (window as unknown as { syntaxResources: () => { liveWorkers: number } }).syntaxResources().liveWorkers)).toBe(0); + await page.evaluate(() => (window as unknown as { releaseNormalization: () => void }).releaseNormalization()); + await expect(root).toHaveCount(0); + await page.evaluate(() => (window as unknown as { holdSyntax: () => void }).holdSyntax()); + await (width < 600 + ? page.getByRole("button", { name: `File: ${DEMO_PATH}`, exact: true }) + : page.getByRole("button").filter({ hasText: /^note\.md$/ })).click(); + await expect.poll(() => page.evaluate(() => (window as unknown as { syntaxResources: () => { heldMessages: number } }).syntaxResources().heldMessages)).toBeGreaterThan(0); + await page.getByRole("button", { name: "Close", exact: true }).click(); + await expect.poll(() => page.evaluate(() => (window as unknown as { syntaxResources: () => { liveWorkers: number } }).syntaxResources().liveWorkers)).toBe(0); + await page.evaluate(() => (window as unknown as { releaseSyntax: () => void }).releaseSyntax()); + await expect(root).toHaveCount(0); + expect(await page.evaluate(() => [...CSS.highlights.keys()].filter((name) => /^markdown-(?:syntax|retained)-/.test(name)).length)).toBe(0); + await testInfo.attach("publication-measurements", { body: JSON.stringify({ renderer: "react-markdown", fixture: fixture.name, width, openMs, searchMs, publicationMs, mountedRanges: after.ranges, phases, responsiveness, memory, timingScope: "Real viewer with production-built assets. Phases include Playwright synchronization and two frames. Search/syntax stages measure synchronous calls: geometry may force layout; publication does not include deferred paint. Load-normalization-elapsed includes worker startup, transfer, parsing, acknowledgements and scheduling, not main-thread CPU time. Collection reuses the render tree and is included in render-transform time. Decode is asynchronous elapsed time. HAST-ready-to-layout-effect includes element construction, reconciliation, commit and scheduling, not isolated React CPU. Keyboard/navigation samples measure event-handler entry through two frames, not hardware input-to-visible-paint latency. Syntax worker round trips start after intentional holds and include transfer, scheduling, grammar loading and result acknowledgements, not isolated parser CPU.", }), contentType: "application/json", }); + await testInfo.attach("viewer-timing-budgets.json", { body: JSON.stringify({ renderer: "react-markdown", fixture: fixture.name, width, engine: testInfo.project.name, trial: testInfo.repeatEachIndex, enforced: enforceBudgets, measurementVersion: "browser-action-boundaries-v1", scope: "Text load from file click through two frames; first exact query from its first input event, and warm query/navigation from their input/click events through exact count/highlight readiness and two frames. Timer gaps cover the viewer window, not hardware latency or CPU attribution. Syntax is deliberately held for the existing active-search test; load completion excludes syntax preparation. This subset is not full rollout acceptance.", ...budgetReport }, null, 2), contentType: "application/json" }); + expect(budgetReport.metrics.filter((metric) => metric.status === "invalid"), "Required timing measurements must be present and valid").toEqual([]); + if (enforceBudgets) expect(budgetReport.status, JSON.stringify(budgetReport.metrics.filter((metric) => metric.status !== "pass"))).toBe("pass"); + }); + }); + } + } +}); + test("edit sessions can reopen immediately after release", async ({ page }) => { await mockMarkdownViewerApi(page, { initialMarkdown: "Immediate re-entry\n" }); await openMarkdownViewer(page); @@ -276,7 +2361,7 @@ test("edit sessions can reopen immediately after release", async ({ page }) => { await second.session.release(); }, { connectionId: DEMO_CONNECTION_ID, path: DEMO_PATH }); - await expect.poll(() => page.evaluate(() => Object.keys(sessionStorage).filter(key => key.startsWith("sambee:edit-lock:")))).toEqual([]); + await expect.poll(() => page.evaluate(() => Object.keys(sessionStorage).filter((key) => key.startsWith("sambee:edit-lock:")))).toEqual([]); }); for (const reloadKind of ["browser", "update"] as const) { @@ -327,7 +2412,7 @@ for (const reloadKind of ["browser", "update"] as const) { await expect(page.getByText(/Unable to enter edit mode/)).toHaveCount(0); await page.getByRole("button", { name: "Close", exact: true }).click(); await expect(page).not.toHaveURL(/mode=edit/); - await expect.poll(() => page.evaluate(() => Object.keys(sessionStorage).filter(key => key.startsWith("sambee:edit-lock:")))).toEqual([]); + await expect.poll(() => page.evaluate(() => Object.keys(sessionStorage).filter((key) => key.startsWith("sambee:edit-lock:")))).toEqual([]); }); } @@ -604,7 +2689,7 @@ test(`${layout} upload resolves a conflict before publishing the next file`, asy if (path === "first.txt" && policy === "ask") { await fulfillJson(route, { detail: "Destination already exists" }, 409); } else { - await fulfillJson(route, { status: "completed", replaced: policy === "replace", effects: { source: "unchanged", destination: "mutated" } }); + await fulfillJson(route, { status: "completed", replaced: policy === "replace", effects: { source: "unchanged", destination: "mutated" }, }); } }, }); @@ -668,6 +2753,990 @@ async function enterMarkdownEditMode(page: Page): Promise { await page.getByRole("textbox", { name: "Markdown editor" }).waitFor(); } +interface SourceResponsivenessProbe { + measures: { name: string; startTime: number; duration: number; detail: Record }[]; + jobs: { url: string; ranges: number; rangeBatches: number; changeChunks: number; changeUnits: number; maximumChunkUnits: number; terminated: boolean }[]; + timerGaps: number[]; + completionMs: number | null; + stop: () => void; +} + +interface SourceTypingProbe { + inputs: { key: string; trusted: boolean; at: number; firstFrameMs: number | null; twoFrameMs: number | null }[]; + measures: { name: string; startTime: number; duration: number; detail: { revision?: number; documentUnits: number } }[]; + gaps: number[]; + longTasks: { startTime: number; duration: number }[]; + longTasksSupported: boolean; + stop: () => void; +} + +for (const width of [1543, 390]) { + test(`production progressive Markdown loading cancellation at ${width}px`, async ({ browser }, testInfo) => { + test.skip(process.env.SAMBEE_E2E_PRODUCTION !== "1", "Requires the normal production build"); + const source = Array.from({ length: PROSE_ENVELOPE_PARAGRAPHS }, (_, index) => `Paragraph ${index + 1}: **formatted** needle and [link](https://example.com/).`).join("\n\n"); + const proxy = await createCacheableViewerProxy({ initialMarkdown: source }); + let context: BrowserContext | undefined; + try { + context = await browser.newContext({ viewport: { width, height: 900 }, serviceWorkers: "block", proxy: { server: proxy.url, bypass: "<-loopback>" } }); + const page = await context.newPage(); + await page.goto("/browse/smb/demo"); + await page.getByRole("button", { name: "Later", exact: true }).click(); + await page.evaluate(() => { + const nativeFrame = window.requestAnimationFrame.bind(window); + const held: FrameRequestCallback[] = []; + const probe = { hold: true, held, release: () => { probe.hold = false; for (const callback of held.splice(0)) nativeFrame(callback); } }; + (window as typeof window & { progressiveProbe: typeof probe }).progressiveProbe = probe; + window.requestAnimationFrame = (callback) => nativeFrame((time) => { + const stage = document.querySelector('[data-markdown-publication="loading"]'); + const viewport = document.querySelector('[data-testid="markdown-viewer-content"]'); + if (probe.hold && stage && viewport && stage.getBoundingClientRect().height >= viewport.clientHeight * 2) held.push(callback); + else callback(time); + }); + }); + const file = page.getByRole("button", { name: DEMO_PATH, exact: true }).or(page.getByRole("button", { name: `File: ${DEMO_PATH}`, exact: true })); + await file.click(); + await page.waitForFunction(() => (window as typeof window & { progressiveProbe: { held: unknown[] } }).progressiveProbe.held.length > 0, undefined, { polling: 25 }); + const preview = page.locator('[data-markdown-search-root="true"]'); + expect(await preview.locator("p").count()).toBeLessThan(PROSE_ENVELOPE_PARAGRAPHS); + await expect(page.getByRole("button", { name: "Search", exact: true })).toBeDisabled(); + await expect(page.getByRole("button", { name: "Edit", exact: true })).toBeDisabled(); + await expect(page.getByRole("button", { name: "Print", exact: true })).toBeDisabled(); + await expect(page.getByRole("status", { name: "Loading...", exact: true })).toBeVisible(); + await expect(page.getByRole("status", { name: "Loading...", exact: true }).getByRole("progressbar")).toBeInViewport({ ratio: 1 }); + await testInfo.attach("progressive-loading", { body: await page.screenshot(), contentType: "image/png" }); + await page.emulateMedia({ media: "print" }); + await expect(preview).toBeHidden(); + await expect(page.getByText("The document is still loading. Close the print dialog and try again when loading finishes.", { exact: true })).toBeVisible(); + await page.emulateMedia({ media: null }); + await page.keyboard.press("Escape"); + await expect(preview).toHaveCount(0); + await page.evaluate(() => (window as typeof window & { progressiveProbe: { release: () => void } }).progressiveProbe.release()); + await file.click(); + await expect(page.locator('[data-markdown-publication="complete"]')).toBeVisible(); + await expect(preview.locator("p")).toHaveCount(PROSE_ENVELOPE_PARAGRAPHS); + await expect(page.getByRole("button", { name: "Search", exact: true })).toBeEnabled(); + await expect(page.getByRole("button", { name: "Print", exact: true })).toBeEnabled(); + await expect(page.getByRole("status", { name: "Loading...", exact: true })).toHaveCount(0); + await page.getByRole("button", { name: "Close", exact: true }).click(); + expect(proxy.errors).toEqual([]); + } finally { + await context?.close(); + await proxy.close(); + } + }); +} + +for (const width of [1543, 390]) { + for (const fixture of ["prose", "table"] as const) { + test(`production prose table responsiveness ${fixture} at ${width}px`, async ({ browser }, testInfo) => { + test.skip(process.env.SAMBEE_E2E_PRODUCTION !== "1", "Requires the normal production build"); + const profiling = process.env.SAMBEE_E2E_TRACE === "1"; + test.skip(profiling && testInfo.project.name !== "chromium", "Load CPU profiling requires Chromium CDP"); + if (process.env.SAMBEE_E2E_MEMORY === "1" || (profiling && process.env.SAMBEE_E2E_BUDGETS === "1")) throw new Error("Document/scroll timing must run separately from GC and profiling"); + const paragraphs = Array.from({ length: PROSE_ENVELOPE_PARAGRAPHS }, (_, index) => `Paragraph ${index + 1}: **formatted** needle and [link](https://example.com/).`); + const rows = Array.from({ length: TABLE_ENVELOPE_ROWS }, (_, index) => `| ${index + 1} | row ${index + 1} needle |`); + const source = fixture === "prose" ? `${paragraphs.join("\n\n")}\n` : `| Key | Value |\n| --- | --- |\n${rows.join("\n")}\n`; + const expectedBlocks = fixture === "prose" ? paragraphs.map((_, index) => `Paragraph ${index + 1}: formatted needle and link.`) : ["Key", "Value", ...rows.flatMap((_, index) => [`${index + 1}`, `row ${index + 1} needle`])]; + const expectedItems = fixture === "prose" ? PROSE_ENVELOPE_PARAGRAPHS : TABLE_ENVELOPE_ROWS; + const proxy = await createCacheableViewerProxy({ initialMarkdown: source }); + let context: BrowserContext | undefined; + try { + context = await browser.newContext({ viewport: { width, height: 900 }, serviceWorkers: "block", proxy: { server: proxy.url, bypass: "<-loopback>" } }); + const page = await context.newPage(); + await page.goto("/browse/smb/demo"); + await page.getByRole("button", { name: "Later", exact: true }).click(); + await page.evaluate(({ fixture, expectedItems, profiling }) => { + const probe = { openAt: null as number | null, completedMs: null as number | null, firstViewportMs: null as number | null, firstViewportItems: null as number | null, maximumTimerGap: 0, stop: () => {} }; + (window as typeof window & { proseTableProbe: typeof probe }).proseTableProbe = probe; + let previousTick: number | undefined; + let completing = false; + document.addEventListener("click", () => { probe.openAt = performance.now(); previousTick = probe.openAt; if (profiling) performance.mark("sambee:viewer-load:start"); }, { once: true, capture: true }); + const timer = setInterval(() => { + if (previousTick === undefined || probe.completedMs !== null) return; + const now = performance.now(); + probe.maximumTimerGap = Math.max(probe.maximumTimerGap, now - previousTick); + previousTick = now; + }, 0); + const observer = new MutationObserver(() => { + const root = document.querySelector('[data-markdown-search-root="true"]'); + if (!root || completing || probe.openAt === null) return; + const count = root.querySelectorAll(fixture === "prose" ? "p" : "tbody tr").length; + const viewport = root.parentElement; + if (probe.firstViewportMs === null && viewport && root.getBoundingClientRect().height >= viewport.clientHeight * 2) { + probe.firstViewportMs = performance.now() - probe.openAt; + probe.firstViewportItems = count; + } + if (count !== expectedItems) return; + completing = true; + requestAnimationFrame(() => requestAnimationFrame(() => { probe.completedMs = performance.now() - (probe.openAt as number); if (profiling) performance.mark("sambee:viewer-load:complete"); })); + }); + observer.observe(document.body, { childList: true, subtree: true }); + probe.stop = () => { observer.disconnect(); clearInterval(timer); }; + }, { fixture, expectedItems, profiling }); + const profiler = profiling ? await context.newCDPSession(page) : undefined; + const traceEvents: unknown[] = []; + if (profiler) { + profiler.on("Tracing.dataCollected", event => traceEvents.push(...event.value)); + await profiler.send("Profiler.enable"); + await profiler.send("Profiler.start"); + await profiler.send("Tracing.start", { categories: "devtools.timeline,blink.user_timing,v8", transferMode: "ReportEvents" }); + } + await page.getByRole("button", { name: DEMO_PATH, exact: true }).or(page.getByRole("button", { name: `File: ${DEMO_PATH}`, exact: true })).click(); + await page.waitForFunction(() => (window as typeof window & { proseTableProbe: { completedMs: number | null } }).proseTableProbe.completedMs !== null); + if (profiler) { + const profile = await profiler.send("Profiler.stop"); + const tracingComplete = new Promise(resolve => profiler.once("Tracing.tracingComplete", () => resolve())); + await profiler.send("Tracing.end"); + await tracingComplete; + expect(profile.profile.samples?.length ?? 0).toBeGreaterThan(0); + expect(traceEvents).toEqual(expect.arrayContaining([ + expect.objectContaining({ name: "sambee:viewer-load:start", ph: "I" }), + expect.objectContaining({ name: "sambee:viewer-load:complete", ph: "I" }), + ])); + await testInfo.attach("viewer-load-cpu-profile", { contentType: "application/json", body: JSON.stringify(profile) }); + await testInfo.attach("viewer-load-timeline", { contentType: "application/json", body: JSON.stringify({ traceEvents }) }); + await profiler.detach(); + } + const load = await page.evaluate(() => { + const probe = (window as typeof window & { proseTableProbe: { completedMs: number; firstViewportMs: number | null; firstViewportItems: number | null; maximumTimerGap: number; stop: () => void } }).proseTableProbe; + probe.stop(); + return { documentCompletion: probe.completedMs, maximumTimerGap: probe.maximumTimerGap, firstViewportMs: probe.firstViewportMs, firstViewportItems: probe.firstViewportItems }; + }); + const preview = page.locator('[data-markdown-search-root="true"]'); + const initialRenderedText = await preview.textContent(); + expect(await preview.locator(fixture === "prose" ? "p" : "th, td").allTextContents()).toEqual(expectedBlocks); + await expect(page.getByRole("button", { name: "Search", exact: true })).toBeEnabled(); + const scrolling = await preview.evaluate(async (root, steps) => { + const scroller = root.parentElement; + if (!(scroller instanceof HTMLElement) || scroller.scrollHeight <= scroller.clientHeight) throw new Error("Expected a scrollable full document"); + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + const nodes: Node[] = []; + for (let node = walker.nextNode(); node; node = walker.nextNode()) nodes.push(node); + const intervals: number[] = []; + const offsets: number[] = []; + const start = performance.now(); + let previous: number | undefined; + await new Promise(resolve => { + const frame = (now: number) => { + if (previous !== undefined) intervals.push(now - previous); + if (offsets.length === steps) { resolve(); return; } + previous = now; + scroller.scrollTop = (scroller.scrollHeight - scroller.clientHeight) * offsets.length / (steps - 1); + offsets.push(scroller.scrollTop); + requestAnimationFrame(frame); + }; + requestAnimationFrame(frame); + }); + const after = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + const unchanged = nodes.every(node => after.nextNode() === node) && after.nextNode() === null; + const selection = window.getSelection(); + if (!selection || !nodes.length) throw new Error("Expected native selectable document text"); + const range = document.createRange(); + range.selectNodeContents(root); + selection.removeAllRanges(); + selection.addRange(range); + const selected = selection.toString(); + selection.removeAllRanges(); + return { intervals, offsets, elapsedMs: performance.now() - start, unchangedTextNodes: unchanged, sameRoot: document.querySelector('[data-markdown-search-root="true"]') === root, selectedText: selected, physicalRefreshHz: null }; + }, SCROLL_PROBE_STEPS); + const intervals = [...scrolling.intervals].sort((left, right) => left - right); + const p95 = intervals[Math.ceil(intervals.length * 0.95) - 1]; + const frameProxy = { p95, maximumMs: Math.max(...intervals), nominal60HzP95TargetMs: NOMINAL_60HZ_SCROLL_TARGETS_MS.p95, nominal60HzMaximumTargetMs: NOMINAL_60HZ_SCROLL_TARGETS_MS.maximum, status: "diagnostic", physicalRefreshHz: null, p95ExceedsNominal: p95 > NOMINAL_60HZ_SCROLL_TARGETS_MS.p95 }; + const budgets = { status: profiling ? "diagnostic" : load.documentCompletion <= VIEWER_TIMING_TARGETS_MS.documentCompletion && load.maximumTimerGap <= VIEWER_TIMING_TARGETS_MS.maximumTimerGap ? "pass" : "fail", documentCompletion: { observedMs: load.documentCompletion, targetMs: VIEWER_TIMING_TARGETS_MS.documentCompletion }, maximumTimerGap: { observedMs: load.maximumTimerGap, targetMs: VIEWER_TIMING_TARGETS_MS.maximumTimerGap } }; + await testInfo.attach("prose-table-responsiveness", { contentType: "application/json", body: JSON.stringify({ fixture, width, engine: testInfo.project.name, trial: testInfo.repeatEachIndex, browserVersion: browser.version(), sourceUnits: source.length, expectedItems, load, budgets, scrolling: { ...scrolling, selectedText: undefined }, frameProxy, enforced: process.env.SAMBEE_E2E_BUDGETS === "1", limitations: ["Programmatic main-thread scroll and rAF intervals, not wheel/touch/compositor smoothness or hardware paint", "Physical refresh rate and power mode unverified; no physical 60 Hz acceptance", "No eligible fenced syntax in these fixtures; syntax readiness is not applicable", "No application/native CPU attribution is inferred from interval or timer gaps"] }) }); + expect(scrolling.sameRoot && scrolling.unchangedTextNodes).toBe(true); + expect(scrolling.offsets).toHaveLength(SCROLL_PROBE_STEPS); + expect(scrolling.offsets[0]).toBe(0); + expect(scrolling.offsets.at(-1)).toBeGreaterThan(0); + expect(intervals).toHaveLength(SCROLL_PROBE_STEPS); + expect(intervals.every(value => Number.isFinite(value) && value > 0)).toBe(true); + expect(scrolling.selectedText).toContain(fixture === "prose" ? "Paragraph 1: formatted needle and link." : "row 1 needle"); + expect(scrolling.selectedText).toContain(fixture === "prose" ? `Paragraph ${expectedItems}: formatted needle and link.` : `row ${expectedItems} needle`); + expect(await preview.textContent()).toBe(initialRenderedText); + await page.getByRole("button", { name: "Close", exact: true }).click(); + await expect(preview).toHaveCount(0); + expect(proxy.errors).toEqual([]); + expect(Number.isFinite(load.documentCompletion) && load.documentCompletion >= 0).toBe(true); + expect(Number.isFinite(load.maximumTimerGap) && load.maximumTimerGap >= 0).toBe(true); + if (process.env.SAMBEE_E2E_BUDGETS === "1") expect(budgets.status, JSON.stringify(budgets)).toBe("pass"); + } finally { + await context?.close(); + await proxy.close(); + } + }); + } +} + +for (const width of [1543, 390]) { + for (const fixture of ["dense", "long-line"] as const) { + test(`production source typing ${fixture} at ${width}px`, async ({ browser }, testInfo) => { + test.skip(process.env.SAMBEE_E2E_PRODUCTION !== "1", "Requires the normal production build"); + if (process.env.SAMBEE_E2E_MEMORY === "1" || process.env.SAMBEE_E2E_TRACE === "1") throw new Error("Typing timing must run separately from GC and profiling"); + const initialMarkdown = `abc 123;${fixture === "dense" ? "\n" : " "}`.repeat(DENSE_SYNTAX_LINE_COUNT); + const proxy = await createCacheableViewerProxy({ initialMarkdown, wordWrapEnabled: false }); + let context: BrowserContext | undefined; + try { + context = await browser.newContext({ viewport: { width, height: 900 }, serviceWorkers: "block", proxy: { server: proxy.url, bypass: "<-loopback>" } }); + const page = await context.newPage(); + await page.goto("/browse/smb/demo"); + await page.getByRole("button", { name: "Later", exact: true }).click(); + await page.getByRole("button", { name: DEMO_PATH, exact: true }).or(page.getByRole("button", { name: `File: ${DEMO_PATH}`, exact: true })).click(); + await enterMarkdownEditMode(page); + const editor = page.getByRole("textbox", { name: "Markdown editor" }); + await editor.click(); + await page.keyboard.press("Control+End"); + await editor.evaluate(async element => { + await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))); + const probe: SourceTypingProbe = { inputs: [], measures: [], gaps: [], longTasks: [], longTasksSupported: PerformanceObserver.supportedEntryTypes.includes("longtask"), stop: () => {} }; + (window as typeof window & { sourceTypingProbe: SourceTypingProbe }).sourceTypingProbe = probe; + let previousTick: number | undefined; + const timer = setInterval(() => { + if (previousTick === undefined) return; + const now = performance.now(); + probe.gaps.push(now - previousTick); + previousTick = now; + }, 0); + const keydown = (event: Event) => { + if (!(event instanceof KeyboardEvent) || event.key.length !== 1 || event.ctrlKey || event.metaKey || event.altKey) return; + const sample = { key: event.key, trusted: event.isTrusted, at: performance.now(), firstFrameMs: null as number | null, twoFrameMs: null as number | null }; + previousTick ??= sample.at; + probe.inputs.push(sample); + requestAnimationFrame(() => { + sample.firstFrameMs = performance.now() - sample.at; + requestAnimationFrame(() => { sample.twoFrameMs = performance.now() - sample.at; }); + }); + }; + element.addEventListener("keydown", keydown, true); + const measures = new PerformanceObserver(list => { + for (const entry of list.getEntries()) { + if (!entry.name.startsWith("sambee:source-editor:")) continue; + const measure = entry as PerformanceMeasure; + probe.measures.push({ name: measure.name, startTime: measure.startTime, duration: measure.duration, detail: measure.detail }); + } + }); + measures.observe({ type: "measure" }); + const longTasks = probe.longTasksSupported ? new PerformanceObserver(list => { + for (const entry of list.getEntries()) probe.longTasks.push({ startTime: entry.startTime, duration: entry.duration }); + }) : undefined; + longTasks?.observe({ type: "longtask" }); + probe.stop = () => { clearInterval(timer); measures.disconnect(); longTasks?.disconnect(); element.removeEventListener("keydown", keydown, true); }; + }); + await page.keyboard.type(SOURCE_TYPING_PAYLOAD, { delay: SOURCE_TYPING_KEY_DELAY_MS }); + await page.waitForFunction(expected => { + const probe = (window as typeof window & { sourceTypingProbe: SourceTypingProbe }).sourceTypingProbe; + return probe.inputs.length === expected && probe.inputs.every(sample => sample.twoFrameMs !== null) && probe.measures.filter(entry => entry.name === "sambee:source-editor:publish").length === expected; + }, SOURCE_TYPING_PAYLOAD.length); + await expect(page.getByLabel("Unsaved changes", { exact: true })).toBeVisible(); + const evidence = await page.evaluate(() => { + const probe = (window as typeof window & { sourceTypingProbe: SourceTypingProbe }).sourceTypingProbe; + probe.stop(); + return { inputs: probe.inputs, measures: probe.measures, gaps: probe.gaps, longTasks: probe.longTasks, longTasksSupported: probe.longTasksSupported }; + }); + const frames = evidence.inputs.map(sample => sample.twoFrameMs ?? Number.NaN).sort((left, right) => left - right); + const maximumStage = (name: string) => { const stages = evidence.measures.filter(entry => entry.name === name); return stages.length ? Math.max(...stages.map(entry => entry.duration)) : null; }; + const budgets = evaluateSourceTypingBudgets({ keyboardTwoFrameP95: frames[Math.ceil(frames.length * 0.95) - 1], maximumTimerGap: evidence.gaps.length ? Math.max(...evidence.gaps) : null, serialization: maximumStage("sambee:source-editor:serialize"), synchronousPublication: maximumStage("sambee:source-editor:publish") }); + await testInfo.attach("source-typing", { contentType: "application/json", body: JSON.stringify({ fixture, width, engine: testInfo.project.name, trial: testInfo.repeatEachIndex, browserVersion: browser.version(), sourceUnits: initialMarkdown.length, inputCount: SOURCE_TYPING_PAYLOAD.length, keyDelayMs: SOURCE_TYPING_KEY_DELAY_MS, evidence, budgets, enforced: process.env.SAMBEE_E2E_BUDGETS === "1", limitations: ["Browser keydown-to-two-frame scheduling proxy, not hardware input or physical paint", "Long Task entries are whole-renderer observations, not isolated application CPU attribution", "Missing Long Tasks support is unmeasured, not a pass"] }) }); + expect(evidence.inputs.map(sample => sample.key).join("")).toBe(SOURCE_TYPING_PAYLOAD); + expect(evidence.inputs.every(sample => sample.trusted)).toBe(true); + for (const name of ["serialize", "publish", "normalize", "parent-update"]) { + const stages = evidence.measures.filter(entry => entry.name === `sambee:source-editor:${name}`); + expect(stages).toHaveLength(SOURCE_TYPING_PAYLOAD.length); + expect(stages.map(entry => entry.detail.documentUnits)).toEqual(Array.from({ length: SOURCE_TYPING_PAYLOAD.length }, (_, index) => initialMarkdown.length + index + 1)); + } + expect(budgets.metrics.filter(metric => metric.status === "invalid")).toEqual([]); + await page.getByRole("button", { name: "Save", exact: true }).click(); + await expect.poll(() => proxy.uploads.length).toBe(1); + const upload = proxy.uploads[0]; + const form = await new Response(upload.body, { headers: { "Content-Type": upload.contentType } }).formData(); + const files = [...form.values()].filter((value): value is File => typeof value !== "string"); + expect(files).toHaveLength(1); + expect(await files[0].text()).toBe(`${initialMarkdown}${SOURCE_TYPING_PAYLOAD}`); + await page.getByRole("button", { name: "Close", exact: true }).click(); + await expect(page.getByRole("textbox", { name: "Markdown editor" })).toHaveCount(0); + expect(proxy.errors).toEqual([]); + if (process.env.SAMBEE_E2E_BUDGETS === "1") expect(budgets.status, JSON.stringify(budgets)).toBe("pass"); + } finally { + await context?.close(); + await proxy.close(); + } + }); + } +} + +const SOURCE_REGEX_PARITY_FIXTURES = [ + { name: "zero-length", source: "alpha beta\nalpha\n", query: "(?=alpha)", replacement: "!", caseSensitive: false, wholeWord: false }, + { name: "case-sensitive", source: "Alpha alpha ALPHA alphabet alpha2\nalpha\n", query: "alpha", replacement: "done", caseSensitive: true, wholeWord: false }, + { name: "whole-word", source: "Alpha alpha alphabet alpha2 _alpha alpha_\nalpha\n", query: "alpha", replacement: "done", caseSensitive: false, wholeWord: true }, + { name: "large-output", source: "alpha\ntail\n", query: "alpha", replacement: "x".repeat(SOURCE_SEARCH_CHUNK_UNITS * 2 + 1), caseSensitive: false, wholeWord: false }, +] as const; + +function nativeSourceRegexOutcome(fixture: typeof SOURCE_REGEX_PARITY_FIXTURES[number]) { + const query = new SearchQuery({ search: fixture.query, replace: fixture.replacement, regexp: true, literal: true, caseSensitive: fixture.caseSensitive, wholeWord: fixture.wholeWord }); + let state = EditorState.create({ doc: fixture.source, extensions: [search()] }); + state = state.update({ effects: setSearchQuery.of(query) }).state; + const cursor = query.getCursor(state); + let count = 0; + for (let match = cursor.next(); !match.done; match = cursor.next()) count += 1; + nativeReplaceAll({ get state() { return state; }, plugin: () => null, dispatch: (spec: TransactionSpec) => { state = state.update(spec).state; } } as unknown as EditorView); + return { count, output: state.doc.toString() }; +} + +for (const width of [1543, 390]) { + for (const fixture of SOURCE_REGEX_PARITY_FIXTURES) { + test(`production source regex parity ${fixture.name} at ${width}px`, async ({ browser }, testInfo) => { + test.skip(process.env.SAMBEE_E2E_PRODUCTION !== "1", "Requires the normal production build"); + const expected = nativeSourceRegexOutcome(fixture); + expect(expected.count).toBeGreaterThan(0); + expect(expected.output).not.toBe(fixture.source); + const proxy = await createCacheableViewerProxy({ initialMarkdown: fixture.source, wordWrapEnabled: false }); + let context: BrowserContext | undefined; + try { + context = await browser.newContext({ viewport: { width, height: 900 }, serviceWorkers: "block", proxy: { server: proxy.url, bypass: "<-loopback>" } }); + await context.addInitScript(() => { + const jobs: { url: string; terminated: boolean; changeChunks: number; continuedChunks: number; maximumChunkUnits: number; insertedUnits: number }[] = []; + (window as typeof window & { sourceParityJobs: typeof jobs }).sourceParityJobs = jobs; + const NativeWorker = window.Worker; + window.Worker = class extends NativeWorker { + private job: typeof jobs[number]; + constructor(url: string | URL, options?: WorkerOptions) { + super(url, options); + this.job = { url: String(url), terminated: false, changeChunks: 0, continuedChunks: 0, maximumChunkUnits: 0, insertedUnits: 0 }; + jobs.push(this.job); + this.addEventListener("message", event => { + const message = event.data; + if (message?.type !== "change") return; + this.job.changeChunks += 1; + this.job.continuedChunks += Number(message.continued); + this.job.insertedUnits += message.insert.length; + this.job.maximumChunkUnits = Math.max(this.job.maximumChunkUnits, message.insert.length); + }); + } + terminate() { this.job.terminated = true; super.terminate(); } + }; + }); + const page = await context.newPage(); + await page.goto("/browse/smb/demo"); + await page.getByRole("button", { name: "Later", exact: true }).click(); + await page.getByRole("button", { name: DEMO_PATH, exact: true }).or(page.getByRole("button", { name: `File: ${DEMO_PATH}`, exact: true })).click(); + await enterMarkdownEditMode(page); + await page.getByRole("textbox", { name: "Markdown editor" }).click(); + await page.keyboard.press("Control+h"); + const controls = page.getByRole("search"); + await controls.getByRole("button", { name: "Use regular expression", exact: true }).click(); + if (fixture.caseSensitive) await controls.getByRole("button", { name: "Match case", exact: true }).click(); + if (fixture.wholeWord) await controls.getByRole("button", { name: "Match whole word", exact: true }).click(); + await controls.getByRole("textbox", { name: "Find", exact: true }).fill(fixture.query); + await controls.getByRole("textbox", { name: "Replace", exact: true }).fill(fixture.replacement); + await expect(controls.getByText(`0 / ${expected.count}`, { exact: true })).toBeVisible(); + await page.mouse.move(0, 0); + await expect(page.getByRole("tooltip")).toHaveCount(0); + await controls.getByRole("button", { name: "Replace all", exact: true }).click(); + await expect(page.getByLabel("Unsaved changes", { exact: true })).toBeVisible(); + await expect(controls.getByRole("alert")).toHaveCount(0); + await controls.getByRole("button", { name: "Close", exact: true }).click(); + await page.getByRole("button", { name: "Save", exact: true }).click(); + await expect.poll(() => proxy.uploads.length).toBe(1); + const upload = proxy.uploads[0]; + const form = await new Response(upload.body, { headers: { "Content-Type": upload.contentType } }).formData(); + const files = [...form.values()].filter((value): value is File => typeof value !== "string"); + expect(files).toHaveLength(1); + expect(await files[0].text()).toBe(expected.output); + await page.getByRole("button", { name: "Close", exact: true }).click(); + await expect(page.getByRole("textbox", { name: "Markdown editor" })).toHaveCount(0); + await expect.poll(() => page.evaluate(() => (window as typeof window & { sourceParityJobs: { terminated: boolean }[] }).sourceParityJobs.filter(job => !job.terminated).length)).toBe(0); + const jobs = await page.evaluate(() => (window as typeof window & { sourceParityJobs: unknown[] }).sourceParityJobs); + const replacements = (jobs as { changeChunks: number; continuedChunks: number; maximumChunkUnits: number; insertedUnits: number }[]).filter(job => job.changeChunks > 0); + expect(replacements).toHaveLength(1); + expect(replacements[0].maximumChunkUnits).toBeLessThanOrEqual(SOURCE_SEARCH_CHUNK_UNITS); + if (fixture.name === "large-output") { + expect(replacements[0].changeChunks).toBe(3); + expect(replacements[0].continuedChunks).toBe(2); + expect(replacements[0].insertedUnits).toBe(fixture.replacement.length); + } + await testInfo.attach("source-regex-parity", { contentType: "application/json", body: JSON.stringify({ fixture: fixture.name, width, count: expected.count, sourceUnits: fixture.source.length, outputUnits: expected.output.length, jobs, policy: "Functional parity with native CodeMirror commands; not timing or memory acceptance" }) }); + expect(proxy.errors).toEqual([]); + } finally { + await context?.close(); + await proxy.close(); + } + }); + } +} + +for (const width of [1543, 390]) { + for (const fault of ["startup", "crash", "decode", "timeout", "delivery-cancel"] as const) { + test(`production source regex lifecycle ${fault} at ${width}px`, async ({ browser }, testInfo) => { + test.skip(process.env.SAMBEE_E2E_PRODUCTION !== "1", "Requires the normal production build"); + const initialMarkdown = "abc 123;\n".repeat(DENSE_SYNTAX_LINE_COUNT); + const proxy = await createCacheableViewerProxy({ initialMarkdown, wordWrapEnabled: false }); + let context: BrowserContext | undefined; + try { + context = await browser.newContext({ viewport: { width, height: 900 }, serviceWorkers: "block", proxy: { server: proxy.url, bypass: "<-loopback>" } }); + await context.addInitScript((fault) => { + const probe = { injected: 0, deliveredChanges: 0, jobs: [] as { sourceSearch: boolean; terminated: boolean }[], replay: () => {} }; + (window as typeof window & { sourceLifecycleProbe: typeof probe }).sourceLifecycleProbe = probe; + const NativeWorker = window.Worker; + window.Worker = class extends NativeWorker { + private job: typeof probe.jobs[number]; + constructor(url: string | URL, options?: WorkerOptions) { + const sourceSearch = String(url).includes("sourceRegexSearch.worker"); + if (sourceSearch && fault === "startup" && probe.injected === 0) { + probe.injected += 1; + throw new Error("Injected source-search construction failure"); + } + super(url, options); + this.job = { sourceSearch, terminated: false }; + probe.jobs.push(this.job); + this.addEventListener("message", event => { + if (!sourceSearch || probe.injected > 0) return; + if (fault === "delivery-cancel") { + if (event.data?.type !== "change") return; + if (probe.deliveredChanges++ === 0) return; + event.stopImmediatePropagation(); + probe.injected += 1; + const stale = event.data; + probe.replay = () => { + this.dispatchEvent(new MessageEvent("message", { data: stale })); + this.dispatchEvent(new MessageEvent("message", { data: { type: "complete", announcements: [], userEvent: "input.replace.all" } })); + }; + } else if (event.data?.type === "ready") { + event.stopImmediatePropagation(); + probe.injected += 1; + if (fault === "crash") this.dispatchEvent(new ErrorEvent("error", { message: "Injected source-search execution failure", cancelable: true })); + if (fault === "decode") this.dispatchEvent(new MessageEvent("messageerror")); + } + }, { capture: true }); + } + terminate() { this.job.terminated = true; super.terminate(); } + }; + }, fault); + const page = await context.newPage(); + await page.goto("/browse/smb/demo"); + await page.getByRole("button", { name: "Later", exact: true }).click(); + await page.getByRole("button", { name: DEMO_PATH, exact: true }).or(page.getByRole("button", { name: `File: ${DEMO_PATH}`, exact: true })).click(); + await enterMarkdownEditMode(page); + const editor = page.getByRole("textbox", { name: "Markdown editor" }); + await editor.click(); + await page.keyboard.press("Control+h"); + const controls = page.getByRole("search"); + await controls.getByRole("button", { name: "Use regular expression", exact: true }).click(); + await controls.getByRole("textbox", { name: "Find", exact: true }).fill("abc (123)"); + if (fault === "delivery-cancel") { + await controls.getByRole("textbox", { name: "Replace", exact: true }).fill("replaced-$1"); + await expect(controls.getByText(`0 / ${DENSE_SYNTAX_LINE_COUNT}`, { exact: true })).toBeVisible(); + await page.mouse.move(0, 0); + await expect(page.getByRole("tooltip")).toHaveCount(0); + await controls.getByRole("button", { name: "Replace all", exact: true }).click(); + await page.waitForFunction(() => (window as typeof window & { sourceLifecycleProbe: { injected: number } }).sourceLifecycleProbe.injected === 1); + await expect(page.getByLabel("Unsaved changes", { exact: true })).toHaveCount(0); + } else { + const messages = { startup: /requires a working dedicated worker/, crash: /worker failed/, decode: /could not be decoded/, timeout: /did not start/ }; + await expect(controls.getByRole("alert")).toContainText(messages[fault]); + await expect(page.getByLabel("Unsaved changes", { exact: true })).toHaveCount(0); + await page.mouse.move(0, 0); + await expect(page.getByRole("tooltip")).toHaveCount(0); + await controls.getByRole("button", { name: "Next match", exact: true }).click(); + await expect(controls.getByText(new RegExp(`^\\d+ / ${DENSE_SYNTAX_LINE_COUNT}$`))).toBeVisible(); + await expect(controls.getByRole("alert")).toHaveCount(0); + } + await editor.focus(); + await page.keyboard.press("Control+End"); + await page.keyboard.type("!"); + await expect(page.getByLabel("Unsaved changes", { exact: true })).toBeVisible(); + if (fault === "delivery-cancel") await page.evaluate(() => (window as typeof window & { sourceLifecycleProbe: { replay: () => void } }).sourceLifecycleProbe.replay()); + await controls.getByRole("button", { name: "Close", exact: true }).click(); + await page.getByRole("button", { name: "Save", exact: true }).click(); + await expect.poll(() => proxy.uploads.length).toBe(1); + const upload = proxy.uploads[0]; + const form = await new Response(upload.body, { headers: { "Content-Type": upload.contentType } }).formData(); + const files = [...form.values()].filter((value): value is File => typeof value !== "string"); + expect(files).toHaveLength(1); + expect(await files[0].text()).toBe(`${initialMarkdown}!`); + await page.getByRole("button", { name: "Close", exact: true }).click(); + await expect(page.getByRole("textbox", { name: "Markdown editor" })).toHaveCount(0); + await expect.poll(() => page.evaluate(() => (window as typeof window & { sourceLifecycleProbe: { jobs: { terminated: boolean }[] } }).sourceLifecycleProbe.jobs.filter(job => !job.terminated).length)).toBe(0); + const evidence = await page.evaluate(() => { + const probe = (window as typeof window & { sourceLifecycleProbe: { injected: number; deliveredChanges: number; jobs: { sourceSearch: boolean; terminated: boolean }[] } }).sourceLifecycleProbe; + return { injected: probe.injected, deliveredChanges: probe.deliveredChanges, jobs: probe.jobs }; + }); + expect(evidence.injected).toBe(1); + expect(evidence.jobs.filter(job => job.sourceSearch).length).toBeGreaterThan(1); + if (fault === "delivery-cancel") expect(evidence.deliveredChanges).toBe(2); + await testInfo.attach("source-regex-lifecycle", { contentType: "application/json", body: JSON.stringify({ fault, width, evidence, policy: "Test-only constructor/event/ready-message injection; functional recovery and stale-delivery rejection, not timing or memory acceptance" }) }); + expect(proxy.errors).toEqual([]); + } finally { + await context?.close(); + await proxy.close(); + } + }); + } +} + +interface ReplacementFeedbackProbe { + hold: boolean; + additions: number; + frames: { width: number; height: number; original: boolean; animation: string }[]; + release: () => void; +} + +for (const width of [1543, 390]) { + for (const regexp of [false, true]) { + test(`production replacement feedback ${regexp ? "regex keyboard" : "literal toolbar"} at ${width}px`, async ({ browser }, testInfo) => { + test.skip(process.env.SAMBEE_E2E_PRODUCTION !== "1", "Requires the normal production build"); + const initialMarkdown = `${"abc 123;\n".repeat(DENSE_SYNTAX_LINE_COUNT)}tail`; + const replacement = "x".repeat(DENSE_EXPANSION_REPLACEMENT_UNITS); + const expected = `${`${replacement};\n`.repeat(DENSE_SYNTAX_LINE_COUNT)}tail`; + const proxy = await createCacheableViewerProxy({ initialMarkdown, wordWrapEnabled: false }); + let context: BrowserContext | undefined; + try { + context = await browser.newContext({ viewport: { width, height: 900 }, serviceWorkers: "block", proxy: { server: proxy.url, bypass: "<-loopback>" } }); + await context.addInitScript(() => { + const nativeFrame = window.requestAnimationFrame.bind(window); + const held: FrameRequestCallback[] = []; + const probe: ReplacementFeedbackProbe = { hold: false, additions: 0, frames: [], release: () => { + probe.hold = false; + for (const callback of held.splice(0)) nativeFrame(callback); + } }; + (window as typeof window & { replacementFeedbackProbe: ReplacementFeedbackProbe }).replacementFeedbackProbe = probe; + window.requestAnimationFrame = callback => nativeFrame(time => { + if (probe.hold && document.querySelector(".sambee-source-replacement-feedback")) held.push(callback); + else callback(time); + }); + new MutationObserver(records => { + for (const record of records) for (const node of record.addedNodes) { + if (!(node instanceof HTMLElement) || !node.matches(".sambee-source-replacement-feedback")) continue; + probe.additions += 1; + nativeFrame(() => { + const rect = node.getBoundingClientRect(); + probe.frames.push({ width: rect.width, height: rect.height, original: Boolean(document.querySelector(".cm-content")?.textContent?.includes("abc 123")), animation: node.querySelector("span")?.getAnimations()[0]?.playState ?? "unavailable" }); + }); + } + }).observe(document, { childList: true, subtree: true }); + }); + const page = await context.newPage(); + const errors: string[] = []; + page.on("pageerror", error => errors.push(error.message)); + await page.goto("/browse/smb/demo"); + await page.getByRole("button", { name: "Later", exact: true }).click(); + await page.getByRole("button", { name: DEMO_PATH, exact: true }).or(page.getByRole("button", { name: `File: ${DEMO_PATH}`, exact: true })).click(); + await enterMarkdownEditMode(page); + const editor = page.getByRole("textbox", { name: "Markdown editor" }); + await editor.click(); + await page.keyboard.press("Control+h"); + const controls = page.getByRole("search"); + if (regexp) await controls.getByRole("button", { name: "Use regular expression", exact: true }).click(); + const find = controls.getByRole("textbox", { name: "Find", exact: true }); + const replace = controls.getByRole("textbox", { name: "Replace", exact: true }); + await find.fill("tail"); + await replace.fill("done"); + await expect(controls.getByText(/^[01] \/ 1$/)).toBeVisible(); + await page.mouse.move(0, 0); + await controls.getByRole("button", { name: "Replace all", exact: true }).click(); + await expect(page.getByLabel("Unsaved changes", { exact: true })).toBeVisible(); + expect(await page.evaluate(() => (window as typeof window & { replacementFeedbackProbe: ReplacementFeedbackProbe }).replacementFeedbackProbe.additions)).toBe(0); + await editor.focus(); + await page.keyboard.press("Control+z"); + await expect(page.getByLabel("Unsaved changes", { exact: true })).toHaveCount(0); + await find.fill("abc 123"); + await replace.fill(replacement); + await expect(controls.getByText(`0 / ${DENSE_SYNTAX_LINE_COUNT}`, { exact: true })).toBeVisible(); + await page.evaluate(() => { (window as typeof window & { replacementFeedbackProbe: ReplacementFeedbackProbe }).replacementFeedbackProbe.hold = true; }); + if (regexp) { await replace.focus(); await replace.press("Control+Alt+Enter"); } + else await controls.getByRole("button", { name: "Replace all", exact: true }).click(); + const feedback = page.locator(".sambee-source-replacement-feedback"); + await expect(feedback).toBeVisible(); + await expect(feedback).toHaveAttribute("role", "status"); + await expect(editor).toHaveAttribute("aria-busy", "true"); + await expect.poll(() => page.evaluate(() => (window as typeof window & { replacementFeedbackProbe: ReplacementFeedbackProbe }).replacementFeedbackProbe.frames.length)).toBeGreaterThan(0); + await testInfo.attach("replacement-feedback.png", { body: await page.screenshot(), contentType: "image/png" }); + const frames = await page.evaluate(() => (window as typeof window & { replacementFeedbackProbe: ReplacementFeedbackProbe }).replacementFeedbackProbe.frames); + expect(frames.every(frame => frame.width > 0 && frame.height > 0 && frame.original && frame.animation === "running")).toBe(true); + await page.evaluate(() => (window as typeof window & { replacementFeedbackProbe: ReplacementFeedbackProbe }).replacementFeedbackProbe.release()); + await expect(page.getByLabel("Unsaved changes", { exact: true })).toBeVisible(); + await expect(feedback).toHaveCount(0); + await expect(editor).not.toHaveAttribute("aria-busy"); + await editor.focus(); + await page.keyboard.press("Control+z"); + await expect(page.getByLabel("Unsaved changes", { exact: true })).toHaveCount(0); + await page.keyboard.press("Control+Shift+Z"); + await expect(page.getByLabel("Unsaved changes", { exact: true })).toBeVisible(); + await controls.getByRole("button", { name: "Close", exact: true }).click(); + await page.getByRole("button", { name: "Save", exact: true }).click(); + await expect.poll(() => proxy.uploads.length).toBe(1); + const upload = proxy.uploads[0]; + const form = await new Response(upload.body, { headers: { "Content-Type": upload.contentType } }).formData(); + const files = [...form.values()].filter((value): value is File => typeof value !== "string"); + expect(files).toHaveLength(1); + expect(await files[0].text()).toBe(expected); + await page.getByRole("button", { name: "Close", exact: true }).click(); + await expect(editor).toHaveCount(0); + await expect(feedback).toHaveCount(0); + expect(errors).toEqual([]); + expect(proxy.errors).toEqual([]); + await testInfo.attach("replacement-feedback", { contentType: "application/json", body: JSON.stringify({ width, regexp, frames, exactSaveUnits: expected.length, policy: "Functional feedback/paint and atomic-history check with held animation frames; not timing acceptance." }) }); + } finally { + await context?.close(); + await proxy.close(); + } + }); + } +} + +for (const width of [1543, 390]) { + for (const fixture of ["dense", "long-line"] as const) { + for (const expansion of [false, true]) { + test(`production source ${expansion ? "dense expansion" : "responsiveness"} ${fixture} at ${width}px`, async ({ browser }, testInfo) => { + test.skip(process.env.SAMBEE_E2E_PRODUCTION !== "1", "Requires the normal production build"); + const profiling = process.env.SAMBEE_E2E_TRACE === "1"; + const memoryMode = process.env.SAMBEE_E2E_MEMORY === "1"; + const diagnostic = profiling || memoryMode; + test.skip(diagnostic && testInfo.project.name !== "chromium", "Source memory/CPU diagnostics require Chromium CDP"); + if ((memoryMode && profiling) || (diagnostic && process.env.SAMBEE_E2E_BUDGETS === "1")) throw new Error("Source timing, GC and tracing must run separately"); + const initialMarkdown = `abc 123;${fixture === "dense" ? "\n" : " "}`.repeat(DENSE_SYNTAX_LINE_COUNT); + const replacement = expansion ? "x".repeat(DENSE_EXPANSION_REPLACEMENT_UNITS) : "replaced-$1"; + const expectedMarkdown = initialMarkdown.replace(/abc (123)/g, replacement); + if (expansion) { + expect(initialMarkdown).toHaveLength(54_000); + expect(expectedMarkdown).toHaveLength(DENSE_EXPANSION_OUTPUT_UNITS); + } + const proxy = await createCacheableViewerProxy({ initialMarkdown, wordWrapEnabled: false }); + let context: BrowserContext | undefined; + try { + context = await browser.newContext({ viewport: { width, height: 900 }, serviceWorkers: "block", proxy: { server: proxy.url, bypass: "<-loopback>" } }); + await context.addInitScript(() => { + const probe: SourceResponsivenessProbe = { measures: [], jobs: [], timerGaps: [], completionMs: null, stop: () => {} }; + (window as typeof window & { sourceResponsivenessProbe: SourceResponsivenessProbe }).sourceResponsivenessProbe = probe; + const NativeWorker = window.Worker; + window.Worker = class extends NativeWorker { + private job: SourceResponsivenessProbe["jobs"][number]; + constructor(url: string | URL, options?: WorkerOptions) { + super(url, options); + this.job = { url: String(url), ranges: 0, rangeBatches: 0, changeChunks: 0, changeUnits: 0, maximumChunkUnits: 0, terminated: false }; + probe.jobs.push(this.job); + this.addEventListener("message", event => { + const message = event.data; + if (message?.type === "ranges") { this.job.ranges += message.ranges.length; this.job.rangeBatches += 1; } + if (message?.type === "change") { this.job.changeChunks += 1; this.job.changeUnits += message.insert.length; this.job.maximumChunkUnits = Math.max(this.job.maximumChunkUnits, message.insert.length); } + }); + } + terminate() { this.job.terminated = true; super.terminate(); } + }; + let commandStart: number | undefined; + let previousTick: number | undefined; + const timer = window.setInterval(() => { + if (commandStart === undefined || probe.completionMs !== null) return; + const now = performance.now(); + if (previousTick !== undefined) probe.timerGaps.push(now - previousTick); + previousTick = now; + }, 0); + document.addEventListener("click", event => { + if (!(event.target instanceof Element) || !event.target.closest('button[aria-label="Replace all"]')) return; + commandStart = performance.now(); + previousTick = commandStart; + }, true); + const observer = new PerformanceObserver(list => { + for (const entry of list.getEntries()) { + if (!entry.name.startsWith("sambee:source-")) continue; + const measure = entry as PerformanceMeasure; + probe.measures.push({ name: measure.name, startTime: measure.startTime, duration: measure.duration, detail: measure.detail }); + if (measure.name === "sambee:source-search:commit" && measure.detail.operation === "replace-all") { + const start = commandStart; + if (start === undefined) continue; + const observeParentUpdate = () => { + if (!document.querySelector('[role="status"][aria-label="Unsaved changes"]')) { + requestAnimationFrame(observeParentUpdate); + return; + } + requestAnimationFrame(() => requestAnimationFrame(() => { probe.completionMs = performance.now() - start; })); + }; + requestAnimationFrame(observeParentUpdate); + } + } + }); + observer.observe({ type: "measure" }); + probe.stop = () => { observer.disconnect(); clearInterval(timer); }; + }); + const page = await context.newPage(); + await page.goto("/browse/smb/demo"); + await page.getByRole("button", { name: "Later", exact: true }).click(); + const memorySession = memoryMode ? await context.newCDPSession(page) : undefined; + const memorySamples: { stage: string; usedSize: number; totalSize: number }[] = []; + const sampleMemory = async (stage: string) => { + if (!memorySession) return; + await memorySession.send("HeapProfiler.collectGarbage"); + const heap = await memorySession.send("Runtime.getHeapUsage"); + memorySamples.push({ stage, usedSize: heap.usedSize, totalSize: heap.totalSize }); + }; + await sampleMemory("pre-open"); + await page.getByRole("button", { name: DEMO_PATH, exact: true }).or(page.getByRole("button", { name: `File: ${DEMO_PATH}`, exact: true })).click(); + await enterMarkdownEditMode(page); + const editor = page.getByRole("textbox", { name: "Markdown editor" }); + await editor.click(); + await page.keyboard.press("Control+h"); + const search = page.getByRole("search"); + await search.getByRole("button", { name: "Use regular expression", exact: true }).click(); + await search.getByRole("textbox", { name: "Find", exact: true }).fill("abc (123)"); + await search.getByRole("textbox", { name: "Replace", exact: true }).fill(replacement); + await expect(search.getByText(`0 / ${DENSE_SYNTAX_LINE_COUNT}`, { exact: true })).toBeVisible(); + await sampleMemory("counted-results"); + await page.mouse.move(0, 0); + await expect(page.getByRole("tooltip")).toHaveCount(0); + const profiler = profiling ? await context.newCDPSession(page) : undefined; + if (profiler) { + await profiler.send("Profiler.enable"); + await profiler.send("Profiler.start"); + } + await search.getByRole("button", { name: "Replace all", exact: true }).click(); + await page.waitForFunction(() => (window as typeof window & { sourceResponsivenessProbe: SourceResponsivenessProbe }).sourceResponsivenessProbe.completionMs !== null); + await expect(search.getByText("0 / 0", { exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "Save", exact: true })).toBeEnabled(); + await sampleMemory("post-replacement"); + const evidence = await page.evaluate(() => { + const probe = (window as typeof window & { sourceResponsivenessProbe: SourceResponsivenessProbe }).sourceResponsivenessProbe; + probe.stop(); + return { measures: probe.measures, jobs: probe.jobs, completionMs: probe.completionMs, maximumTimerGap: probe.timerGaps.length ? Math.max(...probe.timerGaps) : null }; + }); + if (profiler) { + const profile = await profiler.send("Profiler.stop"); + await testInfo.attach("source-dispatch-cpu-profile", { contentType: "application/json", body: JSON.stringify(profile) }); + await profiler.detach(); + } + const commit = evidence.measures.filter(entry => entry.name === "sambee:source-search:commit" && entry.detail.operation === "replace-all"); + const serialization = evidence.measures.filter(entry => entry.name === "sambee:source-editor:serialize"); + const publication = evidence.measures.filter(entry => entry.name === "sambee:source-editor:publish"); + const budgets = evaluateSourceResponsivenessBudgets({ atomicCommit: commit[0]?.duration, serialization: serialization[0]?.duration, synchronousPublication: publication[0]?.duration, maximumTimerGap: evidence.maximumTimerGap }, diagnostic); + await testInfo.attach("source-responsiveness", { contentType: "application/json", body: JSON.stringify({ fixture, width, engine: testInfo.project.name, trial: testInfo.repeatEachIndex, operation: expansion ? "dense-expansion" : "original", surface: "markdown-source", cacheState: "fresh-context/fresh-workers", sourceUnits: initialMarkdown.length, outputUnits: expectedMarkdown.length, evidence, budgets, enforced: process.env.SAMBEE_E2E_BUDGETS === "1", command: `SAMBEE_E2E_PRODUCTION=1 SAMBEE_E2E_BUDGETS=1 npm run test:e2e:all -- --grep 'production source ${expansion ? "dense expansion" : "responsiveness"}' --workers=1 --repeat-each=3 --reporter=json`, limitations: ["Two animation frames are a scheduling proxy, not hardware paint or isolated React CPU", "Transfer counts are not retained/peak heap measurements", "Synchronous publication includes normalization and the parent callback, not deferred React work"] }) }); + expect(commit).toHaveLength(1); + expect(serialization).toHaveLength(1); + expect(publication).toHaveLength(1); + expect(evidence.measures.filter(entry => entry.name === "sambee:source-editor:normalize")).toHaveLength(1); + expect(evidence.measures.filter(entry => entry.name === "sambee:source-editor:parent-update")).toHaveLength(1); + expect(budgets.metrics.filter(metric => metric.status === "invalid")).toEqual([]); + expect(commit[0].detail).toMatchObject({ documentUnits: initialMarkdown.length, changes: DENSE_SYNTAX_LINE_COUNT }); + expect(serialization[0].detail.documentUnits).toBe(expectedMarkdown.length); + expect(evidence.jobs.some(job => job.ranges === DENSE_SYNTAX_LINE_COUNT && job.rangeBatches > 1)).toBe(true); + const replacementJobs = evidence.jobs.filter(job => job.changeChunks > 0); + expect(replacementJobs).toHaveLength(1); + expect(replacementJobs[0].changeUnits).toBe((expansion ? DENSE_EXPANSION_REPLACEMENT_UNITS : "replaced-123".length) * DENSE_SYNTAX_LINE_COUNT); + expect(replacementJobs[0].maximumChunkUnits).toBeLessThanOrEqual(SOURCE_SEARCH_CHUNK_UNITS); + await search.getByRole("button", { name: "Close", exact: true }).click(); + await editor.focus(); + await page.keyboard.press("Control+z"); + await expect(page.getByLabel("Unsaved changes", { exact: true })).toHaveCount(0); + await page.keyboard.press("Control+h"); + await search.getByRole("textbox", { name: "Find", exact: true }).fill("abc (123)"); + await expect(search.getByText(`0 / ${DENSE_SYNTAX_LINE_COUNT}`, { exact: true })).toBeVisible(); + await search.getByRole("button", { name: "Close", exact: true }).click(); + await editor.focus(); + await page.keyboard.press("Control+Shift+z"); + await expect(page.getByLabel("Unsaved changes", { exact: true })).toBeVisible(); + await page.getByRole("button", { name: "Save", exact: true }).click(); + await expect.poll(() => proxy.uploads.length).toBe(1); + const upload = proxy.uploads[0]; + const form = await new Response(upload.body, { headers: { "Content-Type": upload.contentType } }).formData(); + const files = [...form.values()].filter((value): value is File => typeof value !== "string"); + expect(files).toHaveLength(1); + expect(await files[0].text()).toBe(expectedMarkdown); + await page.getByRole("button", { name: "Close", exact: true }).click(); + await expect(page.getByRole("textbox", { name: "Markdown editor" })).toHaveCount(0); + await expect.poll(() => page.evaluate(() => (window as typeof window & { sourceResponsivenessProbe: SourceResponsivenessProbe }).sourceResponsivenessProbe.jobs.filter(job => !job.terminated).length)).toBe(0); + await sampleMemory("post-close"); + if (memorySession) { + const loadedRendererHeapCeilingBytes = 64 * 1024 * 1024; + await testInfo.attach("source-renderer-memory", { contentType: "application/json", body: JSON.stringify({ fixture, width, browserVersion: browser.version(), method: "Chromium CDP Runtime.getHeapUsage after HeapProfiler.collectGarbage at named boundaries", samples: memorySamples, loadedRendererHeapCeilingBytes, limitations: ["Observed post-GC renderer heap, not peak/worker/native/private-process memory", "Counted-results includes the whole loaded viewer, not isolated match-range bytes", "One close isn't six warmed close/reopen growth acceptance"] }) }); + expect(memorySamples.map(sample => sample.stage)).toEqual(["pre-open", "counted-results", "post-replacement", "post-close"]); + for (const sample of memorySamples) expect(Number.isFinite(sample.usedSize) && sample.usedSize > 0).toBe(true); + for (const sample of memorySamples.filter(sample => sample.stage === "counted-results" || sample.stage === "post-replacement")) expect(sample.usedSize).toBeLessThanOrEqual(loadedRendererHeapCeilingBytes); + await memorySession.detach(); + } + expect(proxy.errors).toEqual([]); + if (process.env.SAMBEE_E2E_BUDGETS === "1") expect(budgets.status, JSON.stringify(budgets)).toBe("pass"); + } finally { + await context?.close(); + await proxy.close(); + } + }); + } + } +} + +for (const width of [1543, 390]) { + for (const fixture of TABLE_EDIT_CASES) { + test(`production large table editing ${fixture.name} at ${width}px`, async ({ browser }, testInfo) => { + test.skip(process.env.SAMBEE_E2E_PRODUCTION !== "1", "Requires the normal production build"); + const profiling = process.env.SAMBEE_E2E_TRACE === "1"; + if (process.env.SAMBEE_E2E_MEMORY === "1" || (profiling && process.env.SAMBEE_E2E_BUDGETS === "1")) throw new Error("Table editing timing must run separately from GC and profiling"); + if (profiling && testInfo.project.name !== "chromium" && (process.env.MOZ_PROFILER_STARTUP !== "1" || !process.env.MOZ_PROFILER_SHUTDOWN)) throw new Error("Firefox table diagnostics require native startup profiling and a shutdown profile path"); + const startedAt = performance.now(); + const lifecycle: { stage: string; elapsedMs: number }[] = []; + const recordStage = (stage: string) => lifecycle.push({ stage, elapsedMs: performance.now() - startedAt }); + const pageErrors: string[] = []; + const rows = Array.from({ length: TABLE_ENVELOPE_ROWS }, (_, index) => `| ${index + 1} | row ${index + 1} needle |`); + const source = `| Key | Value |\n| --- | --- |\n${rows.join("\n")}\n`; + const cellWidth = Math.max(String(TABLE_ENVELOPE_ROWS).length, String(fixture.row).length + (fixture.action === "line-break" ? "
".length : 0) + TABLE_EDIT_PAYLOAD.length); + const valueWidth = `row ${TABLE_ENVELOPE_ROWS} needle`.length; + const expectedRows = rows.map((_, index) => { + const value = `${index + 1}${index + 1 === fixture.row ? `${fixture.action === "line-break" ? "
" : ""}${TABLE_EDIT_PAYLOAD}` : ""}`; + return `| ${value.padEnd(cellWidth)} | ${`row ${index + 1} needle`.padEnd(valueWidth)} |`; + }); + const expected = `\n| ${"Key".padEnd(cellWidth)} | ${"Value".padEnd(valueWidth)} |\n| ${"-".repeat(cellWidth)} | ${"-".repeat(valueWidth)} |\n${expectedRows.join("\n")}\n`.replace("
", "
"); + const proxy = await createCacheableViewerProxy({ initialMarkdown: source }); + let context: BrowserContext | undefined; + try { + context = await browser.newContext({ viewport: { width, height: 900 }, serviceWorkers: "block", proxy: { server: proxy.url, bypass: "<-loopback>" } }); + const page = await context.newPage(); + page.on("pageerror", error => pageErrors.push(error.stack ?? error.message)); + const markPhase = async (phase: string) => { + if (profiling) await page.evaluate(name => performance.mark(`sambee:table-phase:${name}`), phase); + }; + await page.goto("/browse/smb/demo"); + await page.getByRole("button", { name: "Later", exact: true }).click(); + await page.getByRole("button", { name: DEMO_PATH, exact: true }).or(page.getByRole("button", { name: `File: ${DEMO_PATH}`, exact: true })).click(); + await enterMarkdownEditModeWithTable(page); + await expect(page.locator(".tbl-data-cell")).toHaveCount(TABLE_ENVELOPE_ROWS * 2); + const cell = page.locator(".tbl-data-cell .tbl-cell-view").nth((fixture.row - 1) * 2); + const nested = await activateCellEditor(cell); + await expect(nested).toHaveText(String(fixture.row)); + await page.keyboard.press("End"); + recordStage("cell-ready"); + await page.evaluate(() => { + const probe = { content: document.activeElement, measures: [] as { name: string; duration: number; startTime: number; detail: unknown }[], inputs: [] as number[], gaps: [] as number[], events: [] as { type: string; phase: string; contentId: number | null; connected: boolean; trusted: boolean; key?: string; data?: string | null; inputType?: string; isComposing?: boolean; text: string | null; nested: boolean; prevented: boolean; activeText: string | null; selectionText: string | null }[], stop: () => {} }; + (window as typeof window & { tableEditProbe: typeof probe }).tableEditProbe = probe; + let previous: number | undefined; + const input = () => { + const start = performance.now(); + previous ??= start; + requestAnimationFrame(() => requestAnimationFrame(() => probe.inputs.push(performance.now() - start))); + }; + document.addEventListener("beforeinput", input, true); + const contentIds = new WeakMap(); + let nextContentId = 0; + const observeInput = (event: Event) => { + if (!(event.target instanceof Element) || probe.events.length >= 256) return; + const content = event.target.closest(".cm-content"); + if (content && !contentIds.has(content)) contentIds.set(content, ++nextContentId); + const record = (phase: string) => { + if (probe.events.length >= 256) return; + const selection = document.getSelection(); + probe.events.push({ type: event.type, phase, contentId: content ? contentIds.get(content) ?? null : null, connected: content?.isConnected ?? false, trusted: event.isTrusted, key: event instanceof KeyboardEvent ? event.key : undefined, data: event instanceof InputEvent || event instanceof CompositionEvent ? event.data : undefined, inputType: event instanceof InputEvent ? event.inputType : undefined, isComposing: event instanceof InputEvent ? event.isComposing : undefined, text: content?.closest(".tbl-cell-editor") ? content.textContent : null, nested: Boolean(content?.closest(".tbl-cell-editor")), prevented: event.defaultPrevented, activeText: document.activeElement?.closest(".tbl-cell-editor")?.textContent ?? null, selectionText: selection?.anchorNode?.textContent ?? null }); + }; + record("capture"); + queueMicrotask(() => record("after-event")); + }; + const eventTypes = ["keydown", "beforeinput", "input", "compositionstart", "compositionend", "focusin", "focusout"]; + for (const type of eventTypes) document.addEventListener(type, observeInput, true); + const timer = setInterval(() => { + if (previous === undefined) return; + const now = performance.now(); + probe.gaps.push(now - previous); + previous = now; + }, 0); + const observer = new PerformanceObserver(list => { + for (const entry of list.getEntries()) if (entry.name.startsWith("sambee:source-editor:") || entry.name.startsWith("sambee:edit-preparation:")) probe.measures.push({ name: entry.name, duration: entry.duration, startTime: entry.startTime, detail: entry instanceof PerformanceMeasure ? entry.detail : null }); + }); + observer.observe({ type: "measure" }); + probe.stop = () => { observer.disconnect(); clearInterval(timer); document.removeEventListener("beforeinput", input, true); for (const type of eventTypes) document.removeEventListener(type, observeInput, true); }; + }); + await captureViewerScheduling(page, testInfo, async () => { + await markPhase("typing-start"); + let compositionMethod: string | null = null; + const composing = fixture.action === "composition" || fixture.action === "composition-close"; + const saveOnClose = fixture.action === "close" || fixture.action === "composition-close"; + if (fixture.action === "line-break") await page.keyboard.press("Shift+Enter"); + if (composing && testInfo.project.name === "chromium") { + compositionMethod = "Chromium CDP composition and commit with untrusted composition-end event; not physical IME"; + const session = await context.newCDPSession(page); + try { + await session.send("Input.imeSetComposition", { text: TABLE_EDIT_PAYLOAD, selectionStart: TABLE_EDIT_PAYLOAD.length, selectionEnd: TABLE_EDIT_PAYLOAD.length }); + await session.send("Input.insertText", { text: TABLE_EDIT_PAYLOAD }); + } finally { await session.detach(); } + } else if (composing) { + compositionMethod = "Firefox browser composition from Playwright text insertion; not physical IME acceptance"; + await page.keyboard.insertText(TABLE_EDIT_PAYLOAD); + } else await page.keyboard.type(TABLE_EDIT_PAYLOAD, { delay: SOURCE_TYPING_KEY_DELAY_MS }); + recordStage("input-complete"); + await markPhase("typing-complete"); + await expect(nested).toBeFocused(); + await markPhase("save-start"); + if (saveOnClose) { + await page.getByRole("button", { name: "Close", exact: true }).click(); + await page.getByRole("dialog").filter({ has: page.getByRole("heading", { name: "Unsaved changes", exact: true }) }).getByRole("button", { name: "Save", exact: true }).click(); + } else await page.getByRole("button", { name: "Save", exact: true }).click(); + recordStage("save-clicked"); + await testInfo.attach("large-table-input", { contentType: "application/json", body: JSON.stringify(await page.evaluate(() => (window as typeof window & { tableEditProbe: { events: unknown[] } }).tableEditProbe.events)) }); + await expect.poll(() => proxy.uploads.length).toBe(1); + if (composing) { + const compositionEvents = await page.evaluate(() => (window as typeof window & { tableEditProbe: { events: { type: string; trusted: boolean; phase: string; isComposing?: boolean }[] } }).tableEditProbe.events.filter(event => event.phase === "capture")); + expect(compositionEvents.some(event => event.type === "compositionstart" && event.trusted)).toBe(true); + expect(compositionEvents.some(event => event.type === "beforeinput" && event.isComposing && event.trusted)).toBe(true); + expect(compositionEvents.some(event => event.type === "compositionend" && (event.trusted || testInfo.project.name === "chromium")), JSON.stringify(compositionEvents)).toBe(true); + } + const upload = proxy.uploads[0]; + const form = await new Response(upload.body, { headers: { "Content-Type": upload.contentType } }).formData(); + const files = [...form.values()].filter((value): value is File => typeof value !== "string"); + expect(files).toHaveLength(1); + const saved = await files[0].text(); + await testInfo.attach("large-table-save.txt", { contentType: "text/plain", body: saved }); + expect(saved === expected, `Exact canonical Save mismatch: expected ${expected.length} units, received ${saved.length}`).toBe(true); + recordStage("save-verified"); + await markPhase("save-verified"); + const evidence = await page.evaluate(async () => { + await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))); + const probe = (window as typeof window & { tableEditProbe: { content: Element | null; measures: { name: string; duration: number }[]; inputs: number[]; gaps: number[]; stop: () => void } }).tableEditProbe; + probe.stop(); + return { measures: probe.measures, inputs: probe.inputs, gaps: probe.gaps, retainedCell: probe.content?.isConnected ?? false, cellFocused: document.activeElement === probe.content }; + }); + const frames = [...evidence.inputs].sort((left, right) => left - right); + const maximumStage = (name: string) => { const stages = evidence.measures.filter(entry => entry.name === name); return stages.length ? Math.max(...stages.map(entry => entry.duration)) : null; }; + const budgets = evaluateSourceTypingBudgets({ keyboardTwoFrameP95: frames[Math.ceil(frames.length * 0.95) - 1], maximumTimerGap: evidence.gaps.length ? Math.max(...evidence.gaps) : null, serialization: maximumStage("sambee:source-editor:serialize"), synchronousPublication: maximumStage("sambee:source-editor:publish") }); + await testInfo.attach("large-table-editing", { contentType: "application/json", body: JSON.stringify({ fixture: fixture.name, row: fixture.row, rows: TABLE_ENVELOPE_ROWS, width, engine: testInfo.project.name, trial: testInfo.repeatEachIndex, surface: "markdown-table", cacheState: "fresh-context/fresh-workers", sourceUnits: source.length, outputUnits: expected.length, payload: TABLE_EDIT_PAYLOAD, compositionMethod, saveOnClose, evidence, budgets, diagnostic: profiling, enforced: process.env.SAMBEE_E2E_BUDGETS === "1", limitations: ["Beforeinput-to-two-frame scheduling proxy, not physical input/paint", "Final native dispatch is not independently attributed", "Physical IME remains a separate gate"] }) }); + for (const stage of ["serialize", "publish", "normalize", "parent-update"]) expect(evidence.measures.some(entry => entry.name === `sambee:source-editor:${stage}`)).toBe(true); + expect(budgets.metrics.filter(metric => metric.status === "invalid")).toEqual([]); + if (!saveOnClose) { + expect(evidence.retainedCell).toBe(true); + expect(evidence.cellFocused).toBe(true); + } + if (saveOnClose) await expect(page.getByRole("textbox", { name: "Markdown editor" })).toHaveCount(0); + recordStage("before-clean-check"); + await expect(page.getByLabel("Unsaved changes", { exact: true })).toHaveCount(0); + if (fixture.action === "line-break") { + const savedCellText = await nested.textContent(); + const renderedCellText = `${fixture.row}\n${TABLE_EDIT_PAYLOAD}`; + await page.keyboard.type("!"); + await expect(nested).toHaveText(`${savedCellText}!`); + await expect(page.getByLabel("Unsaved changes", { exact: true })).toBeVisible(); + await page.keyboard.press("Control+z"); + await expect(cell).toHaveText(renderedCellText); + await expect(page.getByLabel("Unsaved changes", { exact: true })).toHaveCount(0); + await page.keyboard.press("Control+Shift+Z"); + await expect(cell).toHaveText(`${renderedCellText}!`); + await expect(page.getByLabel("Unsaved changes", { exact: true })).toBeVisible(); + await page.keyboard.press("Control+z"); + await expect(cell).toHaveText(renderedCellText); + await expect(page.getByLabel("Unsaved changes", { exact: true })).toHaveCount(0); + recordStage("post-save-history-verified"); + } + recordStage("before-close"); + await markPhase("close-start"); + await page.getByRole("button", { name: "Close", exact: true }).click(); + recordStage("close-clicked"); + await expect(page.getByRole("textbox", { name: "Markdown editor" })).toHaveCount(0); + await expect.poll(() => page.workers().length).toBe(0); + recordStage("closed"); + await markPhase("closed"); + expect(pageErrors).toEqual([]); + expect(proxy.errors).toEqual([]); + if (process.env.SAMBEE_E2E_BUDGETS === "1") expect(budgets.status, JSON.stringify(budgets)).toBe("pass"); + }); + } finally { + await testInfo.attach("large-table-lifecycle", { contentType: "application/json", body: JSON.stringify({ lifecycle, pageErrors }) }); + await context?.close(); + await proxy.close(); + } + }); + } +} + async function enterMarkdownEditModeWithTable(page: Page): Promise { await enterMarkdownEditMode(page); await page.locator(".tbl-table-widget").first().waitFor(); diff --git a/frontend/e2e/viewer-performance-budgets.spec.ts b/frontend/e2e/viewer-performance-budgets.spec.ts new file mode 100644 index 000000000..a1c60e839 --- /dev/null +++ b/frontend/e2e/viewer-performance-budgets.spec.ts @@ -0,0 +1,13 @@ +import { expect, test } from "@playwright/test"; +import { readFile } from "node:fs/promises"; +import { evaluateViewerAcceptanceReport, VIEWER_ACCEPTANCE_CASES } from "./viewer-performance-budgets"; + +if (process.env.SAMBEE_E2E_REPORT) { + test("recorded viewer acceptance report is complete and passing", async () => { + const slice = process.env.SAMBEE_E2E_SLICE as keyof typeof VIEWER_ACCEPTANCE_CASES; + expect(Object.keys(VIEWER_ACCEPTANCE_CASES)).toContain(slice); + const report = JSON.parse(await readFile(process.env.SAMBEE_E2E_REPORT!, "utf8")); + const coverage = evaluateViewerAcceptanceReport(slice, report); + expect(coverage.status, JSON.stringify(coverage)).toBe("pass"); + }); +} diff --git a/frontend/e2e/viewer-performance-budgets.ts b/frontend/e2e/viewer-performance-budgets.ts new file mode 100644 index 000000000..f55d7a2c0 --- /dev/null +++ b/frontend/e2e/viewer-performance-budgets.ts @@ -0,0 +1,189 @@ +export const VIEWER_TIMING_TARGETS_MS = { + documentCompletion: 1_500, + firstExactSearch: 1_000, + changedQuery: 100, + lastMatchNavigation: 100, + maximumTimerGap: 50, +} as const; + +export type ViewerTimingMetric = keyof typeof VIEWER_TIMING_TARGETS_MS; +export type ViewerTimingSamples = Partial>; + +export interface ViewerAcceptanceSample { + identity: string; + status: string; + retry: number; +} + +export function evaluateViewerAcceptanceCoverage(expected: readonly string[], samples: readonly ViewerAcceptanceSample[]) { + const expectedIdentities = new Set(expected); + const observed = new Map(); + for (const sample of samples) { + const entries = observed.get(sample.identity) ?? []; + entries.push(sample); + observed.set(sample.identity, entries); + } + const missing = expected.filter(identity => !observed.has(identity)); + const unexpected = [...observed.keys()].filter(identity => !expectedIdentities.has(identity)); + const duplicate = [...observed].filter(([, entries]) => entries.length !== 1).map(([identity]) => identity); + const invalid = samples.filter(sample => sample.status !== "pass" || sample.retry !== 0).map(sample => sample.identity); + const validExpectation = expected.length > 0 && expectedIdentities.size === expected.length; + return { + status: validExpectation && !missing.length && !unexpected.length && !duplicate.length && !invalid.length ? "pass" : "fail", + expectedCount: expected.length, + observedCount: samples.length, + validExpectation, + missing, + unexpected, + duplicate, + invalid, + }; +} + +const ACCEPTANCE_WIDTHS = [1543, 390] as const; +const ACCEPTANCE_ENGINES = ["chromium", "firefox"] as const; +const PUBLICATION_FIXTURES = ["dense", "near-safeguard", "host-envelope", "many-blocks", "long-line"] as const; +const sourceCases = (operation: string) => ACCEPTANCE_WIDTHS.flatMap(width => ["dense", "long-line"].map(fixture => `production source ${operation} ${fixture} at ${width}px`)); + +export const VIEWER_ACCEPTANCE_CASES = { + "source-responsiveness": sourceCases("responsiveness"), + "dense-expansion": sourceCases("dense expansion"), + "source-typing": sourceCases("typing"), + "prose-table": ACCEPTANCE_WIDTHS.flatMap(width => ["prose", "table"].map(fixture => `production prose table responsiveness ${fixture} at ${width}px`)), + "table-editing": ACCEPTANCE_WIDTHS.flatMap(width => ["first", "middle", "last", "line-break", "composition", "close-flush", "composition-close"].map(fixture => `production large table editing ${fixture} at ${width}px`)), + publication: ACCEPTANCE_WIDTHS.flatMap(width => PUBLICATION_FIXTURES.map(fixture => `production syntax publication / ${fixture} at ${width}px preserves active search and selection`)), + readiness: ACCEPTANCE_WIDTHS.flatMap(width => PUBLICATION_FIXTURES.map(fixture => `production syntax readiness / unheld ${fixture} readiness at ${width}px`)), + "readiness-http-cache": ACCEPTANCE_WIDTHS.flatMap(width => PUBLICATION_FIXTURES.map(fixture => `production syntax readiness / unheld ${fixture} readiness at ${width}px`)), +} as const; + +interface ViewerReportSuite { + title: string; + suites?: ViewerReportSuite[]; + specs?: { + title: string; + tests: { + projectName: string; + repeatEachIndex?: number; + status: string; + results: { status: string; retry: number; attachments?: { name: string; body?: string }[] }[]; + }[]; + }[]; +} + +export function evaluateViewerAcceptanceReport(slice: keyof typeof VIEWER_ACCEPTANCE_CASES, report: { suites: ViewerReportSuite[]; errors?: unknown[] }) { + const trials = 3; + const titles = VIEWER_ACCEPTANCE_CASES[slice]; + const identity = (title: string, engine: string, trial: number) => JSON.stringify([title, engine, trial]); + const expected = titles.flatMap(title => ACCEPTANCE_ENGINES.flatMap(engine => Array.from({ length: trials }, (_, trial) => identity(title, engine, trial)))); + const samples: ViewerAcceptanceSample[] = []; + const visit = (suite: ViewerReportSuite, parents: string[]) => { + for (const spec of suite.specs ?? []) { + const title = [...parents, spec.title].join(" / "); + for (const test of spec.tests) { + for (const result of test.results) { + const attachments = result.attachments ?? []; + const readiness = slice === "readiness" || slice === "readiness-http-cache"; + const name = slice === "publication" ? "viewer-timing-budgets.json" : readiness ? "syntax-readiness.json" : slice === "source-typing" ? "source-typing" : slice === "prose-table" ? "prose-table-responsiveness" : slice === "table-editing" ? "large-table-editing" : "source-responsiveness"; + const evidence = attachments.filter(attachment => attachment.name === name); + let validEvidence = evidence.length === (slice === "readiness-http-cache" ? 2 : 1); + const cacheStates: string[] = []; + let trial = test.repeatEachIndex; + for (const attachment of evidence) { + try { + const measurement = JSON.parse(Buffer.from(attachment.body ?? "", "base64").toString("utf8")); + trial ??= measurement.trial; + validEvidence &&= Number.isInteger(trial) && (measurement.trial === undefined || measurement.trial === trial); + const budget = measurement.budgets ?? measurement.budget ?? measurement; + let computed: { status: string }; + if (readiness) { + computed = evaluateViewerReadinessBudgets(measurement.metrics ?? {}); + cacheStates.push(measurement.cacheState); + } else if (slice === "prose-table") { + computed = evaluateTimingMetrics({ documentCompletion: VIEWER_TIMING_TARGETS_MS.documentCompletion, maximumTimerGap: VIEWER_TIMING_TARGETS_MS.maximumTimerGap }, measurement.load ?? {}, false); + } else { + const values: Record = {}; + const metrics = budget.metrics ?? []; + for (const metric of metrics) values[metric.metric] = metric.observedMs; + computed = slice === "publication" ? evaluateViewerTimingBudgets(values) : slice === "source-typing" || slice === "table-editing" ? evaluateSourceTypingBudgets(values) : evaluateSourceResponsivenessBudgets(values); + validEvidence &&= metrics.length === Object.keys(values).length; + } + validEvidence &&= measurement.enforced === true && budget.status === "pass" && computed.status === "pass"; + } catch { validEvidence = false; } + } + if (readiness) validEvidence &&= JSON.stringify(cacheStates.sort()) === JSON.stringify(slice === "readiness-http-cache" ? ["cold", "warm-http-fresh-workers"] : ["routed-no-cache"]); + samples.push({ identity: identity(title, test.projectName, trial ?? -1), retry: result.retry, status: result.status === "passed" && test.status === "expected" && validEvidence ? "pass" : "invalid" }); + } + } + } + for (const child of suite.suites ?? []) visit(child, [...parents, child.title]); + }; + for (const suite of report.suites) visit(suite, []); + const coverage = evaluateViewerAcceptanceCoverage(expected, samples); + return { ...coverage, status: !report.errors?.length ? coverage.status : "fail", slice }; +} + +export const VIEWER_READINESS_TARGETS_MS = { + firstUsableText: 1_500, + firstVisibleSyntax: 1_500, + fullSyntaxPreparation: 3_000, +} as const; + +export type ViewerReadinessMetric = keyof typeof VIEWER_READINESS_TARGETS_MS; +export type ViewerReadinessSamples = Partial>; + +export const SOURCE_RESPONSIVENESS_TARGETS_MS = { + atomicCommit: 50, + serialization: 50, + synchronousPublication: 50, + maximumTimerGap: 50, +} as const; + +export type SourceResponsivenessSamples = Partial>; + +export function evaluateSourceResponsivenessBudgets(samples: SourceResponsivenessSamples, diagnostic = false) { + return evaluateTimingMetrics(SOURCE_RESPONSIVENESS_TARGETS_MS, samples, diagnostic); +} + +export const SOURCE_TYPING_TARGETS_MS = { + keyboardTwoFrameP95: 50, + maximumTimerGap: 50, + serialization: 50, + synchronousPublication: 50, +} as const; + +export type SourceTypingSamples = Partial>; + +export function evaluateSourceTypingBudgets(samples: SourceTypingSamples) { + return evaluateTimingMetrics(SOURCE_TYPING_TARGETS_MS, samples, false, ["keyboardTwoFrameP95"]); +} + +function evaluateTimingMetrics(targets: Record, samples: Partial>, diagnostic: boolean, exclusive: readonly Metric[] = []) { + const metrics = (Object.entries(targets) as [Metric, number][]).map(([metric, targetMs]) => { + const observedMs = samples[metric]; + const valid = typeof observedMs === "number" && Number.isFinite(observedMs) && observedMs >= 0; + const status = !valid ? "invalid" : observedMs > targetMs || (observedMs === targetMs && exclusive.includes(metric)) ? "fail" : "pass"; + return { metric, targetMs, observedMs: valid ? observedMs : null, status }; + }); + return { + status: diagnostic ? "diagnostic" : metrics.every((metric) => metric.status === "pass") ? "pass" : "fail", + metrics, + }; +} + +export function evaluateViewerReadinessBudgets(samples: ViewerReadinessSamples, diagnostic = false) { + return evaluateTimingMetrics(VIEWER_READINESS_TARGETS_MS, samples, diagnostic); +} + +export function evaluateViewerTimingBudgets(samples: ViewerTimingSamples, diagnostic = false) { + return { + ...evaluateTimingMetrics(VIEWER_TIMING_TARGETS_MS, samples, diagnostic), + openCriteria: [ + "Actual hardware input-to-visible-response latency", + "Application-owned blocking work attribution and scrolling refresh-rate acceptance", + "First usable text and visible syntax completion budgets", + "Controlled renderer heap, six-cycle shutdown growth and aggregate worker/native memory", + "Fresh/cached grammar isolation, large prose/tables, source typing and dense replacement", + "Native Find, OS clipboard, assistive technology, printing and physical-device acceptance", + ], + }; +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d19ff0a22..4c8d9d97b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "sambee-frontend", - "version": "2.0.13", + "version": "2.0.14", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sambee-frontend", - "version": "2.0.13", + "version": "2.0.14", "hasInstallScript": true, "dependencies": { "@codemirror/autocomplete": "^6.20.3", @@ -19,6 +19,7 @@ "@codemirror/view": "^6.43.14", "@emotion/react": "^11.11.1", "@emotion/styled": "^11.11.0", + "@lezer/highlight": "^1.2.3", "@mui/icons-material": "^9.5.0", "@mui/material": "^9.1.2", "@react-spring/web": "^10.1.2", @@ -27,7 +28,6 @@ "axios": "1.20.0", "codemirror": "^6.0.2", "codemirror-markdown-tables": "^1.0.1", - "highlight.js": "^11.12.0", "i18next": "^26.4.7", "react": "^19.3.1", "react-colorful": "^5.8.1", @@ -37,9 +37,12 @@ "react-pdf": "^11.0.0", "react-photo-view": "^1.2.7", "react-router-dom": "^7.18.4", - "rehype-highlight": "^7.0.2", + "rehype-slug": "6.0.0", "remark-gfm": "^4.0.1", + "remark-parse": "^11.0.0", + "remark-rehype": "11.1.2", "swiper": "^14.3.0", + "unified": "^11.0.5", "yet-another-react-lightbox": "^3.32.2" }, "devDependencies": { @@ -48,6 +51,8 @@ "@testing-library/jest-dom": "^7.0.1", "@testing-library/react": "^16.3.3", "@testing-library/user-event": "^14.6.8", + "@types/hast": "^3.0.4", + "@types/mdast": "^4.0.4", "@types/node": "^26.6.5", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", @@ -4071,6 +4076,12 @@ "node": ">= 0.4" } }, + "node_modules/github-slugger": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz", + "integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==", + "license": "ISC" + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -4122,10 +4133,10 @@ "node": ">= 0.4" } }, - "node_modules/hast-util-is-element": { + "node_modules/hast-util-heading-rank": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz", - "integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==", + "resolved": "https://registry.npmjs.org/hast-util-heading-rank/-/hast-util-heading-rank-3.0.0.tgz", + "integrity": "sha512-EJKb8oMUXVHcWZTDepnr+WNbfnXKFNf9duMesmr4S8SXTJBJ9M4Yok08pu9vxdJwdlGRhVumk9mEhkEvKGifwA==", "license": "MIT", "dependencies": { "@types/hast": "^3.0.0" @@ -4162,16 +4173,13 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/hast-util-to-text": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz", - "integrity": "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==", + "node_modules/hast-util-to-string": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/hast-util-to-string/-/hast-util-to-string-3.0.1.tgz", + "integrity": "sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A==", "license": "MIT", "dependencies": { - "@types/hast": "^3.0.0", - "@types/unist": "^3.0.0", - "hast-util-is-element": "^3.0.0", - "unist-util-find-after": "^5.0.0" + "@types/hast": "^3.0.0" }, "funding": { "type": "opencollective", @@ -4202,15 +4210,6 @@ "set-cookie-parser": "^3.0.1" } }, - "node_modules/highlight.js": { - "version": "11.12.0", - "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.12.0.tgz", - "integrity": "sha512-nbfWpyRMcMrPMmDwJB+dhX/eiaPKtc2RB+0QZskqJ3WjRA/FDS0e9hZrx8EC/lbEv8gXy98FcDbNa/dspAaJMg==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/hoist-non-react-statics": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", @@ -4774,30 +4773,6 @@ "loose-envify": "cli.js" } }, - "node_modules/lowlight": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-3.3.0.tgz", - "integrity": "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "devlop": "^1.0.0", - "highlight.js": "~11.11.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/lowlight/node_modules/highlight.js": { - "version": "11.11.2", - "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.2.tgz", - "integrity": "sha512-oaXMACAU0kzOMXBjWpNcX+vlwSBCIAiZ9BHa7gA15NOTtT2L/l8OSZDuqS2XppOhZBPJ7hm4o8ep2kyuip2uEQ==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/lru-cache": { "version": "11.5.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", @@ -5328,9 +5303,9 @@ } }, "node_modules/micromark-extension-gfm-table": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", - "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.2.tgz", + "integrity": "sha512-pRzm4kDTu0MjlmBkxmS9yYhw60nncfcEwu9NNdPFSQEFXS95ZKyIIyTSHu/o3ReBUrLKYEq+7YaXCRn/bPB4MA==", "license": "MIT", "dependencies": { "devlop": "^1.0.0", @@ -6346,17 +6321,17 @@ "node": ">=8" } }, - "node_modules/rehype-highlight": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/rehype-highlight/-/rehype-highlight-7.0.2.tgz", - "integrity": "sha512-k158pK7wdC2qL3M5NcZROZ2tR/l7zOzjxXd5VGdcfIyoijjQqpHd3JKtYSBDpDZ38UI2WJWuFAtkMDxmx5kstA==", + "node_modules/rehype-slug": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/rehype-slug/-/rehype-slug-6.0.0.tgz", + "integrity": "sha512-lWyvf/jwu+oS5+hL5eClVd3hNdmwM1kAC0BUvEGD19pajQMIzcNUd/k9GsfQ+FfECvX+JE+e9/btsKH0EjJT6A==", "license": "MIT", "dependencies": { "@types/hast": "^3.0.0", - "hast-util-to-text": "^4.0.0", - "lowlight": "^3.0.0", - "unist-util-visit": "^5.0.0", - "vfile": "^6.0.0" + "github-slugger": "^2.0.0", + "hast-util-heading-rank": "^3.0.0", + "hast-util-to-string": "^3.0.0", + "unist-util-visit": "^5.0.0" }, "funding": { "type": "opencollective", @@ -6962,20 +6937,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/unist-util-find-after": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz", - "integrity": "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-is": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, "node_modules/unist-util-is": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index bc9f2ceea..adf66409d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "sambee-frontend", - "version": "2.0.13", + "version": "2.0.14", "private": true, "type": "module", "scripts": { @@ -10,13 +10,14 @@ "preinstall": "node scripts/dependency-state.mjs --clear", "postinstall": "node scripts/patch-react-transition-group.mjs && npm run prepare:pdfjs-assets && node scripts/dependency-state.mjs --write", "check:dependencies": "node scripts/dependency-state.mjs --check", - "build": "npm run prepare:pdfjs-assets && tsc && vite build", + "typecheck": "tsc -b --noEmit", + "build": "npm run typecheck && npm run prepare:pdfjs-assets && vite build", "preview": "vite preview", "lint": "biome check src", "lint:fix": "biome check --write src", "format": "biome format --write src", "test": "vitest run", - "test:ci": "vitest run --maxWorkers=2", + "test:ci": "node scripts/run-tests-ci.mjs", "test:stable": "node scripts/run-tests-stable.mjs", "test:watch": "vitest", "test:ui": "vitest --ui", @@ -24,6 +25,7 @@ "test:coverage": "vitest run --coverage", "test:e2e": "playwright test --project=chromium", "test:e2e:all": "playwright test", + "test:browser:ci": "node scripts/run-browser-ci.mjs", "test:e2e:debug": "playwright test --project=chromium --headed --debug", "test:e2e:install": "playwright install chromium firefox", "test:changed": "vitest run --changed", @@ -40,6 +42,7 @@ "@codemirror/view": "^6.43.14", "@emotion/react": "^11.11.1", "@emotion/styled": "^11.11.0", + "@lezer/highlight": "^1.2.3", "@mui/icons-material": "^9.5.0", "@mui/material": "^9.1.2", "@react-spring/web": "^10.1.2", @@ -48,7 +51,6 @@ "axios": "1.20.0", "codemirror": "^6.0.2", "codemirror-markdown-tables": "^1.0.1", - "highlight.js": "^11.12.0", "i18next": "^26.4.7", "react": "^19.3.1", "react-colorful": "^5.8.1", @@ -58,9 +60,12 @@ "react-pdf": "^11.0.0", "react-photo-view": "^1.2.7", "react-router-dom": "^7.18.4", - "rehype-highlight": "^7.0.2", + "rehype-slug": "6.0.0", "remark-gfm": "^4.0.1", + "remark-parse": "^11.0.0", + "remark-rehype": "11.1.2", "swiper": "^14.3.0", + "unified": "^11.0.5", "yet-another-react-lightbox": "^3.32.2" }, "overrides": { @@ -72,6 +77,8 @@ "@testing-library/jest-dom": "^7.0.1", "@testing-library/react": "^16.3.3", "@testing-library/user-event": "^14.6.8", + "@types/hast": "^3.0.4", + "@types/mdast": "^4.0.4", "@types/node": "^26.6.5", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index be0d41f8d..abd270df2 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -1,23 +1,54 @@ import { defineConfig, devices } from "@playwright/test"; const frontendE2ePort = 4173; -const baseURL = `http://127.0.0.1:${frontendE2ePort}`; +const productionProof = process.env.SAMBEE_E2E_PRODUCTION === "1"; +const correctnessCI = process.env.SAMBEE_E2E_CI === "1"; +const ciMode = productionProof ? "production" : "development"; +const existingAppProof = productionProof || process.env.SAMBEE_E2E_DEVELOPMENT === "1"; +const baseURL = existingAppProof ? "http://localhost:3000" : `http://127.0.0.1:${frontendE2ePort}`; + +if ( + correctnessCI && + ["SAMBEE_E2E_BUDGETS", "SAMBEE_E2E_MEMORY", "SAMBEE_E2E_TRACE", "SAMBEE_E2E_RETAINED", "SAMBEE_E2E_HTTP_CACHE"].some( + (name) => process.env[name] === "1" + ) +) { + throw new Error("Run browser correctness separately from diagnostic and alternate-renderer modes"); +} export default defineConfig({ testDir: "./e2e", - timeout: 30_000, + timeout: correctnessCI ? 30_000 : 15_000, fullyParallel: false, - retries: process.env.CI ? 1 : 0, + workers: correctnessCI ? 1 : undefined, + retries: correctnessCI ? 0 : process.env.CI ? 1 : 0, + forbidOnly: correctnessCI || !!process.env.CI, + grep: correctnessCI ? (productionProof ? /production CI correctness/ : /development Markdown worker/) : undefined, + outputDir: correctnessCI ? `test-results/ci/${ciMode}-artifacts` : "test-results", + reporter: correctnessCI + ? [["list"], ["json", { outputFile: `test-results/ci/${ciMode}.json` }], ["./scripts/ci-browser-reporter.mjs", { mode: ciMode }]] + : undefined, use: { baseURL, - trace: "on-first-retry", - }, - webServer: { - command: `npm run dev -- --host 127.0.0.1 --port ${frontendE2ePort}`, - url: baseURL, - reuseExistingServer: true, - timeout: 120_000, + trace: correctnessCI ? "retain-on-failure" : "on-first-retry", + screenshot: correctnessCI ? "only-on-failure" : "off", }, + webServer: + correctnessCI && !productionProof + ? { + command: "npm run dev -- --host localhost --port 3000 --strictPort", + url: baseURL, + reuseExistingServer: !process.env.CI, + timeout: 120_000, + } + : existingAppProof + ? undefined + : { + command: `npm run dev -- --host 127.0.0.1 --port ${frontendE2ePort}`, + url: baseURL, + reuseExistingServer: true, + timeout: 120_000, + }, projects: [ { name: "chromium", diff --git a/frontend/scripts/ci-browser-reporter.mjs b/frontend/scripts/ci-browser-reporter.mjs new file mode 100644 index 000000000..d0b54b3ec --- /dev/null +++ b/frontend/scripts/ci-browser-reporter.mjs @@ -0,0 +1,24 @@ +import { validateBrowserCases } from "./ci-test-policy.mjs"; + +export default class CiBrowserReporter { + constructor(options) { + this.mode = options.mode; + } + + onBegin(_config, suite) { + this.suite = suite; + } + + onEnd(result) { + const cases = this.suite.allTests().map((test) => ({ + engine: test.parent.project()?.name, + title: test.title, + status: test.results.at(-1)?.status, + attempts: test.results.length, + })); + const errors = validateBrowserCases(this.mode, cases); + for (const error of errors) console.error(error); + console.log(`Browser correctness ${this.mode}: ${cases.length} cases, ${errors.length} contract violations`); + if (errors.length || result.status !== "passed") return { status: "failed" }; + } +} diff --git a/frontend/scripts/ci-test-policy.d.mts b/frontend/scripts/ci-test-policy.d.mts new file mode 100644 index 000000000..170a86a34 --- /dev/null +++ b/frontend/scripts/ci-test-policy.d.mts @@ -0,0 +1,18 @@ +export const CI_MAX_WORKERS: number; +export const CI_WORKER_MEMORY_BYTES: number; +export const CI_RESERVED_MEMORY_BYTES: number; +export const CI_BROWSER_ENGINES: readonly string[]; +export const CI_BROWSER_WIDTHS: readonly number[]; +export const CI_PRODUCTION_SCENARIOS: readonly string[]; +export const CI_DEVELOPMENT_SCENARIO: string; + +export interface BrowserCase { + engine: string | undefined; + title: string | undefined; + status: string | undefined; + attempts: number; +} + +export function ciWorkerCount(cpuCount: number, memoryBytes: number): number; +export function expectedBrowserCases(mode: string): string[]; +export function validateBrowserCases(mode: string, cases: readonly BrowserCase[]): string[]; diff --git a/frontend/scripts/ci-test-policy.mjs b/frontend/scripts/ci-test-policy.mjs new file mode 100644 index 000000000..f0c9a79e6 --- /dev/null +++ b/frontend/scripts/ci-test-policy.mjs @@ -0,0 +1,38 @@ +export const CI_MAX_WORKERS = 4; +export const CI_WORKER_MEMORY_BYTES = 2 * 1024 ** 3; +export const CI_RESERVED_MEMORY_BYTES = 2 * 1024 ** 3; +export const CI_BROWSER_ENGINES = ["chromium", "firefox"]; +export const CI_BROWSER_WIDTHS = [1543, 390]; +export const CI_PRODUCTION_SCENARIOS = ["real workers", "native contracts", "edit and save", "cancel and reopen"]; +export const CI_DEVELOPMENT_SCENARIO = "normalizes and renders entity text without DOM globals in the worker"; + +export function ciWorkerCount(cpuCount, memoryBytes) { + if (!Number.isFinite(cpuCount) || cpuCount < 1 || !Number.isFinite(memoryBytes) || memoryBytes < 1) { + throw new Error("CI worker selection requires positive CPU and memory measurements"); + } + const memoryWorkers = Math.max(1, Math.floor((memoryBytes - CI_RESERVED_MEMORY_BYTES) / CI_WORKER_MEMORY_BYTES)); + return Math.min(CI_MAX_WORKERS, Math.floor(cpuCount), memoryWorkers); +} + +export function expectedBrowserCases(mode) { + if (mode !== "production" && mode !== "development") throw new Error(`Unknown browser CI mode: ${mode}`); + const titles = + mode === "production" + ? CI_PRODUCTION_SCENARIOS.flatMap((scenario) => CI_BROWSER_WIDTHS.map((width) => `CI ${scenario} at ${width}px`)) + : [CI_DEVELOPMENT_SCENARIO]; + return CI_BROWSER_ENGINES.flatMap((engine) => titles.map((title) => `${engine}: ${title}`)); +} + +export function validateBrowserCases(mode, cases) { + const missing = new Set(expectedBrowserCases(mode)); + const errors = []; + for (const result of cases) { + const identity = `${result.engine}: ${result.title}`; + if (!missing.delete(identity)) errors.push(`Unexpected or duplicate browser case: ${identity}`); + if (result.status !== "passed" || result.attempts !== 1) { + errors.push(`Browser case must pass once without skips/retries: ${identity} (${result.status}, ${result.attempts} attempts)`); + } + } + for (const identity of missing) errors.push(`Missing browser case: ${identity}`); + return errors; +} diff --git a/frontend/scripts/draft-checkpoint-performance-proof.mjs b/frontend/scripts/draft-checkpoint-performance-proof.mjs new file mode 100644 index 000000000..97228754b --- /dev/null +++ b/frontend/scripts/draft-checkpoint-performance-proof.mjs @@ -0,0 +1,255 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; +import { chromium, firefox } from "playwright"; +import { build } from "vite"; + +const FRONTEND = fileURLToPath(new URL("..", import.meta.url)); +const APPROVED_URL = "http://localhost:3000/browse/smb/demo"; +const ASSET_PREFIX = "/__draft_checkpoint_proof__/"; +const HOST_TASK_LIMIT_MS = 50; +const output = await mkdtemp(resolve(tmpdir(), "sambee-draft-checkpoint-proof-")); +const reports = []; + +try { + await build({ + root: FRONTEND, + configFile: false, + logLevel: "warn", + define: { "process.env.NODE_ENV": JSON.stringify("production") }, + build: { + outDir: output, + emptyOutDir: true, + minify: true, + lib: { + entry: { + checkpoint: resolve(FRONTEND, "src/services/draftCheckpoint.ts"), + recovery: resolve(FRONTEND, "src/services/draftRecovery.ts"), + identity: resolve(FRONTEND, "src/services/authSession.ts"), + state: fileURLToPath(import.meta.resolve("@codemirror/state")), + }, + formats: ["es"], + fileName: (_format, name) => `${name}.js`, + }, + }, + }); + for (const [browserName, browserType] of Object.entries({ chromium, firefox })) { + const browser = await browserType.launch({ headless: true }); + try { + for (const width of [1543, 390]) { + const page = await browser.newPage({ viewport: { width, height: 900 } }); + const errors = []; + page.on("pageerror", (error) => errors.push(error.message)); + await page.route(`**${ASSET_PREFIX}**`, async (route) => { + const pathname = new URL(route.request().url()).pathname; + const path = resolve(output, decodeURIComponent(pathname.slice(ASSET_PREFIX.length))); + if (!path.startsWith(`${output}${sep}`)) return route.abort(); + try { + await route.fulfill({ body: await readFile(path), contentType: "text/javascript" }); + } catch { + await route.abort(); + } + }); + await page.route(APPROVED_URL, (route) => + route.fulfill({ + contentType: "text/html", + body: 'P1 checkpoint proof', + }) + ); + await page.goto(APPROVED_URL); + for (const [fixture, source] of [ + ["code", "let x=1;\n".repeat(24_000)], + ["paragraph-source", "A **formatted** paragraph of text.\n\n".repeat(4_000)], + ["table-source", "| first cell | second cell |\n".repeat(2_400)], + ]) { + const measurement = await page.evaluate( + async ({ source, prefix }) => { + const { Text } = await import(`${prefix}state.js`); + const { authSession } = await import(`${prefix}identity.js`); + const { createDraftCheckpointController, DRAFT_CHECKPOINT_START_MS, DRAFT_CHECKPOINT_LAG_MS, DRAFT_TRANSITION_FLUSH_MS } = + await import(`${prefix}checkpoint.js`); + const { loadDraft } = await import(`${prefix}recovery.js`); + authSession.setAuthenticated({ access_token: "proof-only", token_type: "bearer", user_id: "checkpoint-proof" }, false); + sessionStorage.clear(); + const controller = createDraftCheckpointController("demo", "/checkpoint.md", "markdown"); + let doc = Text.of(source.split("\n")); + let serializedRevision = null; + const edits = []; + const serializations = []; + const acknowledgements = []; + const writes = []; + const gaps = []; + let lastTask = performance.now(); + const heartbeat = setInterval(() => { + const now = performance.now(); + gaps.push(now - lastTask); + lastTask = now; + }, 0); + const original = Storage.prototype.setItem; + Storage.prototype.setItem = function (key, value) { + const start = performance.now(); + original.call(this, key, value); + const completed = performance.now(); + writes.push(completed - start); + acknowledgements.push({ revision: serializedRevision, at: completed }); + }; + try { + for (let revision = 1; revision <= 20; revision += 1) { + doc = doc.replace(doc.length, doc.length, Text.of(["x"])); + const captured = doc; + edits.push({ revision, at: performance.now() }); + controller.request(revision, async () => { + const start = performance.now(); + const content = captured.toString(); + serializedRevision = revision; + serializations.push({ revision, at: start, ms: performance.now() - start }); + return { baseline: source, content }; + }); + await new Promise((resolve) => setTimeout(resolve, 25)); + } + const flushStart = performance.now(); + const outcome = await controller.flush(); + const flushMs = performance.now() - flushStart; + const lags = edits.map((edit) => { + const stored = acknowledgements.find((ack) => ack.revision >= edit.revision); + if (!stored) throw new Error("An edited revision was not acknowledged"); + return stored.at - edit.at; + }); + const firstStartMs = serializations[0].at - edits[0].at; + return { + outcome, + sourceUnits: source.length, + exactContent: loadDraft("demo", "/checkpoint.md", "markdown")?.content === `${source}${"x".repeat(20)}`, + serializationCount: serializations.length, + firstStartMs, + maxLagMs: Math.max(...lags), + flushMs, + maxSerializationMs: Math.max(...serializations.map((entry) => entry.ms)), + maxStorageMs: Math.max(...writes), + maxSnapshotToStorageMs: Math.max( + ...acknowledgements.map((ack) => ack.at - serializations.find((entry) => entry.revision === ack.revision).at) + ), + maxTimerGapMs: Math.max(0, ...gaps), + targets: { start: DRAFT_CHECKPOINT_START_MS, lag: DRAFT_CHECKPOINT_LAG_MS, flush: DRAFT_TRANSITION_FLUSH_MS }, + }; + } finally { + Storage.prototype.setItem = original; + clearInterval(heartbeat); + controller.dispose(); + authSession.clear(); + } + }, + { source, prefix: ASSET_PREFIX } + ); + assert.deepEqual(measurement.outcome, { saved: true, revision: 20 }); + assert.equal(measurement.exactContent, true); + assert.ok(measurement.serializationCount < 20, "Do not serialize each edit"); + assert.ok(measurement.firstStartMs <= measurement.targets.start, "Checkpoint start exceeded approved target"); + assert.ok(measurement.maxLagMs <= measurement.targets.lag, "Checkpoint lag exceeded approved target"); + assert.ok(measurement.flushMs <= measurement.targets.flush, "Transition flush exceeded approved target"); + assert.ok(measurement.maxSerializationMs <= HOST_TASK_LIMIT_MS, "Serialization task exceeded interaction ceiling"); + assert.ok(measurement.maxStorageMs <= HOST_TASK_LIMIT_MS, "Storage task exceeded interaction ceiling"); + assert.ok( + measurement.maxSnapshotToStorageMs <= HOST_TASK_LIMIT_MS, + "Snapshot preparation and storage exceeded interaction ceiling" + ); + assert.ok(measurement.maxTimerGapMs <= HOST_TASK_LIMIT_MS, "Scheduling responsiveness proxy exceeded ceiling"); + reports.push({ browserName, browser: browser.version(), width, fixture, ...measurement }); + } + + const lifecycleSource = "let x=1;\n".repeat(24_000); + for (const lifecycle of ["late-completion", "reload"]) { + await page.evaluate( + async ({ prefix, source }) => { + const { authSession } = await import(`${prefix}identity.js`); + const { Text } = await import(`${prefix}state.js`); + const { createDraftCheckpointController } = await import(`${prefix}checkpoint.js`); + const { createDraftCheckpointSession } = await import(`${prefix}recovery.js`); + authSession.setAuthenticated({ access_token: "proof-only", token_type: "bearer", user_id: "checkpoint-proof" }, false); + sessionStorage.clear(); + const controller = createDraftCheckpointController("demo", "/lifecycle.md", "markdown"); + let doc = Text.of(source.split("\n")); + let release; + const deferred = new Promise((resolve) => { + release = resolve; + }); + controller.request(1, () => deferred); + const flush = controller.flush(); + document.querySelector("#input").oninput = () => { + doc = doc.replace(doc.length, doc.length, Text.of(["x"])); + }; + window.checkpointLifecycleProof = { controller, release, flush, result: null }; + window.addEventListener( + "pagehide", + () => { + const start = performance.now(); + const session = createDraftCheckpointSession("demo", "/lifecycle.md", "markdown"); + try { + const result = session.commit(session.capture(2), source, doc.toString()); + window.checkpointLifecycleProof.result = { ...result, ms: performance.now() - start }; + } finally { + session.dispose(); + } + }, + { once: true } + ); + await Promise.resolve(); + }, + { prefix: ASSET_PREFIX, source: lifecycleSource } + ); + await page.locator("#input").press("x"); + if (lifecycle === "late-completion") { + const result = await page.evaluate( + async ({ prefix }) => { + window.dispatchEvent(new PageTransitionEvent("pagehide")); + const proof = window.checkpointLifecycleProof; + proof.release({ baseline: "saved", content: "obsolete async edit" }); + const late = await proof.flush; + const { loadDraft } = await import(`${prefix}recovery.js`); + const recovered = loadDraft("demo", "/lifecycle.md", "markdown"); + proof.controller.dispose(); + return { latest: proof.result, late, content: recovered?.content }; + }, + { prefix: ASSET_PREFIX } + ); + assert.equal(result.latest.saved, true); + assert.ok(result.latest.ms <= HOST_TASK_LIMIT_MS); + assert.deepEqual(result.late, { saved: false, revision: 1, reason: "stale-checkpoint" }); + assert.equal(result.content, `${lifecycleSource}x`); + reports.push({ + browserName, + width, + fixture: "synchronous-lifecycle", + sourceUnits: lifecycleSource.length, + snapshotAndStorageMs: result.latest.ms, + }); + } else { + await page.reload(); + const recovered = await page.evaluate( + async ({ prefix }) => { + const { authSession } = await import(`${prefix}identity.js`); + authSession.setAuthenticated({ access_token: "proof-only", token_type: "bearer", user_id: "checkpoint-proof" }, false); + const { loadDraft } = await import(`${prefix}recovery.js`); + return loadDraft("demo", "/lifecycle.md", "markdown")?.content; + }, + { prefix: ASSET_PREFIX } + ); + assert.equal(recovered, `${lifecycleSource}x`); + } + } + assert.deepEqual(errors, []); + await page.close(); + console.log( + `${browserName} width=${width}: sustained-edit checkpoints, pending-snapshot keyboard input, synchronous lifecycle, late-result rejection and real reload passed` + ); + } + } finally { + await browser.close(); + } + } + console.log(JSON.stringify({ kind: "P1 recovery boundary proof, NOT integrated editor acceptance", measurements: reports }, null, 2)); +} finally { + await rm(output, { recursive: true, force: true }); +} diff --git a/frontend/scripts/markdown-document-performance-proof.mjs b/frontend/scripts/markdown-document-performance-proof.mjs new file mode 100644 index 000000000..a00f3c56c --- /dev/null +++ b/frontend/scripts/markdown-document-performance-proof.mjs @@ -0,0 +1,845 @@ +import assert from "node:assert/strict"; + +const HOST_ENVELOPE_UNITS = { "dense-code": 24_000, paragraphs: 4_000, table: 2_400 }; +const HOST_BUDGETS = { loadMs: 1_500, searchMs: 1_000, warmSearchMs: 100, navigationMs: 100, maximumTaskServiceGapMs: 50 }; +const HOST_RENDERER_HEAP_BYTES = 64 * 1_024 * 1_024; +const HOST_SHUTDOWN_GROWTH_BYTES = 1_024 * 1_024; +const HOST_TRIALS = 3; + +export function documentHostBudgetFailures(report) { + const failures = []; + for (const width of [1543, 390]) { + for (const [fixture, units] of Object.entries(HOST_ENVELOPE_UNITS)) + for (let trial = 0; trial < HOST_TRIALS; trial += 1) { + const entries = (report.scaling ?? []).filter( + (entry) => entry.width === width && entry.fixture === fixture && entry.units === units && entry.trial === trial + ); + const entry = entries[0]; + const label = `${report.browserName} ${width} ${fixture} ${units} trial ${trial}`; + if (entries.length !== 1 || !entry?.measurement || entry.error) { + failures.push(`${label}: missing/duplicate/failed trial${entry?.error ? `: ${entry.error}` : ""}`); + continue; + } + for (const [metric, budget] of Object.entries(HOST_BUDGETS)) { + const actual = entry.measurement[metric]; + if (!Number.isFinite(actual) || actual < 0 || actual > budget) + failures.push(`${label}: ${metric}=${actual} exceeds ${budget} ms or is invalid`); + } + if (report.browserName === "chromium") { + const bytes = entry.loadedRendererSnapshot?.usedSize; + if (!Number.isFinite(bytes) || bytes < 0 || bytes > HOST_RENDERER_HEAP_BYTES) + failures.push(`${label}: loaded renderer heap=${bytes} exceeds ${HOST_RENDERER_HEAP_BYTES} bytes or is unavailable`); + } + } + if (report.browserName === "chromium") { + const resource = (report.resources ?? []).find((entry) => entry.width === width && entry.supported); + const samples = resource?.samples; + if (samples?.length !== 6) failures.push(`${width}: six post-shutdown resource samples required`); + else { + const growth = samples.at(-1).usedSize - samples[1].usedSize; + if (!Number.isFinite(growth) || growth > HOST_SHUTDOWN_GROWTH_BYTES) + failures.push(`${width}: warmed shutdown heap growth=${growth} exceeds ${HOST_SHUTDOWN_GROWTH_BYTES} bytes or is unavailable`); + for (const sample of samples) + if (sample.owned.elements !== 0 || sample.owned.highlights !== 0 || sample.owned.liveWorkers !== 0) + failures.push(`${width}: owned resources remain after cycle ${sample.cycle}`); + } + } + } + return failures; +} + +export async function runMarkdownDocumentProof(page, report, width) { + await page.evaluate(() => { + const OriginalWorker = window.Worker; + window.documentWorkers = []; + window.Worker = class extends OriginalWorker { + constructor(...args) { + super(...args); + window.documentWorkers.push({ worker: this, terminated: false }); + } + terminate() { + window.documentWorkers.find((entry) => entry.worker === this).terminated = true; + super.terminate(); + } + postMessage(message, ...args) { + if (message.type === "syntax-window" && window.pauseSyntaxWindow) { + window.pausedSyntaxWorker = this; + return; + } + if (message.type === "prepare") this.documentPreparation = message.document === true; + if (this.documentPreparation && window.pauseDocumentPublication && message.type === "ack") { + window.resumeDocumentPublication = () => super.postMessage(message, ...args); + return; + } + super.postMessage(message, ...args); + } + }; + }); + const fixtures = [ + { + name: "unicode-boundaries", + source: `# Unicode boundaries\n\n${"x".repeat(2_047)}\u{1f30d}${"x".repeat(2_047)}e\u0301\n\n\`\`\`text\n${"x".repeat(2_047)}\u{1f30d}\t end\n\`\`\``, + }, + { name: "empty", source: "" }, + { name: "canonical-normalization", source: "* first\n* second\n\n| Left | Right |\n| --- | --- |\n| a\\&b | first
second third |\n\n```md\n* literal
\n```\n" }, + { name: "unknown-language", source: "# Plain fallback\n\n```unknown-language-proof\nUnchanged source.\n```" }, + { name: "image", source: "# Image sizing\n\n![Visible fixture](/__markdown_preview_proof__/fixture.png)\n\nText after image." }, + { + name: "mixed", + source: + "# Whole Markdown\n\n**Strong**, *emphasis*, ~~deleted~~ and [link](https://example.com). Unicode \u{1f30d} & ©.\n\n> Nested quote\n>\n> - first\n> - child\n\n- [x] done\n- [ ] pending\n\n| Left | Right |\n| :--- | ---: |\n| first
second | `inline` |\n\n```js\nlet x = 1;\n```\n\nFootnote[^note].\n\n[^note]: Offscreen footnote.\n\n\n[unsafe](javascript:alert%281%29)", + }, + { name: "dense-code", source: `# Dense code\n\n\`\`\`js\n${"let x=1;\n".repeat(6_000)}\`\`\`\n\nLast paragraph.` }, + { + name: "large-paragraph", + source: `# Large paragraph\n\n${"Text **strong** with \u{1f30d} and inline `code`. ".repeat(1_000)}\n\nEnd.`, + }, + { + name: "large-table", + source: `# Large table\n\n| Number | Value |\n| --- | --- |\n${Array.from({ length: 800 }, (_, index) => `| ${index} | cell **${index}**
next |`).join("\n")}\n\nEnd.`, + }, + { name: "long-line", source: `# Long line\n\n\`\`\`js\n// ${"x".repeat(60_000)}\n\`\`\`\n\nEnd.` }, + ]; + for (const fixture of fixtures) { + await page.evaluate((source) => window.codePreviewPrototype.baselineDocument(source), fixture.source); + await page.locator("[data-markdown-baseline] img").evaluateAll(async (images) => Promise.all(images.map((image) => image.decode()))); + const expected = await page.evaluate(() => { + const container = document.querySelector("[data-markdown-baseline]"); + const selection = window.getSelection(); + selection.selectAllChildren(container); + const selected = selection.toString(); + selection.removeAllRanges(); + return { + text: container.textContent, + selected, + height: container.scrollHeight, + tags: [...container.querySelectorAll("h1,h2,p,table,tr,td,th,ul,ol,li,input,a,pre,code,br")].map((element) => element.tagName), + }; + }); + await page.setViewportSize({ width: width === 390 ? 1543 : 390, height: 900 }); + expected.resizedHeight = await page.locator("[data-markdown-baseline]").evaluate((container) => container.scrollHeight); + await page.setViewportSize({ width, height: 900 }); + const nativeFind = async (selector) => + page.locator(selector).evaluate((container) => { + container.scrollTop = 0; + window.getSelection().removeAllRanges(); + const found = window.find("Last paragraph.", false, false, true); + const selection = window.getSelection(); + const rect = selection.rangeCount ? selection.getRangeAt(0).getBoundingClientRect() : null; + const viewport = container.getBoundingClientRect(); + const result = { + found, + selected: selection.toString(), + visible: Boolean(rect && rect.bottom > viewport.top && rect.top < viewport.bottom), + }; + selection.removeAllRanges(); + return result; + }); + const baselineFind = fixture.name === "dense-code" ? await nativeFind("[data-markdown-baseline]") : null; + const timing = await page.evaluate(async (source) => { + const start = performance.now(); + let previous = start; + let maximumTaskServiceGapMs = 0; + let timer; + const probe = () => { + const now = performance.now(); + maximumTaskServiceGapMs = Math.max(maximumTaskServiceGapMs, now - previous); + previous = now; + timer = setTimeout(probe, 0); + }; + const longTasks = []; + const longTaskObservationSupported = PerformanceObserver.supportedEntryTypes.includes("longtask"); + const observer = new PerformanceObserver((list) => longTasks.push(...list.getEntries().map((entry) => entry.duration))); + if (longTaskObservationSupported) observer.observe({ type: "longtask" }); + timer = setTimeout(probe, 0); + try { + await window.codePreviewPrototype.loadDocument(source); + await new Promise(requestAnimationFrame); + await new Promise(requestAnimationFrame); + longTasks.push(...observer.takeRecords().map((entry) => entry.duration)); + maximumTaskServiceGapMs = Math.max(maximumTaskServiceGapMs, performance.now() - previous); + return { loadToPaintMs: performance.now() - start, longTaskObservationSupported, longTasks, maximumTaskServiceGapMs }; + } finally { + clearTimeout(timer); + observer.disconnect(); + } + }, fixture.source); + await page.locator("[data-markdown-document] img").evaluateAll(async (images) => Promise.all(images.map((image) => image.decode()))); + const actual = await page.evaluate(() => { + const container = document.querySelector("[data-markdown-document]"); + const selection = window.getSelection(); + selection.selectAllChildren(container); + const selected = selection.toString(); + selection.removeAllRanges(); + return { + stats: window.codePreviewPrototype.stats(), + text: container.textContent, + selected, + height: container.scrollHeight, + tags: [...container.querySelectorAll("h1,h2,p,table,tr,td,th,ul,ol,li,input,a,pre,code,br")].map((element) => element.tagName), + unsafe: container.querySelector("script,[onclick],a[href^='javascript:']") !== null, + }; + }); + assert.equal(actual.stats.complete, true); + assert.equal(actual.stats.error, null); + assert.equal(actual.text, expected.text, fixture.name); + assert.equal(actual.selected, expected.selected, `${fixture.name}: native selection`); + assert.deepEqual(actual.tags, expected.tags, fixture.name); + assert.equal(actual.height, expected.height, `${fixture.name}: layout`); + assert.equal(actual.unsafe, false); + assert.ok(actual.stats.largestBatch <= actual.stats.batchLimit); + assert.ok(actual.stats.highlightRanges <= 4_000); + assert.equal(actual.stats.syntaxError, null); + assert.ok(actual.stats.cachedSyntaxRanges <= actual.stats.syntaxCacheLimit); + assert.ok(actual.stats.peakCachedSyntaxRanges <= actual.stats.syntaxCacheLimit); + assert.ok(actual.stats.largestSyntaxPage <= actual.stats.syntaxPageLimit); + if (fixture.name === "dense-code") { + await page.waitForFunction( + () => !window.codePreviewPrototype.stats().syntaxFetching && window.codePreviewPrototype.stats().highlightRanges > 0 + ); + const top = await page.evaluate(() => { + const container = document.querySelector("[data-markdown-document]"); + const node = container.querySelector("code [data-document-fragment]").firstChild; + window.syntaxSelectionNode = node; + const range = new Range(); + range.setStart(node, 0); + range.setEnd(node, 3); + const selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); + const stats = window.codePreviewPrototype.stats(); + container.scrollTop = container.scrollHeight; + return stats; + }); + await page.waitForFunction(() => { + const container = document.querySelector("[data-markdown-document]"); + const viewport = container.getBoundingClientRect(); + const highlight = CSS.highlights.get("document-keyword"); + return ( + !window.codePreviewPrototype.stats().syntaxFetching && + highlight && + [...highlight].some((range) => { + const rect = range.getBoundingClientRect(); + return rect.bottom > viewport.top && rect.top < viewport.bottom; + }) + ); + }); + const bottom = await page.evaluate(() => { + const selection = window.getSelection(); + const stats = window.codePreviewPrototype.stats(); + const stable = + selection.anchorNode === window.syntaxSelectionNode && selection.toString() === "let" && window.syntaxSelectionNode.isConnected; + selection.removeAllRanges(); + window.syntaxSelectionNode = null; + return { ...stats, stable }; + }); + assert.ok(top.workerSyntaxRanges > top.syntaxCacheLimit); + assert.ok(bottom.peakCachedSyntaxRanges <= bottom.syntaxCacheLimit); + assert.ok(bottom.largestSyntaxPage <= bottom.syntaxPageLimit); + assert.equal(bottom.syntaxError, null); + assert.equal(bottom.stable, true); + assert.ok(bottom.syntaxPages > top.syntaxPages); + report.interactions.push({ width, mode: "viewport-syntax", top, bottom }); + await page.evaluate(() => { + window.pauseSyntaxWindow = true; + document.querySelector("[data-markdown-document]").scrollTop = 0; + }); + await page.waitForFunction(() => !!window.pausedSyntaxWorker); + const syntaxFailure = await page.evaluate(async () => { + const container = document.querySelector("[data-markdown-document]"); + const node = container.querySelector("code [data-document-fragment]").firstChild; + const text = container.textContent; + window.pausedSyntaxWorker.dispatchEvent(new Event("messageerror")); + await new Promise(requestAnimationFrame); + await new Promise(requestAnimationFrame); + window.pauseSyntaxWindow = false; + window.pausedSyntaxWorker = null; + return { stats: window.codePreviewPrototype.stats(), textRetained: container.textContent === text, nodeRetained: node.isConnected }; + }); + assert.match(syntaxFailure.stats.syntaxError, /could not be decoded/); + assert.equal(syntaxFailure.stats.cachedSyntaxRanges, 0); + assert.equal(syntaxFailure.textRetained, true); + assert.equal(syntaxFailure.nodeRetained, true); + report.interactions.push({ width, mode: "viewport-syntax-failure", ...syntaxFailure }); + } + await page.setViewportSize({ width: width === 390 ? 1543 : 390, height: 900 }); + assert.equal( + await page.locator("[data-markdown-document]").evaluate((container) => container.scrollHeight), + expected.resizedHeight, + `${fixture.name}: resized layout` + ); + await page.setViewportSize({ width, height: 900 }); + if (baselineFind) { + const actualFind = await nativeFind("[data-markdown-document]"); + assert.equal(actualFind.found, true); + assert.equal(actualFind.selected, "Last paragraph."); + assert.deepEqual(actualFind, baselineFind); + report.interactions.push({ width, mode: "whole-markdown", nativeFind: actualFind }); + if (!actualFind.visible) + report.blockers.push({ + width, + nativeFindReveal: + "Headless window.find selected but did not reveal in either canonical or prototype document; actual browser Find UI unverified", + }); + } + if (fixture.name === "dense-code") assert.ok(actual.stats.highlightRanges > 0); + if (fixture.name === "unknown-language") assert.equal(actual.stats.fallbacks.length, 1); + if (fixture.name === "image") { + const image = await page.locator("[data-markdown-document] img").evaluate(async (element) => { + const before = element.nextElementSibling?.getBoundingClientRect().top ?? element.getBoundingClientRect().bottom; + element.style.width = "80px"; + element.style.height = "120px"; + await new Promise(requestAnimationFrame); + return { + loaded: element.naturalWidth > 0, + resized: element.getBoundingClientRect().height === 120, + before, + after: element.getBoundingClientRect().bottom, + }; + }); + assert.equal(image.loaded, true); + assert.equal(image.resized, true); + report.interactions.push({ width, mode: "whole-markdown", image }); + } + if (fixture.name === "mixed") { + const readingTree = await page.locator("[data-markdown-document]").ariaSnapshot(); + assert.ok(readingTree.includes("Whole Markdown")); + assert.ok(readingTree.includes("Offscreen footnote")); + await page.locator("[data-markdown-document] a[data-footnote-ref]").click(); + const targetVisible = await page.locator("[data-markdown-document] li[id^='user-content-fn-']").evaluate((element) => { + const rect = element.getBoundingClientRect(); + const viewport = document.querySelector("[data-markdown-document]").getBoundingClientRect(); + return rect.bottom > viewport.top && rect.top < viewport.bottom; + }); + assert.equal(targetVisible, true); + const color = await page.evaluate(() => { + document.getElementById("prototype").style.setProperty("--sambee-syntax-keyword", "#13579b"); + return getComputedStyle(document.getElementById("prototype"), "::highlight(document-keyword)").color; + }); + assert.equal(color, "rgb(19, 87, 155)"); + } + await page.emulateMedia({ media: "print" }); + const print = await page + .locator("[data-markdown-document]") + .evaluate((container) => ({ text: container.textContent, unclipped: container.clientHeight >= container.scrollHeight })); + assert.equal(print.text, expected.text); + assert.equal(print.unclipped, true); + await page.emulateMedia({ media: "screen" }); + report.interactions.push({ + width, + fixture: fixture.name, + mode: "whole-markdown", + exactText: true, + exactSelection: true, + equalHeight: true, + ...actual.stats, + }); + report.measurements.push({ + width, + fixture: fixture.name, + virtual: "whole-markdown", + cache: "sequential-worker", + mountedRows: actual.stats.mountedElements, + mountedSpans: actual.stats.mountedSpans, + preparationMs: null, + workerPipelineWallMs: actual.stats.workerPipelineWallMs, + indexingMs: null, + publicationBatches: actual.stats.batches, + ...timing, + }); + } + await page.evaluate(() => { + const input = document.createElement("input"); + input.id = "document-input-probe"; + document.body.prepend(input); + window.documentInputEvents = []; + input.addEventListener("input", () => window.documentInputEvents.push(window.codePreviewPrototype.stats().complete)); + window.pauseDocumentPublication = true; + window.documentOutcome = window.codePreviewPrototype + .loadDocument(`# Pending document\n\n${"Paragraph **text** with Unicode \u{1f30d}.\n\n".repeat(4_000)}`) + .then( + () => "complete", + (error) => error.name + ); + }); + await page.waitForFunction(() => { + const stats = window.codePreviewPrototype.stats(); + return stats.batches > 0 && !stats.complete; + }); + await page.locator("#document-input-probe").pressSequentially("responsive"); + const partial = await page.evaluate(async () => { + const container = document.querySelector("[data-markdown-document]"); + const text = container.querySelector("h1").firstChild; + const selection = window.getSelection(); + const range = new Range(); + range.setStart(text, 0); + range.setEnd(text, 7); + selection.removeAllRanges(); + selection.addRange(range); + const before = window.codePreviewPrototype.stats().batches; + window.resumeDocumentPublication(); + await new Promise(requestAnimationFrame); + await new Promise(requestAnimationFrame); + const after = window.codePreviewPrototype.stats().batches; + const stableSelection = selection.anchorNode === text && selection.focusNode === text && selection.toString() === "Pending"; + selection.removeAllRanges(); + const obsolete = window.documentOutcome; + window.pauseDocumentPublication = false; + await window.codePreviewPrototype.loadDocument("# Replacement\n\nLatest document."); + const outcome = await obsolete; + document.getElementById("document-input-probe").remove(); + return { + before, + after, + stableSelection, + inputEvents: window.documentInputEvents, + outcome, + oldTextDetached: !text.isConnected, + text: document.querySelector("[data-markdown-document]").textContent, + }; + }); + assert.ok(partial.after > partial.before); + assert.equal(partial.stableSelection, true); + assert.ok(partial.inputEvents.some((complete) => !complete)); + assert.equal(partial.outcome, "AbortError"); + assert.equal(partial.oldTextDetached, true); + assert.equal(partial.text, "Replacement\nLatest document."); + const rapid = await page.evaluate(async () => { + const obsolete = window.codePreviewPrototype.loadDocument("# Obsolete").then( + () => "complete", + (error) => error.name + ); + await window.codePreviewPrototype.loadDocument("# Current"); + return { outcome: await obsolete, text: document.querySelector("[data-markdown-document]").textContent }; + }); + assert.equal(rapid.outcome, "AbortError"); + assert.equal(rapid.text, "Current"); + await page.evaluate(() => { + window.hiddenDocumentOutcome = window.codePreviewPrototype.loadDocument( + `# Hidden document\n\n${"Paragraph **text**.\n\n".repeat(4_000)}` + ); + }); + await page.waitForFunction(() => { + const stats = window.codePreviewPrototype.stats(); + return stats.batches > 0 && !stats.complete; + }); + const hidden = await page.evaluate(async () => { + const descriptor = Object.getOwnPropertyDescriptor(document, "hidden"); + try { + Object.defineProperty(document, "hidden", { configurable: true, value: true }); + document.dispatchEvent(new Event("visibilitychange")); + await window.hiddenDocumentOutcome; + return { + complete: window.codePreviewPrototype.stats().complete, + text: document.querySelector("[data-markdown-document]").textContent, + }; + } finally { + if (descriptor) Object.defineProperty(document, "hidden", descriptor); + else delete document.hidden; + } + }); + assert.equal(hidden.complete, true); + assert.equal(hidden.text, `Hidden document\n${Array.from({ length: 4_000 }, () => "Paragraph text.").join("\n")}`); + report.interactions.push({ width, mode: "whole-markdown", partial, rapid, hidden: { complete: hidden.complete } }); + await page.evaluate(() => { + window.documentOutcome = window.codePreviewPrototype.loadDocument(`# Failure probe\n\n${"Paragraph **text**.\n\n".repeat(4_000)}`).then( + () => "complete", + (error) => error.message + ); + }); + await page.waitForFunction(() => { + const stats = window.codePreviewPrototype.stats(); + return stats.batches > 0 && !stats.complete; + }); + const failure = await page.evaluate(async () => { + const node = document.querySelector("[data-markdown-document] h1").firstChild; + const before = document.querySelector("[data-markdown-document]").textContent; + const latest = window.documentWorkers.at(-1); + latest.worker.dispatchEvent(new Event("messageerror")); + const outcome = await window.documentOutcome; + const stats = window.codePreviewPrototype.stats(); + return { + outcome, + stats, + terminated: latest.terminated, + retainedText: document.querySelector("[data-markdown-document]").textContent === before, + nodeStable: node.isConnected, + }; + }); + assert.match(failure.outcome, /could not be decoded/); + assert.equal(failure.stats.complete, false); + assert.match(failure.stats.error, /could not be decoded/); + assert.equal(failure.terminated, true); + assert.equal(failure.retainedText, true); + assert.equal(failure.nodeStable, true); + await runDocumentSearchProof(page, report, width); + await page.evaluate(() => window.codePreviewPrototype.loadDocument("# Recovered\n\nReadable again.")); + const cleanup = await page.evaluate(() => { + const node = document.querySelector("[data-markdown-document] h1").firstChild; + window.codePreviewPrototype.shutdownDocument(); + return { + detached: !node.isConnected, + text: document.querySelector("[data-markdown-document]").textContent, + workersTerminated: window.documentWorkers.every((entry) => entry.terminated), + highlights: [...CSS.highlights.keys()].filter((name) => name.startsWith("document-")).length, + }; + }); + assert.equal(cleanup.detached, true); + assert.equal(cleanup.text, ""); + assert.equal(cleanup.workersTerminated, true); + assert.equal(cleanup.highlights, 0); + report.interactions.push({ width, mode: "whole-markdown", failure, cleanup }); + if (process.env.SAMBEE_PREVIEW_PROOF_SCALE === "1") await runDocumentScalingProof(page, report, width); + report.resources ??= []; + if (report.browserName === "chromium") { + const cdp = await page.context().newCDPSession(page); + try { + await cdp.send("HeapProfiler.enable"); + const samples = []; + for (let cycle = 0; cycle < 6; cycle += 1) { + await page.evaluate(async (cycle) => { + await window.codePreviewPrototype.loadDocument( + `# Resource cycle ${cycle}\n\n\`\`\`js\n${"let x=1;\n".repeat(1_200)}\`\`\`\n\nEnd.` + ); + await window.codePreviewPrototype.searchDocument("let x"); + await window.codePreviewPrototype.jumpDocument(1_199); + window.codePreviewPrototype.shutdownDocument(); + await new Promise(requestAnimationFrame); + await new Promise(requestAnimationFrame); + }, cycle); + await cdp.send("HeapProfiler.collectGarbage"); + const heap = await cdp.send("Runtime.getHeapUsage"); + const dom = await cdp.send("Memory.getDOMCounters"); + const owned = await page.evaluate(() => ({ + elements: document.querySelector("[data-markdown-document]").childElementCount, + highlights: [...CSS.highlights.keys()].filter((name) => name.startsWith("document-")).length, + liveWorkers: window.documentWorkers.filter((entry) => !entry.terminated).length, + })); + assert.equal(owned.elements, 0); + assert.equal(owned.highlights, 0); + assert.equal(owned.liveWorkers, 0); + samples.push({ cycle, ...heap, ...dom, owned }); + } + report.resources.push({ + width, + supported: true, + scope: "Chromium main-renderer heap/DOM after explicit GC; excludes worker/process memory and is not an accepted memory ceiling", + samples, + }); + } finally { + await cdp.detach(); + } + } else + report.resources.push({ + width, + supported: false, + reason: "Chromium CDP heap and DOM counters are unavailable in this browser; no zero-memory claim", + }); + await page.evaluate( + (source) => window.codePreviewPrototype.loadDocument(source), + fixtures.find((fixture) => fixture.name === "mixed").source + ); +} + +async function runDocumentScalingProof(page, report, width) { + const fixtures = [ + ...[1, 4, 12].map((blocks) => ({ + name: "dense-code", + units: blocks * 6_000, + query: "let x", + expected: blocks * 6_000, + source: Array.from({ length: blocks }, (_, index) => `## Code ${index}\n\n\`\`\`js\n${"let x=1;\n".repeat(6_000)}\`\`\``).join( + "\n\n" + ), + })), + ...[1_000, 4_000, 8_000].map((paragraphs) => ({ + name: "paragraphs", + units: paragraphs, + query: "strong", + expected: paragraphs, + source: `# Paragraphs\n\n${"Text **strong** and inline `code`.\n\n".repeat(paragraphs)}`, + })), + ...[800, 2_400, 4_800].map((rows) => ({ + name: "table", + units: rows, + query: "cell", + expected: rows, + source: `# Table\n\n| Number | Value |\n| --- | --- |\n${Array.from({ length: rows }, (_, index) => `| ${index} | cell **${index}**
next |`).join("\n")}`, + })), + ]; + report.scaling ??= []; + const cdp = report.browserName === "chromium" ? await page.context().newCDPSession(page) : null; + try { + for (const fixture of fixtures.filter( + (fixture) => process.env.SAMBEE_PREVIEW_PROOF_BUDGETS !== "1" || fixture.units === HOST_ENVELOPE_UNITS[fixture.name] + )) + for (let trial = 0; trial < 3; trial += 1) { + try { + const measurement = await page.evaluate( + async ({ source, query, ordinal }) => { + window.codePreviewPrototype.shutdownDocument(); + const start = performance.now(); + let previous = start; + let maximumTaskServiceGapMs = 0; + const timer = setInterval(() => { + const now = performance.now(); + maximumTaskServiceGapMs = Math.max(maximumTaskServiceGapMs, now - previous); + previous = now; + }, 0); + try { + await window.codePreviewPrototype.loadDocument(source); + await new Promise(requestAnimationFrame); + await new Promise(requestAnimationFrame); + const loadMs = performance.now() - start; + const searchStart = performance.now(); + const result = await window.codePreviewPrototype.searchDocument(query); + const searchMs = performance.now() - searchStart; + const warmStart = performance.now(); + const warm = await window.codePreviewPrototype.searchDocument(query.toUpperCase()); + const warmSearchMs = performance.now() - warmStart; + if (warm.count !== result.count) throw new Error("Warm-index query changed the exact match count"); + const jumpStart = performance.now(); + const jump = await window.codePreviewPrototype.jumpDocument(ordinal); + await new Promise(requestAnimationFrame); + await new Promise(requestAnimationFrame); + const navigationMs = performance.now() - jumpStart; + maximumTaskServiceGapMs = Math.max(maximumTaskServiceGapMs, performance.now() - previous); + return { + loadMs, + searchMs, + warmSearchMs, + navigationMs, + maximumTaskServiceGapMs, + exactCount: result.count, + pending: jump.pending, + ...window.codePreviewPrototype.stats(), + }; + } finally { + clearInterval(timer); + } + }, + { source: fixture.source, query: fixture.query, ordinal: fixture.expected - 1 } + ); + assert.equal(measurement.exactCount, fixture.expected); + assert.equal(measurement.pending, false); + assert.equal(measurement.syntaxError, null); + assert.ok(measurement.peakCachedSyntaxRanges <= measurement.syntaxCacheLimit); + const heap = cdp ? await cdp.send("Runtime.getHeapUsage") : null; + const dom = cdp ? await cdp.send("Memory.getDOMCounters") : null; + report.scaling.push({ + width, + fixture: fixture.name, + units: fixture.units, + sourceUnits: fixture.source.length, + trial, + workerCache: "cold", + measurement, + loadedRendererSnapshot: cdp ? { ...heap, ...dom } : null, + memoryScope: "Loaded main-renderer snapshot only; not peak allocation or worker/native/process memory", + }); + } catch (error) { + const failure = { + width, + fixture: fixture.name, + units: fixture.units, + sourceUnits: fixture.source.length, + trial, + error: error instanceof Error ? error.message : String(error), + }; + report.scaling.push(failure); + report.blockers.push({ ...failure, scope: "Exploratory scaling failure; this size is not accepted as supported" }); + } + } + } finally { + await page.evaluate(() => window.codePreviewPrototype.shutdownDocument()); + if (cdp) await cdp.detach(); + } +} + +async function runDocumentSearchProof(page, report, width) { + await page.evaluate(() => + window.codePreviewPrototype.loadDocument( + `# Search target\n\nA **bold** word. Next.\n\nOther paragraph.\n\n${"Repeated needle.\n\n".repeat(1_200)}\n\`\`\`text\n${"x".repeat(2_047)}\u{1f30d} edge\n\`\`\`\n\nWord word sword\n\n${"a".repeat(128)}!` + ) + ); + const search = await page.evaluate(async () => { + const api = window.codePreviewPrototype; + const container = document.querySelector("[data-markdown-document]"); + const text = container.querySelector("h1").firstChild; + const selection = window.getSelection(); + const range = new Range(); + range.setStart(text, 0); + range.setEnd(text, 6); + selection.removeAllRanges(); + selection.addRange(range); + const cases = []; + for (const [query, options] of [ + ["bold word", {}], + ["bold\\s+word", { regexp: true }], + ["Next.\nOther", {}], + ["\u{1f30d} edge", {}], + ["word", { wholeWord: true }], + ["Word", { wholeWord: true, caseSensitive: true }], + ["(?=word)", { regexp: true }], + ["", {}], + ]) { + const result = await api.searchDocument(query, options); + const highlight = CSS.highlights.get("document-search")?.values().next().value; + cases.push({ query, count: result.count, text: highlight?.toString() ?? null, cached: result.matches.length }); + } + const workersBefore = window.documentWorkers.length; + const result = await api.searchDocument("needle"); + const last = await api.jumpDocument(1_199); + const active = api.stats().activeMatch; + const highlight = CSS.highlights.get("document-search").values().next().value; + const rect = highlight.getBoundingClientRect(); + const viewport = container.getBoundingClientRect(); + const lastVisible = rect.bottom > viewport.top && rect.top < viewport.bottom; + const lateWindow = api.stats().search.cachedMatches; + await api.jumpDocument(1_198); + await api.jumpDocument(0); + const stableSelection = selection.anchorNode === text && selection.focusNode === text && selection.toString() === "Search"; + selection.removeAllRanges(); + return { + cases, + count: result.count, + windowSize: result.matches.length, + last, + active, + lastVisible, + lateWindow, + reusedIndex: window.documentWorkers.length === workersBefore, + stableSelection, + }; + }); + assert.deepEqual( + search.cases.map((result) => result.count), + [1, 1, 1, 1, 3, 1, 4, 0] + ); + assert.equal(search.cases[0].text, "bold word"); + assert.equal(search.cases[3].text, "\u{1f30d} edge"); + assert.equal(search.count, 1_200); + assert.equal(search.windowSize, 64); + assert.equal(search.last.pending, false); + assert.equal(search.active.ordinal, 1_199); + assert.equal(search.lastVisible, true); + assert.ok(search.lateWindow <= 64); + assert.equal(search.reusedIndex, true); + assert.equal(search.stableSelection, true); + const invalid = await page.evaluate(async () => { + try { + await window.codePreviewPrototype.searchDocument("[", { regexp: true }); + return null; + } catch (error) { + return { error: error.message, stats: window.codePreviewPrototype.stats().search }; + } + }); + assert.match(invalid.error, /Invalid document search expression/); + assert.equal(invalid.stats.complete, false); + assert.equal(invalid.stats.count, null); + const supersession = await page.evaluate(async () => { + const obsolete = window.codePreviewPrototype.searchDocument("needle").then( + () => "complete", + (error) => error.name + ); + const latest = await window.codePreviewPrototype.searchDocument("bold word"); + return { obsolete: await obsolete, count: latest.count, text: CSS.highlights.get("document-search").values().next().value.toString() }; + }); + assert.equal(supersession.obsolete, "AbortError"); + assert.equal(supersession.count, 1); + assert.equal(supersession.text, "bold word"); + await page.evaluate(() => { + const input = document.createElement("input"); + input.id = "regex-input-probe"; + document.body.prepend(input); + window.regexInputStates = []; + input.addEventListener("input", () => window.regexInputStates.push(window.codePreviewPrototype.stats().search.complete)); + const start = performance.now(); + window.regexOutcome = null; + window.codePreviewPrototype.searchDocument("(a+)+$", { regexp: true }).then( + () => { + window.regexOutcome = { unexpected: true }; + }, + (error) => { + window.regexOutcome = { + error: error.message, + elapsedMs: performance.now() - start, + terminated: window.documentWorkers.at(-1).terminated, + }; + } + ); + }); + await page.locator("#regex-input-probe").pressSequentially("responsive"); + await page.waitForFunction(() => window.regexOutcome !== null); + const deadline = await page.evaluate(async () => { + const result = window.regexOutcome; + const stats = window.codePreviewPrototype.stats().search; + const inputServiced = window.regexInputStates.some((complete) => !complete); + document.getElementById("regex-input-probe").remove(); + const recovered = await window.codePreviewPrototype.searchDocument("needle"); + return { ...result, stats, inputServiced, recoveredCount: recovered.count }; + }); + assert.match(deadline.error, /deadline; worker terminated/); + assert.equal(deadline.terminated, true); + assert.equal(deadline.stats.count, null); + assert.equal(deadline.stats.complete, false); + assert.equal(deadline.inputServiced, true); + assert.equal(deadline.recoveredCount, 1_200); + await page.evaluate(() => { + window.pauseDocumentPublication = true; + window.pendingDocumentLoad = window.codePreviewPrototype + .loadDocument(`# Partial search\n\n${"Paragraph **text**.\n\n".repeat(1_500)}\n\nUnpublished-target.`) + .then( + () => "complete", + (error) => error.name + ); + }); + await page.waitForFunction(() => !!window.resumeDocumentPublication && window.codePreviewPrototype.stats().batches > 0); + const pending = await page.evaluate(async () => { + const api = window.codePreviewPrototype; + const count = (await api.searchDocument("Unpublished-target")).count; + const navigation = await api.jumpDocument(0); + const before = { ...api.stats().activeMatch }; + window.pauseDocumentPublication = false; + window.resumeDocumentPublication(); + window.resumeDocumentPublication = null; + const outcome = await window.pendingDocumentLoad; + await new Promise(requestAnimationFrame); + await new Promise(requestAnimationFrame); + const range = CSS.highlights.get("document-search").values().next().value; + const rect = range.getBoundingClientRect(); + const viewport = document.querySelector("[data-markdown-document]").getBoundingClientRect(); + return { + count, + navigation, + before, + outcome, + after: api.stats().activeMatch, + text: range.toString(), + visible: rect.bottom > viewport.top && rect.top < viewport.bottom, + }; + }); + assert.equal(pending.count, 1); + assert.equal(pending.navigation.pending, true); + assert.equal(pending.before.pending, true); + assert.equal(pending.outcome, "complete"); + assert.equal(pending.after.pending, false); + assert.equal(pending.text, "Unpublished-target"); + assert.equal(pending.visible, true); + const shutdown = await page.evaluate(async () => { + const obsolete = window.codePreviewPrototype.searchDocument("Paragraph").then( + () => "complete", + (error) => error.name + ); + window.codePreviewPrototype.shutdownDocument(); + return { + outcome: await obsolete, + highlights: [...CSS.highlights.keys()].filter((name) => name.startsWith("document-")).length, + workersTerminated: window.documentWorkers.every((entry) => entry.terminated), + }; + }); + assert.equal(shutdown.outcome, "AbortError"); + assert.equal(shutdown.highlights, 0); + assert.equal(shutdown.workersTerminated, true); + report.interactions.push({ width, mode: "whole-markdown-search", search, invalid, supersession, deadline, pending, shutdown }); +} diff --git a/frontend/scripts/markdown-preview-performance-proof.mjs b/frontend/scripts/markdown-preview-performance-proof.mjs new file mode 100644 index 000000000..63aec6d9c --- /dev/null +++ b/frontend/scripts/markdown-preview-performance-proof.mjs @@ -0,0 +1,1043 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { extname, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; +import { chromium, firefox } from "playwright"; +import { build } from "vite"; +import { documentHostBudgetFailures, runMarkdownDocumentProof } from "./markdown-document-performance-proof.mjs"; + +const FRONTEND = fileURLToPath(new URL("..", import.meta.url)); +const APPROVED_URL = "http://localhost:3000/browse/smb/demo"; +const ASSET_PREFIX = "/__markdown_preview_proof__/"; +const MAX_MOUNTED_ROWS = 40; +const MAX_MOUNTED_SPANS = 4_000; +const NATIVE_FIND_TARGET = "sambee-native-find-target"; +const RENDER_MODES = { + full: false, + virtual: true, + "retained-text": "retained-text", + "retained-text-worker": "retained-text-worker", + "retained-text-worker-progressive": "retained-text-worker-progressive", +}; +const BROWSERS = { chromium, firefox }; +const browserName = process.env.SAMBEE_PREVIEW_PROOF_BROWSER ?? "chromium"; +assert.ok(Object.keys(BROWSERS).includes(browserName), "Unknown proof browser"); +const modeNames = (process.env.SAMBEE_PREVIEW_PROOF_MODES ?? "full,virtual,retained-text").split(","); +assert.ok(modeNames.length > 0 && modeNames.every((name) => Object.keys(RENDER_MODES).includes(name)), "Unknown proof rendering mode"); +const output = await mkdtemp(resolve(tmpdir(), "sambee-preview-proof-")); +const documentProof = process.env.SAMBEE_PREVIEW_PROOF_DOCUMENT === "1"; +const budgetProof = process.env.SAMBEE_PREVIEW_PROOF_BUDGETS === "1"; +assert.ok( + !budgetProof || (documentProof && process.env.SAMBEE_PREVIEW_PROOF_SCALE === "1"), + "Host budgets require whole-document scaling mode" +); +let browser; +const failures = []; +const report = { + kind: "P1 production-built code-only prototype; NOT live-viewer acceptance", + modes: modeNames, + browserName, + measurements: [], + interactions: [], + blockers: [], +}; + +try { + browser = await BROWSERS[browserName].launch({ headless: true }); + report.browser = browser.version(); + await build({ + base: ASSET_PREFIX, + resolve: { conditions: ["worker", "module", "browser", "production"] }, + worker: { + format: "es", + plugins: () => [ + { + name: "preview-worker-diagnostics", + enforce: "pre", + resolveId(source, importer) { + if (source === "../services/logger" && importer?.endsWith("/utils/codeMirrorHighlighting.ts")) { + return resolve(FRONTEND, "scripts/prototypes/markdownCodePreparation.worker.ts"); + } + }, + }, + ], + }, + root: FRONTEND, + configFile: resolve(FRONTEND, "vite.config.ts"), + logLevel: "warn", + build: { + sourcemap: true, + outDir: output, + emptyOutDir: true, + minify: true, + rollupOptions: { + input: resolve(FRONTEND, "scripts/prototypes/markdownCodePreview.tsx"), + output: { entryFileNames: "entry.js", manualChunks: undefined }, + }, + }, + }); + for (const width of [1543, 390]) { + const page = await browser.newPage({ viewport: { width, height: 900 } }); + page.on("pageerror", (error) => failures.push(error.message)); + await page.route(`**${ASSET_PREFIX}**`, async (route) => { + const pathname = new URL(route.request().url()).pathname; + if (pathname === `${ASSET_PREFIX}fixture.png`) + return route.fulfill({ + contentType: "image/png", + body: Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aoykAAAAASUVORK5CYII=", "base64"), + }); + const path = resolve(output, decodeURIComponent(pathname.slice(ASSET_PREFIX.length))); + if (!path.startsWith(`${output}${sep}`)) return route.abort(); + try { + await route.fulfill({ body: await readFile(path), contentType: extname(path) === ".css" ? "text/css" : "text/javascript" }); + } catch { + failures.push(`Prototype asset missing: ${pathname}`); + await route.abort(); + } + }); + await page.route(APPROVED_URL, (route) => + route.fulfill({ + contentType: "text/html", + body: `P1 code preview proof
`, + }) + ); + await page.goto(APPROVED_URL); + await page.waitForFunction(() => Boolean(window.codePreviewPrototype)); + if (documentProof) { + report.kind = "P1 production-built whole-Markdown prototype; NOT live-viewer acceptance"; + await runMarkdownDocumentProof(page, report, width); + await page.screenshot({ path: resolve(output, `document-${width}.png`) }); + await page.close(); + continue; + } + const fixtures = [ + { name: "dense", blocks: ["let x=1;\n".repeat(6_000)] }, + { name: "near-safeguard", blocks: ["let x=1;\n".repeat(11_111)] }, + { name: "many-blocks", blocks: Array.from({ length: 600 }, () => "let x=1;\n".repeat(101)) }, + { + name: "whitespace", + blocks: ["let x = '\u{1f30d}';\t // preserve spaces\n".repeat(37), `${" \t\n\nlet x = 2;\r\n".repeat(25)}end`], + }, + ]; + for (const fixture of fixtures) { + for (const modeName of modeNames) { + const virtual = RENDER_MODES[modeName]; + await page.goto(APPROVED_URL); + await page.waitForFunction(() => Boolean(window.codePreviewPrototype)); + for (const cache of ["cold", "cached"]) { + const measurement = await page.evaluate( + async ({ blocks, virtual }) => { + const start = performance.now(); + const longTasks = []; + const taskServiceGaps = []; + let lastTaskAt = start; + let taskProbeTimer; + const probeTask = () => { + const now = performance.now(); + taskServiceGaps.push(now - lastTaskAt); + lastTaskAt = now; + taskProbeTimer = setTimeout(probeTask, 0); + }; + taskProbeTimer = setTimeout(probeTask, 0); + const longTaskObservationSupported = PerformanceObserver.supportedEntryTypes.includes("longtask"); + const observer = new PerformanceObserver((list) => { + longTasks.push(...list.getEntries().map((entry) => entry.duration)); + }); + if (longTaskObservationSupported) observer.observe({ type: "longtask", buffered: false }); + await window.codePreviewPrototype.load(blocks, virtual); + await new Promise(requestAnimationFrame); + await new Promise(requestAnimationFrame); + longTasks.push(...observer.takeRecords().map((entry) => entry.duration)); + observer.disconnect(); + clearTimeout(taskProbeTimer); + taskServiceGaps.push(performance.now() - lastTaskAt); + return { + ...window.codePreviewPrototype.stats(), + loadToPaintMs: performance.now() - start, + longTasks, + longTaskObservationSupported, + taskServiceProbeCount: taskServiceGaps.length, + maximumTaskServiceGapMs: Math.max(...taskServiceGaps), + }; + }, + { blocks: fixture.blocks, virtual } + ); + assert.equal(measurement.sourceUnits, fixture.blocks.join("").length); + assert.equal(measurement.matchCount, fixture.blocks.join("").split("let x").length - 1); + assert.equal(measurement.oversizedLines, 0); + if (virtual === true) { + assert.ok(measurement.mountedRows > 0 && measurement.mountedRows <= MAX_MOUNTED_ROWS, JSON.stringify(measurement)); + assert.ok(measurement.mountedSpans <= MAX_MOUNTED_SPANS, JSON.stringify(measurement)); + assert.ok(measurement.mountedTextUnits < measurement.sourceUnits); + } else { + assert.equal(measurement.mountedTextUnits, measurement.sourceUnits); + } + if (typeof virtual === "string") { + assert.equal(measurement.execution, virtual.startsWith("retained-text-worker") ? "worker" : "main-thread"); + assert.equal(measurement.preparationComplete, true); + assert.equal(measurement.searchComplete, true); + if (virtual === "retained-text-worker-progressive" && fixture.name !== "whitespace") { + assert.ok(measurement.sourceIndexingYields > 0); + } + if (virtual.startsWith("retained-text-worker")) { + assert.equal(measurement.sourceUnitsSent, measurement.sourceUnits); + assert.equal( + measurement.sourceChunks, + fixture.blocks.reduce((count, text) => count + Math.max(1, Math.ceil(text.length / measurement.sourceChunkLimit)), 0) + ); + assert.ok(measurement.largestSourceChunkUnits <= measurement.sourceChunkLimit); + assert.ok(measurement.transferChunks > 0); + assert.ok(measurement.largestChunkItems > 0 && measurement.largestChunkItems <= measurement.transferChunkLimit); + } + assert.equal(measurement.publicationComplete, true); + assert.equal(measurement.publishedRows, measurement.totalRows); + assert.ok(measurement.largestPublicationBatch <= measurement.publicationBatchLimit); + assert.equal(measurement.publicationBatches, Math.max(1, Math.ceil(measurement.totalRows / measurement.publicationBatchLimit))); + } + const jumps = []; + if (virtual) { + for (const ordinal of [measurement.matchCount - 1, 0, Math.floor(measurement.matchCount / 2)]) { + const start = performance.now(); + await page.evaluate((target) => window.codePreviewPrototype.jump(target), ordinal); + await page.waitForFunction((target) => { + const stats = window.codePreviewPrototype.stats(); + return stats.active === target && stats.activeMounted; + }, ordinal); + const stats = await page.evaluate(() => window.codePreviewPrototype.stats()); + if (virtual === true) assert.ok(stats.mountedRows <= MAX_MOUNTED_ROWS, JSON.stringify(stats)); + else { + assert.ok(stats.decoratedRows <= MAX_MOUNTED_ROWS, JSON.stringify(stats)); + assert.ok(stats.highlightRanges <= MAX_MOUNTED_SPANS, JSON.stringify(stats)); + } + jumps.push({ ordinal, commandToObservedMs: performance.now() - start }); + } + await page.setViewportSize({ width: width === 390 ? 1543 : 390, height: 900 }); + await page.waitForFunction(() => window.codePreviewPrototype.stats().activeMounted); + await page.setViewportSize({ width, height: 900 }); + const nativeSelection = await page.evaluate((blocks) => { + const selection = window.getSelection(); + const container = document.querySelector("[data-prototype-scroll]"); + const candidateHeight = container.scrollHeight; + selection.selectAllChildren(container); + const text = selection.toString(); + const baseline = document.createElement("div"); + for (const block of blocks) { + const pre = document.createElement("pre"); + pre.style.margin = "0"; + pre.style.whiteSpace = "pre-wrap"; + pre.style.overflowWrap = "anywhere"; + pre.style.fontSize = "14px"; + pre.style.lineHeight = "20px"; + pre.textContent = block; + baseline.append(pre); + } + document.body.append(baseline); + baseline.style.width = `${container.clientWidth}px`; + const baselineHeight = baseline.getBoundingClientRect().height; + selection.selectAllChildren(baseline); + const expected = selection.toString(); + selection.removeAllRanges(); + baseline.remove(); + let uncontainedSelection = null; + if (text !== expected && container.hasAttribute("data-retained-text")) { + const style = document.createElement("style"); + style.textContent = "[data-retained-text] pre{content-visibility:visible!important}"; + document.head.append(style); + selection.selectAllChildren(container); + uncontainedSelection = { units: selection.toString().length, exact: selection.toString() === expected }; + selection.removeAllRanges(); + style.remove(); + } + return { + units: text.length, + baselineUnits: expected.length, + exact: text === expected, + exactDomText: container.textContent === blocks.join(""), + uncontainedSelection, + candidateHeight, + baselineHeight, + }; + }, fixture.blocks); + report.interactions.push({ + fixture: fixture.name, + width, + mode: modeName, + nativeSelection, + modelUnits: measurement.sourceUnits, + }); + if (typeof virtual === "string") { + assert.equal(nativeSelection.exact, true, JSON.stringify(nativeSelection)); + assert.equal(nativeSelection.exactDomText, true); + assert.equal(nativeSelection.candidateHeight, Math.max(480, nativeSelection.baselineHeight)); + if (fixture.name !== "whitespace") { + const expectedHeight = fixture.blocks.join("").split("\n").length - 1; + assert.equal(nativeSelection.candidateHeight, Math.max(480, expectedHeight * 20)); + } + } + } + report.measurements.push({ fixture: fixture.name, width, virtual, cache, ...measurement, jumps }); + } + } + } + for (const retainedMode of modeNames.filter((name) => typeof RENDER_MODES[name] === "string")) { + if (retainedMode === "retained-text-worker-progressive") { + const indexingSupersession = await page.evaluate(async (mode) => { + const nativePostMessage = Worker.prototype.postMessage; + const workerJobs = []; + Worker.prototype.postMessage = function (message, ...args) { + if (message.type === "prepare") workerJobs.push(message.generation); + return nativePostMessage.call(this, message, ...args); + }; + try { + const obsolete = window.codePreviewPrototype.load(["let x=0;\n".repeat(6_000)], mode).then( + () => "complete", + (error) => error.name + ); + await window.codePreviewPrototype.load(["let x=1;\n"], mode); + return { + obsolete: await obsolete, + workerJobs, + stats: window.codePreviewPrototype.stats(), + text: document.querySelector("[data-retained-text]").textContent, + }; + } finally { + Worker.prototype.postMessage = nativePostMessage; + } + }, retainedMode); + assert.equal(indexingSupersession.obsolete, "AbortError"); + assert.deepEqual(indexingSupersession.workerJobs, [indexingSupersession.stats.generation]); + assert.equal(indexingSupersession.text, "let x=1;\n"); + report.interactions.push({ width, mode: retainedMode, indexingSupersession }); + await page.evaluate( + ({ blocks, mode }) => { + const nativePostMessage = Worker.prototype.postMessage; + Worker.prototype.postMessage = function (message, ...args) { + if (message.type === "run") { + window.releaseProgressiveWorker = () => { + Worker.prototype.postMessage = nativePostMessage; + nativePostMessage.call(this, message, ...args); + }; + return; + } + return nativePostMessage.call(this, message, ...args); + }; + const input = document.createElement("input"); + input.id = "progressive-input-probe"; + document.body.prepend(input); + window.progressiveInputEvents = []; + input.addEventListener("input", () => + window.progressiveInputEvents.push(window.codePreviewPrototype.stats().preparationComplete) + ); + window.progressiveOutcome = window.codePreviewPrototype.load(blocks, mode).then( + () => "complete", + (error) => error.name + ); + }, + { blocks: fixtures.find((fixture) => fixture.name === "many-blocks").blocks, mode: retainedMode } + ); + await page.waitForFunction( + () => + window.releaseProgressiveWorker && + window.codePreviewPrototype.stats().publishedRows > 0 && + !window.codePreviewPrototype.stats().preparationComplete + ); + await page.locator("#progressive-input-probe").pressSequentially("responsive"); + const beforePreparation = await page.evaluate(async () => { + await new Promise(requestAnimationFrame); + await new Promise(requestAnimationFrame); + const firstText = document.querySelector("[data-retained-text] [data-index]").firstChild; + const selection = window.getSelection(); + const range = new Range(); + range.setStart(firstText, 0); + range.setEnd(firstText, 5); + selection.removeAllRanges(); + selection.addRange(range); + window.progressiveFirstText = firstText; + let pendingSearch; + try { + window.codePreviewPrototype.jump(0); + } catch (error) { + pendingSearch = error.message; + } + return { + ...window.codePreviewPrototype.stats(), + pendingSearch, + inputEvents: window.progressiveInputEvents, + inputValue: document.getElementById("progressive-input-probe").value, + }; + }); + assert.equal(beforePreparation.preparationComplete, false); + assert.equal(beforePreparation.searchComplete, false); + assert.ok(beforePreparation.mountedTextUnits > 0); + assert.match(beforePreparation.pendingSearch, /search preparation is pending/); + assert.equal(beforePreparation.inputValue, "responsive"); + assert.ok(beforePreparation.inputEvents.length > 0 && beforePreparation.inputEvents.every((complete) => !complete)); + const afterPreparation = await page.evaluate(async () => { + window.releaseProgressiveWorker(); + const outcome = await window.progressiveOutcome; + await new Promise(requestAnimationFrame); + await new Promise(requestAnimationFrame); + const firstText = window.progressiveFirstText; + const selection = window.getSelection(); + const result = { + ...window.codePreviewPrototype.stats(), + outcome, + textNodeStable: document.querySelector("[data-retained-text] [data-index]").firstChild === firstText, + selectionStable: selection.anchorNode === firstText && selection.focusNode === firstText && selection.toString() === "let x", + exactText: document.querySelector("[data-retained-text]").textContent === "let x=1;\n".repeat(600 * 101), + }; + selection.removeAllRanges(); + document.getElementById("progressive-input-probe").remove(); + delete window.progressiveFirstText; + delete window.releaseProgressiveWorker; + return result; + }); + assert.equal(afterPreparation.outcome, "complete"); + assert.equal(afterPreparation.preparationComplete, true); + assert.equal(afterPreparation.searchComplete, true); + assert.equal(afterPreparation.textNodeStable, true); + assert.equal(afterPreparation.selectionStable, true); + assert.equal(afterPreparation.exactText, true); + assert.equal(afterPreparation.matchCount, 600 * 101); + assert.ok(afterPreparation.highlightRanges > 0); + report.interactions.push({ width, mode: retainedMode, beforePreparation, afterPreparation }); + + const progressiveFailure = await page.evaluate(async (mode) => { + const nativePostMessage = Worker.prototype.postMessage; + Worker.prototype.postMessage = function (message, ...args) { + if (message.type === "run") { + Worker.prototype.postMessage = nativePostMessage; + queueMicrotask(() => this.dispatchEvent(new ErrorEvent("error", { message: "proof progressive failure", cancelable: true }))); + return; + } + return nativePostMessage.call(this, message, ...args); + }; + try { + let outcome; + try { + await window.codePreviewPrototype.load(["let x=7;\n"], mode); + outcome = "unexpected success"; + } catch (error) { + outcome = error.message; + } + return { + outcome, + stats: window.codePreviewPrototype.stats(), + text: document.querySelector("[data-retained-text]").textContent, + }; + } finally { + Worker.prototype.postMessage = nativePostMessage; + } + }, retainedMode); + assert.match(progressiveFailure.outcome, /proof progressive failure/); + assert.match(progressiveFailure.stats.preparationError, /proof progressive failure/); + assert.equal(progressiveFailure.stats.preparationComplete, false); + assert.equal(progressiveFailure.stats.searchComplete, false); + assert.equal(progressiveFailure.stats.publicationComplete, true); + assert.equal(progressiveFailure.text, "let x=7;\n"); + + await page.evaluate((mode) => { + const nativePostMessage = Worker.prototype.postMessage; + Worker.prototype.postMessage = function (message, ...args) { + if (message.type === "run") { + Worker.prototype.postMessage = nativePostMessage; + const listener = this.onmessage; + window.deliverObsoleteProgressiveResult = () => + listener.call( + this, + new MessageEvent("message", { + data: { type: "complete", generation: message.generation, preparationMs: 0, indexingMs: 0 }, + }) + ); + return; + } + return nativePostMessage.call(this, message, ...args); + }; + window.obsoleteProgressiveOutcome = window.codePreviewPrototype.load(["let x=8;\n".repeat(6_000)], mode).then( + () => "complete", + (error) => error.name + ); + }, retainedMode); + await page.waitForFunction( + () => + window.deliverObsoleteProgressiveResult && + !window.codePreviewPrototype.stats().preparationComplete && + window.codePreviewPrototype.stats().publishedRows > 0 + ); + const progressiveSupersession = await page.evaluate(async (mode) => { + const firstText = document.querySelector("[data-retained-text] [data-index]").firstChild; + const obsoleteGeneration = window.codePreviewPrototype.stats().generation; + await window.codePreviewPrototype.load(["let x=9;\n"], mode); + const outcome = await window.obsoleteProgressiveOutcome; + window.deliverObsoleteProgressiveResult(); + await new Promise(requestAnimationFrame); + await new Promise(requestAnimationFrame); + delete window.deliverObsoleteProgressiveResult; + return { + outcome, + obsoleteGeneration, + stats: window.codePreviewPrototype.stats(), + oldTextDetached: !firstText.isConnected, + text: document.querySelector("[data-retained-text]").textContent, + }; + }, retainedMode); + assert.equal(progressiveSupersession.outcome, "AbortError"); + assert.ok(progressiveSupersession.stats.generation > progressiveSupersession.obsoleteGeneration); + assert.equal(progressiveSupersession.stats.preparationComplete, true); + assert.equal(progressiveSupersession.stats.preparationError, null); + assert.equal(progressiveSupersession.oldTextDetached, true); + assert.equal(progressiveSupersession.text, "let x=9;\n"); + report.interactions.push({ width, mode: retainedMode, progressiveFailure, progressiveSupersession }); + } else { + await page.evaluate( + ({ blocks, mode }) => { + const input = document.createElement("input"); + input.id = "publication-input-probe"; + document.body.prepend(input); + window.publicationInputEvents = []; + input.addEventListener("input", () => { + const stats = window.codePreviewPrototype.stats(); + window.publicationInputEvents.push({ + complete: stats.publicationComplete, + publishedRows: stats.publishedRows, + value: input.value, + }); + }); + window.publicationOutcome = window.codePreviewPrototype.load(blocks, mode).then( + () => ({ status: "complete" }), + (error) => ({ status: error.name }) + ); + }, + { blocks: fixtures.find((fixture) => fixture.name === "many-blocks").blocks, mode: retainedMode } + ); + await page.waitForFunction(() => { + const stats = window.codePreviewPrototype.stats(); + return stats.totalRows === 4200 && stats.publishedRows > 0 && !stats.publicationComplete; + }); + const selectionDuringPublication = await page.evaluate(async () => { + const firstText = document.querySelector("[data-retained-text] [data-index]").firstChild; + const selection = window.getSelection(); + const range = new Range(); + range.setStart(firstText, 0); + range.setEnd(firstText, 5); + selection.removeAllRanges(); + selection.addRange(range); + const before = window.codePreviewPrototype.stats().publishedRows; + await new Promise(requestAnimationFrame); + await new Promise(requestAnimationFrame); + const after = window.codePreviewPrototype.stats().publishedRows; + const result = { + before, + after, + firstTextStable: document.querySelector("[data-retained-text] [data-index]").firstChild === firstText, + anchorStable: selection.anchorNode === firstText, + focusStable: selection.focusNode === firstText, + selected: selection.toString(), + }; + selection.removeAllRanges(); + return result; + }); + assert.ok(selectionDuringPublication.after > selectionDuringPublication.before); + assert.equal(selectionDuringPublication.firstTextStable, true); + assert.equal(selectionDuringPublication.anchorStable, true); + assert.equal(selectionDuringPublication.focusStable, true); + assert.equal(selectionDuringPublication.selected, "let x"); + report.interactions.push({ width, mode: retainedMode, selectionDuringPublication }); + const pendingJump = await page.evaluate(() => { + const target = Math.floor(window.codePreviewPrototype.stats().matchCount / 2); + const supersededTarget = window.codePreviewPrototype.stats().matchCount - 1; + const superseded = window.codePreviewPrototype.jump(supersededTarget); + const result = window.codePreviewPrototype.jump(target); + const stats = window.codePreviewPrototype.stats(); + return { + target, + supersededTarget, + supersededPending: superseded.pending, + pending: result.pending, + pendingMatch: stats.pendingMatch, + activeMounted: stats.activeMounted, + publicationBatchesAtRequest: stats.publicationBatches, + }; + }); + assert.equal(pendingJump.supersededPending, true); + assert.equal(pendingJump.pending, true); + assert.equal(pendingJump.pendingMatch, pendingJump.target); + assert.equal(pendingJump.activeMounted, false); + await page.waitForFunction((target) => { + const stats = window.codePreviewPrototype.stats(); + return stats.active === target && stats.pendingMatch === null && stats.activeMounted; + }, pendingJump.target); + const priorityReveal = await page.evaluate(async () => { + const stats = window.codePreviewPrototype.stats(); + const container = document.querySelector("[data-retained-text]"); + const element = container.querySelector(`[data-index="${stats.activeRow}"]`); + const rect = element.getBoundingClientRect(); + const viewport = container.getBoundingClientRect(); + const textNode = element.firstChild; + const selection = window.getSelection(); + const range = new Range(); + range.setStart(textNode, 0); + range.setEnd(textNode, 5); + selection.removeAllRanges(); + selection.addRange(range); + await new Promise(requestAnimationFrame); + await new Promise(requestAnimationFrame); + const result = { + publicationCompleteAtReveal: stats.publicationComplete, + publishedRowsAtReveal: stats.publishedRows, + publicationBatchesAtReveal: stats.publicationBatches, + totalRows: stats.totalRows, + targetVisible: rect.bottom > viewport.top && rect.top < viewport.bottom, + destinationTextStable: element.firstChild === textNode, + destinationSelectionStable: + selection.anchorNode === textNode && selection.focusNode === textNode && selection.toString() === "let x", + publishedRowsAfterSelection: window.codePreviewPrototype.stats().publishedRows, + }; + selection.removeAllRanges(); + return result; + }); + assert.equal(priorityReveal.publicationCompleteAtReveal, false); + assert.ok(priorityReveal.publishedRowsAtReveal < priorityReveal.totalRows); + assert.equal(priorityReveal.targetVisible, true); + assert.equal(priorityReveal.destinationTextStable, true); + assert.equal(priorityReveal.destinationSelectionStable, true); + assert.ok(priorityReveal.publishedRowsAfterSelection > priorityReveal.publishedRowsAtReveal); + await page.locator("#publication-input-probe").pressSequentially("responsive"); + const publicationInput = await page.evaluate(async () => { + const outcome = await window.publicationOutcome; + const events = window.publicationInputEvents; + const stats = window.codePreviewPrototype.stats(); + document.getElementById("publication-input-probe").remove(); + return { outcome, events, publicationBatches: stats.publicationBatches, publicationComplete: stats.publicationComplete }; + }); + assert.equal(publicationInput.outcome.status, "complete"); + assert.equal(publicationInput.publicationComplete, true); + assert.ok( + publicationInput.events.some((event) => !event.complete), + JSON.stringify(publicationInput) + ); + assert.equal(publicationInput.events.at(-1).value, "responsive"); + await page.waitForFunction((target) => { + const stats = window.codePreviewPrototype.stats(); + return stats.active === target && stats.pendingMatch === null && stats.activeMounted; + }, pendingJump.target); + const priorityCompletion = await page.evaluate(() => { + const stats = window.codePreviewPrototype.stats(); + const container = document.querySelector("[data-retained-text]"); + const indices = [...container.querySelectorAll("[data-index]")].map((element) => Number(element.dataset.index)); + const target = container.querySelector(`[data-index="${stats.activeRow}"]`).getBoundingClientRect(); + const viewport = container.getBoundingClientRect(); + return { + priorityPublishedRows: stats.priorityPublishedRows, + exactOrder: indices.every((index, position) => index === position), + exactText: container.textContent === "let x=1;\n".repeat(600 * 101), + targetVisible: target.bottom > viewport.top && target.top < viewport.bottom, + }; + }); + assert.ok(priorityCompletion.priorityPublishedRows > 0); + assert.equal(priorityCompletion.exactOrder, true); + assert.equal(priorityCompletion.exactText, true); + assert.equal(priorityCompletion.targetVisible, true); + report.interactions.push({ + width, + mode: retainedMode, + pendingJump, + pendingJumpRevealed: true, + priorityReveal, + priorityCompletion, + }); + report.interactions.push({ width, mode: retainedMode, publicationInput }); + + const previousGeneration = await page.evaluate((mode) => { + const previous = window.codePreviewPrototype.stats().generation; + window.obsoletePublication = window.codePreviewPrototype.load(["let x=1;\n".repeat(11_111)], mode).then( + () => ({ status: "complete" }), + (error) => ({ status: error.name }) + ); + return previous; + }, retainedMode); + await page.waitForFunction((previous) => { + const stats = window.codePreviewPrototype.stats(); + return stats.generation > previous && stats.publishedRows > 0 && !stats.publicationComplete; + }, previousGeneration); + const cancelledPublication = await page.evaluate(async (mode) => { + const firstText = document.querySelector("[data-retained-text] [data-index]").firstChild; + const obsoleteGeneration = window.codePreviewPrototype.stats().generation; + await window.codePreviewPrototype.load(["let x=2;\n"], mode); + const obsolete = await window.obsoletePublication; + await new Promise(requestAnimationFrame); + await new Promise(requestAnimationFrame); + return { + obsolete, + oldTextDetached: !firstText.isConnected, + generation: window.codePreviewPrototype.stats().generation, + obsoleteGeneration, + text: document.querySelector("[data-retained-text]").textContent, + publicationComplete: window.codePreviewPrototype.stats().publicationComplete, + }; + }, retainedMode); + assert.equal(cancelledPublication.obsolete.status, "AbortError"); + assert.equal(cancelledPublication.oldTextDetached, true); + assert.ok(cancelledPublication.generation > cancelledPublication.obsoleteGeneration); + assert.equal(cancelledPublication.text, "let x=2;\n"); + assert.equal(cancelledPublication.publicationComplete, true); + report.interactions.push({ width, mode: retainedMode, cancelledPublication }); + + const cancelledPreparation = await page.evaluate(async (mode) => { + const obsolete = window.codePreviewPrototype.load(["let x=3;\n"], mode).then( + () => ({ status: "complete" }), + (error) => ({ status: error.name }) + ); + await window.codePreviewPrototype.load(["let x=4;\n"], mode); + return { + obsolete: await obsolete, + text: document.querySelector("[data-retained-text]").textContent, + publicationComplete: window.codePreviewPrototype.stats().publicationComplete, + }; + }, retainedMode); + assert.equal(cancelledPreparation.obsolete.status, "AbortError"); + assert.equal(cancelledPreparation.text, "let x=4;\n"); + assert.equal(cancelledPreparation.publicationComplete, true); + report.interactions.push({ width, mode: retainedMode, cancelledPreparation }); + + if (retainedMode === "retained-text-worker") { + const workerRecovery = await page.evaluate(async (mode) => { + const NativeWorker = window.Worker; + const nativeTerminate = NativeWorker.prototype.terminate; + const nativePostMessage = NativeWorker.prototype.postMessage; + let sourceChunksSent = 0; + let firstSourceUnits = 0; + let notifyTransferStarted; + const transferStarted = new Promise((resolve) => { + notifyTransferStarted = resolve; + }); + NativeWorker.prototype.postMessage = function (message, ...args) { + const result = nativePostMessage.call(this, message, ...args); + if (message.type === "source") { + sourceChunksSent += 1; + firstSourceUnits ||= message.text.length; + notifyTransferStarted(); + } + return result; + }; + let terminations = 0; + const outcomes = []; + NativeWorker.prototype.terminate = function () { + terminations += 1; + return nativeTerminate.call(this); + }; + const outcome = async () => { + try { + await window.codePreviewPrototype.load(["let x=5;\n"], mode); + return "unexpected success"; + } catch (error) { + return error.message; + } + }; + try { + const obsolete = window.codePreviewPrototype.load(["let x=3;\n".repeat(11_111)], mode).catch((error) => error.name); + await Promise.race([ + transferStarted, + obsolete.then(() => { + throw new Error("Obsolete preparation settled before the transfer-cancellation probe"); + }), + ]); + const sourceChunksAtCancellation = sourceChunksSent; + await window.codePreviewPrototype.load(["stable view\n"], "retained-text"); + const cancellation = await obsolete; + const stableText = document.querySelector("[data-retained-text]").firstChild; + window.Worker = undefined; + outcomes.push(await outcome()); + window.Worker = class { + constructor() { + throw new Error("proof constructor failure"); + } + }; + outcomes.push(await outcome()); + window.Worker = class extends NativeWorker { + constructor(...args) { + super(...args); + queueMicrotask(() => this.dispatchEvent(new ErrorEvent("error", { message: "proof worker crash", cancelable: true }))); + } + }; + outcomes.push(await outcome()); + window.Worker = class extends NativeWorker { + constructor(...args) { + super(...args); + queueMicrotask(() => this.dispatchEvent(new Event("messageerror"))); + } + }; + outcomes.push(await outcome()); + const stableViewPreserved = + stableText.isConnected && document.querySelector("[data-retained-text]").textContent === "stable view\n"; + window.Worker = NativeWorker; + await window.codePreviewPrototype.load(["let x=6;\n"], mode); + return { + cancellation, + sourceChunksAtCancellation, + firstSourceUnits, + outcomes, + terminations, + stableViewPreserved, + text: document.querySelector("[data-retained-text]").textContent, + }; + } finally { + window.Worker = NativeWorker; + NativeWorker.prototype.terminate = nativeTerminate; + NativeWorker.prototype.postMessage = nativePostMessage; + } + }, retainedMode); + report.interactions.push({ width, mode: retainedMode, workerRecovery }); + assert.equal(workerRecovery.cancellation, "AbortError"); + assert.equal(workerRecovery.sourceChunksAtCancellation, 1); + assert.ok(workerRecovery.firstSourceUnits > 0); + assert.ok(workerRecovery.terminations >= 3, JSON.stringify(workerRecovery)); + assert.match(workerRecovery.outcomes[0], /unavailable; no main-thread fallback/); + assert.equal(workerRecovery.outcomes[1], "proof constructor failure"); + assert.match(workerRecovery.outcomes[2], /proof worker crash/); + assert.match(workerRecovery.outcomes[3], /could not be decoded/); + assert.equal(workerRecovery.stableViewPreserved, true); + assert.equal(workerRecovery.text, "let x=6;\n"); + + const sourceBoundaryParity = await page.evaluate(async (mode) => { + const limit = window.codePreviewPrototype.stats().sourceChunkLimit; + const paddingLine = "// filler\n"; + const padding = (units) => paddingLine.repeat(Math.floor(units / paddingLine.length)) + " ".repeat(units % paddingLine.length); + let source = `${padding(limit - 1 - "// ".length)}// \u{1f30d}\r\n`; + source += `${padding(2 * limit - 2 - source.length)}let x = 1;\r\n`; + source += `${padding(3 * limit - 1 - source.length)}\r\n// end\n`; + const blocks = ["", source, "", "let x = 2;\n", ""]; + await window.codePreviewPrototype.load(blocks, "retained-text"); + const expected = window.codePreviewPrototype.inspect(); + await window.codePreviewPrototype.load(blocks, mode); + const actual = window.codePreviewPrototype.inspect(); + const stats = window.codePreviewPrototype.stats(); + const result = { + exactModels: JSON.stringify(actual.models) === JSON.stringify(expected.models), + exactMatches: JSON.stringify(actual.matches) === JSON.stringify(expected.matches), + exactDomText: document.querySelector("[data-retained-text]").textContent === blocks.join(""), + surrogateSplit: source.charCodeAt(limit - 1) === 0xd83c && source.charCodeAt(limit) === 0xdf0d, + querySplit: source.slice(2 * limit - 2, 2 * limit + 3) === "let x", + crlfSplit: source.slice(3 * limit - 1, 3 * limit + 1) === "\r\n", + chunks: stats.sourceChunks, + expectedChunks: blocks.reduce((count, text) => count + Math.max(1, Math.ceil(text.length / limit)), 0), + }; + await window.codePreviewPrototype.load([], mode); + const empty = window.codePreviewPrototype.inspect(); + const emptyStats = window.codePreviewPrototype.stats(); + result.emptyDocument = + empty.models.length === 0 && + empty.matches.length === 0 && + emptyStats.sourceChunks === 0 && + emptyStats.publicationComplete && + document.querySelector("[data-retained-text]").textContent === ""; + return result; + }, retainedMode); + report.interactions.push({ width, mode: retainedMode, sourceBoundaryParity }); + for (const property of [ + "exactModels", + "exactMatches", + "exactDomText", + "surrogateSplit", + "querySplit", + "crlfSplit", + "emptyDocument", + ]) { + assert.equal(sourceBoundaryParity[property], true, JSON.stringify(sourceBoundaryParity)); + } + assert.equal(sourceBoundaryParity.chunks, sourceBoundaryParity.expectedChunks); + } + } + const interactionBlocks = [ + `${"let x=1;\n".repeat(6_000)}// ${NATIVE_FIND_TARGET}\n`, + `${"let x=1; // wrapped text with tabs\tand Unicode \u{1f30d} ".repeat(20)}\n`.repeat(30), + ]; + await page.evaluate(({ blocks, mode }) => window.codePreviewPrototype.load(blocks, mode), { + blocks: interactionBlocks, + mode: retainedMode, + }); + await page.waitForFunction(() => window.codePreviewPrototype.stats().decoratedRows > 0); + const interaction = await page.evaluate(async (target) => { + const container = document.querySelector("[data-prototype-scroll]"); + const root = document.getElementById("prototype"); + const firstText = container.querySelector("[data-index]").firstChild; + const selection = window.getSelection(); + const range = new Range(); + range.setStart(firstText, 0); + range.setEnd(firstText, 5); + selection.removeAllRanges(); + selection.addRange(range); + const anchor = selection.anchorNode; + const focus = selection.focusNode; + const selected = selection.toString(); + root.style.setProperty("--sambee-syntax-keyword", "#13579b"); + container.scrollTop = container.scrollHeight; + await new Promise(requestAnimationFrame); + await new Promise(requestAnimationFrame); + await new Promise(requestAnimationFrame); + const selectionStable = selection.anchorNode === anchor && selection.focusNode === focus && selection.toString() === selected; + const textNodeStable = container.querySelector("[data-index]").firstChild === firstText; + const keywordColor = getComputedStyle(root, "::highlight(preview-keyword)").color; + selection.removeAllRanges(); + container.scrollTop = 0; + await new Promise(requestAnimationFrame); + await new Promise(requestAnimationFrame); + const findStart = performance.now(); + const found = window.find(target, false, false, true); + const findMs = performance.now() - findStart; + await new Promise(requestAnimationFrame); + await new Promise(requestAnimationFrame); + const nativeFindSelection = selection.toString(); + const findRange = selection.rangeCount ? selection.getRangeAt(0).getBoundingClientRect() : null; + const viewport = container.getBoundingClientRect(); + const findVisible = Boolean(findRange && findRange.bottom > viewport.top && findRange.top < viewport.bottom); + selection.removeAllRanges(); + const baselineStyle = document.createElement("style"); + baselineStyle.textContent = "[data-retained-text] pre{content-visibility:visible!important}"; + document.head.append(baselineStyle); + container.scrollTop = 0; + await new Promise(requestAnimationFrame); + await new Promise(requestAnimationFrame); + const baselineFound = window.find(target, false, false, true); + await new Promise(requestAnimationFrame); + await new Promise(requestAnimationFrame); + const baselineRange = selection.rangeCount ? selection.getRangeAt(0).getBoundingClientRect() : null; + const baselineVisible = Boolean(baselineRange && baselineRange.bottom > viewport.top && baselineRange.top < viewport.bottom); + selection.removeAllRanges(); + baselineStyle.remove(); + return { + selectionStable, + textNodeStable, + keywordColor, + found, + nativeFindSelection, + findVisible, + findMs, + baselineFound, + baselineVisible, + }; + }, NATIVE_FIND_TARGET); + assert.equal(interaction.selectionStable, true); + assert.equal(interaction.textNodeStable, true); + assert.equal(interaction.keywordColor, "rgb(19, 87, 155)"); + assert.equal(interaction.found, true); + assert.equal(interaction.nativeFindSelection, NATIVE_FIND_TARGET); + assert.equal(interaction.baselineFound, true); + assert.equal(interaction.findVisible, interaction.baselineVisible, JSON.stringify(interaction)); + if (!interaction.findVisible) + report.blockers.push({ + width, + nativeFindReveal: "Unverified: headless window.find selected but did not scroll in either contained or uncontained text", + }); + const anchor = page.locator("[data-retained-text] [data-index]").last(); + await anchor.evaluate((element) => { + element.id = "prototype-last-fragment"; + }); + await page.evaluate(() => { + history.replaceState(null, "", window.location.pathname + window.location.search); + window.location.hash = "prototype-last-fragment"; + }); + await page.waitForFunction(() => { + const last = document.getElementById("prototype-last-fragment").getBoundingClientRect(); + const viewport = document.querySelector("[data-retained-text]").getBoundingClientRect(); + return last.bottom > viewport.top && last.top < viewport.bottom; + }); + await page.emulateMedia({ media: "print" }); + const print = await page.evaluate(() => { + const container = document.querySelector("[data-retained-text]"); + return { + text: container.textContent, + overflow: getComputedStyle(container).overflow, + allFragmentsVisible: [...container.querySelectorAll("[data-index]")].every( + (element) => getComputedStyle(element).contentVisibility === "visible" + ), + unclipped: container.clientHeight >= container.scrollHeight, + }; + }); + assert.equal(print.text, interactionBlocks.join("")); + assert.equal(print.overflow, "visible"); + assert.equal(print.allFragmentsVisible, true); + assert.equal(print.unclipped, true); + await page.emulateMedia({ media: "screen" }); + const readingTree = await page.locator("[data-retained-text]").ariaSnapshot(); + assert.ok(readingTree.includes(NATIVE_FIND_TARGET)); + report.interactions.push({ + width, + mode: retainedMode, + ...interaction, + hashAnchorVisible: true, + accessibilityTreeIncludesOffscreenTarget: true, + printLayout: { exactText: true, unclipped: true }, + }); + } + await page.screenshot({ path: resolve(output, `preview-${width}.png`) }); + if (modeNames.some((name) => typeof RENDER_MODES[name] === "string")) { + const cleanup = await page.evaluate(async () => { + const oldText = document.querySelector("[data-retained-text] [data-index]").firstChild; + await window.codePreviewPrototype.load([], true); + return { + detachedOldText: !oldText.isConnected, + remainingHighlightSets: [...CSS.highlights.keys()].filter((name) => name.startsWith("preview-")).length, + }; + }); + assert.equal(cleanup.detachedOldText, true); + assert.equal(cleanup.remainingHighlightSets, 0); + report.interactions.push({ width, mode: "retained-text", cleanup }); + } + await page.close(); + } + assert.deepEqual(failures, []); + if (budgetProof) { + report.hostBudgetFailures = documentHostBudgetFailures(report); + assert.deepEqual(report.hostBudgetFailures, [], "Approved provisional development-host budget gate failed; P1 remains open"); + report.hostBudgetsPassed = true; + } + report.blockers.push( + "Retained-text with lightweight off-screen text DOM is the approved architecture direction; acceptance is not complete. Whole-Markdown search and regex deadline probes do not establish arbitrary-structure/layout or aggregate memory bounds, reference-device budgets, actual browser Find UI/assistive-technology behavior, production recovery policy, replacement behavior, or an accepted search latency envelope. P1 remains open." + ); + if (process.env.SAMBEE_KEEP_PREVIEW_PROOF === "1") { + report.artifacts = output; + await writeFile(resolve(output, "report.json"), JSON.stringify(report, null, 2)); + } + console.log(report.kind); + console.table( + report.measurements.map((measurement) => ({ + fixture: measurement.fixture, + width: measurement.width, + virtual: measurement.virtual, + cache: measurement.cache, + rows: measurement.mountedRows, + spans: measurement.mountedSpans, + batches: measurement.publicationBatches, + prepareMs: measurement.preparationMs?.toFixed(1) ?? "included in pipeline", + pipelineMs: measurement.workerPipelineWallMs?.toFixed(1) ?? "n/a", + indexMs: measurement.indexingMs?.toFixed(1) ?? "included in pipeline", + loadToPaintMs: measurement.loadToPaintMs.toFixed(1), + longestTaskMs: measurement.longTaskObservationSupported ? Math.max(0, ...measurement.longTasks) : "unavailable", + maxTaskGapMs: measurement.maximumTaskServiceGapMs.toFixed(1), + })) + ); + console.log( + "P1 interaction gate: OPEN. Provisional host targets are approved; physical devices, large-structure failures, total memory, native Find/accessibility and production acceptance remain." + ); + if (report.artifacts) console.log(`Evidence: ${report.artifacts}`); + if (report.scaling) + console.log( + `Scaling: ${report.scaling.length} trials; ${report.scaling.filter((entry) => entry.error).length} explicit failures; provisional host budget gate ${report.hostBudgetsPassed ? "PASSED (not P1 acceptance)" : "not enabled"}` + ); +} catch (error) { + report.failure = { + name: error instanceof Error ? error.name : "UnknownError", + message: error instanceof Error ? error.message : String(error), + }; + if (process.env.SAMBEE_KEEP_PREVIEW_PROOF === "1") { + report.artifacts = output; + await writeFile(resolve(output, "report.json"), JSON.stringify(report, null, 2)); + console.error(`Failed proof evidence: ${output}`); + } + throw error; +} finally { + await browser?.close(); + if (process.env.SAMBEE_KEEP_PREVIEW_PROOF !== "1") await rm(output, { recursive: true, force: true }); +} diff --git a/frontend/scripts/markdown-syntax-publication-proof.mjs b/frontend/scripts/markdown-syntax-publication-proof.mjs new file mode 100644 index 000000000..d00e02998 --- /dev/null +++ b/frontend/scripts/markdown-syntax-publication-proof.mjs @@ -0,0 +1,138 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { extname, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; +import { chromium } from "playwright"; +import { build } from "vite"; + +const FRONTEND = fileURLToPath(new URL("..", import.meta.url)); +const APPROVED_URL = "http://localhost:3000/browse/smb/demo"; +const PREFIX = "/__syntax_publication_proof__/"; +const output = await mkdtemp(resolve(tmpdir(), "sambee-live-syntax-")); +let browser; +try { + await build({ + root: FRONTEND, + configFile: resolve(FRONTEND, "vite.config.ts"), + base: PREFIX, + logLevel: "warn", + build: { + outDir: output, + emptyOutDir: true, + rollupOptions: { + input: { + publication: resolve(FRONTEND, "src/utils/markdownSyntaxPublication.ts"), + preparation: resolve(FRONTEND, "src/utils/markdownSyntaxWorker.ts"), + parser: resolve(FRONTEND, "src/utils/codeMirrorHighlightingCore.ts"), + }, + preserveEntrySignatures: "strict", + output: { entryFileNames: "[name].js" }, + }, + }, + }); + browser = await chromium.launch({ headless: true }); + const results = []; + for (const width of [1543, 390]) { + const page = await browser.newPage({ viewport: { width, height: 900 } }); + const errors = []; + page.on("pageerror", (error) => errors.push(error.message)); + await page.route(`**${PREFIX}**`, async (route) => { + const pathname = new URL(route.request().url()).pathname; + const path = resolve(output, decodeURIComponent(pathname.slice(PREFIX.length))); + if (!path.startsWith(`${output}${sep}`)) return route.abort(); + await route.fulfill({ body: await readFile(path), contentType: extname(path) === ".css" ? "text/css" : "text/javascript" }); + }); + await page.route(APPROVED_URL, (route) => route.fulfill({ + contentType: "text/html", + body: '
', + })); + await page.goto(APPROVED_URL); + const samples = await page.evaluate(async ({ prefix }) => { + const { prepareMarkdownSyntax } = await import(`${prefix}preparation.js`); + const { createStaticHighlighter } = await import(`${prefix}parser.js`); + const { publishMarkdownSyntax, MAX_VIEWPORT_SYNTAX_RANGES } = await import(`${prefix}publication.js`); + const host = document.getElementById("host"); + const frame = () => new Promise((resolve) => requestAnimationFrame(resolve)); + const samples = []; + for (const lines of [6000, 11000]) { + const text = "let x=1;\n".repeat(lines); + const requests = [{ text, label: "js" }]; + const started = performance.now(); + const results = await prepareMarkdownSyntax(requests, new AbortController().signal); + const preparationMs = performance.now() - started; + const direct = await createStaticHighlighter((message) => { throw new Error(message); }).prepare(requests); + if (JSON.stringify(results) !== JSON.stringify(direct)) throw new Error("Production worker/parser parity failed"); + const ranges = results[0].ranges; + const pre = document.createElement("pre"); + const code = document.createElement("code"); + pre.append(code); + host.replaceChildren(pre); + const baselineStart = performance.now(); + const fragment = document.createDocumentFragment(); + let offset = 0; + for (const range of ranges) { + if (range.from > offset) fragment.append(document.createTextNode(text.slice(offset, range.from))); + const span = document.createElement("span"); + span.className = range.categories.map((category) => `sambee-syntax-${category}`).join(" "); + span.textContent = text.slice(range.from, range.to); + fragment.append(span); + offset = range.to; + } + fragment.append(document.createTextNode(text.slice(offset) + "\n")); + code.replaceChildren(fragment); + const baselineDomMs = performance.now() - baselineStart; + const baselineHeight = host.scrollHeight; + const baselineSpans = code.querySelectorAll("span").length; + code.textContent = `${text}\n`; + const node = code.firstChild; + const selection = document.getSelection(); + const selected = document.createRange(); + selected.setStart(node, 0); + selected.setEnd(node, 3); + selection.removeAllRanges(); + selection.addRange(selected); + const controller = new AbortController(); + const publicationStart = performance.now(); + publishMarkdownSyntax(host, host, { requests, results }, controller.signal); + await frame(); + await frame(); + const publicationWallMs = performance.now() - publicationStart; + const decorations = () => [...CSS.highlights.entries()].filter(([name]) => name.startsWith("markdown-syntax-")); + const mountedRanges = decorations().reduce((sum, [, ranges]) => sum + ranges.size, 0); + const keywordName = decorations().find(([name]) => name.endsWith("-keyword"))[0]; + const lightColor = getComputedStyle(code, `::highlight(${keywordName})`).color; + host.style.setProperty("--sambee-syntax-keyword", "#ff8800"); + const darkColor = getComputedStyle(code, `::highlight(${keywordName})`).color; + if (lightColor === darkColor) throw new Error("Syntax colors did not follow theme variables"); + if (code.textContent !== `${text}\n` || code.firstChild !== node || selection.toString() !== "let") throw new Error("Syntax publication changed text or selection"); + if (host.scrollHeight !== baselineHeight) throw new Error("Syntax publication changed document geometry"); + if (mountedRanges <= 0 || mountedRanges > MAX_VIEWPORT_SYNTAX_RANGES || code.children.length) throw new Error("Invalid viewport syntax work bound"); + selection.removeAllRanges(); + host.scrollTop = host.scrollHeight; + host.dispatchEvent(new Event("scroll")); + await frame(); + await frame(); + const finalRanges = decorations().flatMap(([, ranges]) => [...ranges]); + if (!finalRanges.length || !finalRanges.some((range) => range.startOffset > text.length / 2)) throw new Error("End-of-document syntax was not published"); + controller.abort(); + if (decorations().length) throw new Error("Cancelled syntax ranges survived disposal"); + samples.push({ sourceUnits: text.length, preparationMs, baselineDomMs, baselineSpans, publicationWallMs, mountedRanges, lightColor, darkColor }); + host.scrollTop = 0; + host.style.setProperty("--sambee-syntax-keyword", "#c00000"); + } + const parityRequests = [{text: "name: value\n", label: "yml"}, {text: '# Heading\n\n```py\nprint("🌍")\n```', label: "md"}]; + const parity = await prepareMarkdownSyntax(parityRequests, new AbortController().signal); + const direct = await createStaticHighlighter((message) => { throw new Error(message); }).prepare(parityRequests); + if (JSON.stringify(parity) !== JSON.stringify(direct) || parity.some((result) => !("ranges" in result))) throw new Error("Production alias/nested parser parity failed"); + return samples; + }, { prefix: PREFIX }); + assert.deepEqual(errors, []); + results.push({ width, samples }); + await page.close(); + } + console.log(JSON.stringify({ kind: "Production live syntax publication slice; not whole-viewer or physical-device acceptance", browser: browser.version(), results }, null, 2)); +} finally { + await browser?.close(); + await rm(output, { recursive: true, force: true }); +} diff --git a/frontend/scripts/prototypes/markdownCodePreparation.ts b/frontend/scripts/prototypes/markdownCodePreparation.ts new file mode 100644 index 000000000..8315b7bc4 --- /dev/null +++ b/frontend/scripts/prototypes/markdownCodePreparation.ts @@ -0,0 +1,316 @@ +import type { CodePreviewFragment, CodePreviewModel } from "../../src/utils/markdownCodePreviewModel"; +import { + DOCUMENT_PUBLICATION_BATCH_ITEMS, + DOCUMENT_RECORD_BATCH_ITEMS, + DOCUMENT_TEXT_CHUNK_UNITS, + type DocumentSyntaxWindow, + type MarkdownDocumentRecord, +} from "../../src/utils/markdownDocumentPreviewModel"; +import type { SyntaxRange } from "../../src/utils/syntaxHighlighting"; + +export const PREPARATION_CHUNK_ITEMS = 512; +export const PREPARATION_SOURCE_CHUNK_UNITS = 16_384; +const WORKER_PROGRESS_TIMEOUT_MS = 30_000; + +export interface PreviewMatch { + from: number; + to: number; +} + +export type PreparationRequest = + | { type: "syntax-window"; generation: number; requestId: number; window: DocumentSyntaxWindow; offset: number } + | { type: "prepare"; generation: number; blockCount: number; document?: boolean } + | { type: "source"; generation: number; sequence: number; block: number; text: string; final: boolean } + | { type: "run"; generation: number } + | { type: "ack"; generation: number }; + +export type PreparationResponse = + | { + type: "syntax-page"; + generation: number; + requestId: number; + records: Extract[]; + next: number | null; + } + | { type: "document-chunk"; generation: number; records: MarkdownDocumentRecord[] } + | { type: "source-ack"; generation: number; sequence: number } + | { + type: "diagnostic"; + generation: number; + message: string; + context?: Record; + component?: string; + error?: { message: string; name: string }; + } + | { type: "chunk"; generation: number; block: number; ranges: SyntaxRange[]; fragments: CodePreviewFragment[]; matches: PreviewMatch[] } + | { type: "complete"; generation: number; preparationMs: number; indexingMs: number; syntaxRanges?: number } + | { type: "error"; generation: number; message: string }; + +interface WorkerPreparation { + syntaxRanges: number; + sourceChunks: number; + largestSourceChunkUnits: number; + sourceUnitsSent: number; + models: CodePreviewModel[]; + matches: PreviewMatch[]; + preparationMs: number; + indexingMs: number; + clientAssemblyMs: number; + chunks: number; + largestChunkItems: number; +} + +let worker: Worker | null = null; + +export function createDocumentSyntaxSession(generation: number) { + const current = worker; + if (!current) throw new Error("Document syntax worker is unavailable"); + let nextId = 0; + let pending: { + id: number; + resolve: (page: Extract) => void; + reject: (error: Error) => void; + } | null = null; + let disposed = false; + let failure: Error | null = null; + let timer: ReturnType; + const fail = (error: Error) => { + failure = error; + clearTimeout(timer); + const request = pending; + pending = null; + request?.reject(error); + current.terminate(); + if (worker === current) worker = null; + }; + const message = (event: MessageEvent) => { + if (event.data.generation !== generation || disposed) return; + if (event.data.type === "error") { + fail(new Error(event.data.message)); + return; + } + if (event.data.type !== "syntax-page" || event.data.requestId !== pending?.id) return; + if (event.data.records.length > DOCUMENT_RECORD_BATCH_ITEMS) { + fail(new Error("Document syntax page exceeded its record bound")); + return; + } + clearTimeout(timer); + const request = pending; + pending = null; + request.resolve(event.data); + }; + const error = (event: ErrorEvent) => { + event.preventDefault(); + fail(new Error(`Document syntax worker failed: ${event.message}`)); + }; + const decode = () => fail(new Error("Document syntax page could not be decoded")); + current.addEventListener("message", message); + current.addEventListener("error", error); + current.addEventListener("messageerror", decode); + return { + dispose() { + if (disposed) return; + disposed = true; + clearTimeout(timer); + const request = pending; + pending = null; + request?.reject(new DOMException("Document syntax session disposed", "AbortError")); + current.removeEventListener("message", message); + current.removeEventListener("error", error); + current.removeEventListener("messageerror", decode); + }, + page(window: DocumentSyntaxWindow, offset: number) { + return new Promise>((resolve, reject) => { + if (disposed || failure || pending) { + reject(failure ?? new Error(disposed ? "Document syntax session disposed" : "Document syntax request overlaps")); + return; + } + pending = { id: ++nextId, resolve, reject }; + timer = setTimeout( + () => fail(new Error("Document syntax request exceeded its prototype progress timeout")), + WORKER_PROGRESS_TIMEOUT_MS + ); + try { + current.postMessage({ type: "syntax-window", generation, requestId: nextId, window, offset } satisfies PreparationRequest); + } catch (error) { + fail(error instanceof Error ? error : new Error(String(error))); + } + }); + }, + }; +} + +export function shutdownCodePreviewWorker(): void { + worker?.terminate(); + worker = null; +} + +/** P1 execution probe; successful jobs reuse grammars, aborted jobs destroy their worker. */ +export function prepareCodePreviewInWorker( + blocks: string[], + generation: number, + signal: AbortSignal, + onDiagnostic: (diagnostic: Extract) => void, + onDocumentChunk?: (records: readonly MarkdownDocumentRecord[]) => Promise +): Promise { + if (signal.aborted) return Promise.reject(new DOMException("Preparation cancelled", "AbortError")); + if (typeof Worker === "undefined") + return Promise.reject(new Error("Worker preparation is unavailable; no main-thread fallback was used")); + return new Promise((resolve, reject) => { + let current: Worker; + try { + worker ??= new Worker(new URL("./markdownCodePreparation.worker.ts", import.meta.url), { type: "module" }); + current = worker; + } catch (error) { + reject(error); + return; + } + const ranges = blocks.map(() => [] as SyntaxRange[]); + const fragments = blocks.map(() => [] as CodePreviewFragment[]); + const matches: PreviewMatch[] = []; + const offsets: number[] = []; + let offset = 0; + for (const block of blocks) { + offsets.push(offset); + offset += block.length; + } + let chunks = 0; + let sourceBlock = 0; + let sourceOffset = 0; + let sourceSequence = 0; + let sourceComplete = false; + let sourceChunks = 0; + let largestSourceChunkUnits = 0; + let sourceUnitsSent = 0; + let largestChunkItems = 0; + let clientAssemblyMs = 0; + let settled = false; + let watchdog: ReturnType; + const cleanup = () => { + clearTimeout(watchdog); + signal.removeEventListener("abort", abort); + current.onmessage = null; + current.onerror = null; + current.removeEventListener("messageerror", decodeFailure); + }; + const fail = (error: unknown) => { + if (settled) return; + settled = true; + cleanup(); + current.terminate(); + if (worker === current) worker = null; + reject(error); + }; + const abort = () => fail(new DOMException("Worker preparation cancelled", "AbortError")); + const armWatchdog = () => { + clearTimeout(watchdog); + watchdog = setTimeout( + () => fail(new Error("Worker preparation made no progress before its prototype timeout")), + WORKER_PROGRESS_TIMEOUT_MS + ); + }; + signal.addEventListener("abort", abort, { once: true }); + current.onerror = (event) => { + event.preventDefault(); + fail(new Error(`Worker preparation failed: ${event.message} at ${event.filename}:${event.lineno}:${event.colno}`)); + }; + const decodeFailure = () => fail(new Error("Worker preparation result could not be decoded")); + current.addEventListener("messageerror", decodeFailure); + current.onmessage = async (event: MessageEvent) => { + if (settled || event.data.generation !== generation) return; + const response = event.data; + try { + armWatchdog(); + if (response.type === "document-chunk") { + if ( + !onDocumentChunk || + response.records.length > DOCUMENT_PUBLICATION_BATCH_ITEMS || + response.records.some( + (record) => (record.kind === "text" || record.kind === "attribute") && record.value.length > DOCUMENT_TEXT_CHUNK_UNITS + ) + ) + throw new Error("Invalid Markdown document batch"); + await onDocumentChunk(response.records); + if (settled) return; + current.postMessage({ type: "ack", generation } satisfies PreparationRequest); + return; + } + if (response.type === "source-ack") { + if (sourceComplete || response.sequence !== sourceSequence) throw new Error("Invalid source acknowledgement"); + if (sourceBlock === blocks.length) { + sourceComplete = true; + current.postMessage({ type: "run", generation } satisfies PreparationRequest); + return; + } + const block = sourceBlock; + const text = blocks[block].slice(sourceOffset, sourceOffset + PREPARATION_SOURCE_CHUNK_UNITS); + sourceOffset += text.length; + const final = sourceOffset === blocks[block].length; + if (final) { + sourceBlock += 1; + sourceOffset = 0; + } + sourceChunks += 1; + largestSourceChunkUnits = Math.max(largestSourceChunkUnits, text.length); + sourceUnitsSent += text.length; + sourceSequence += 1; + current.postMessage({ type: "source", generation, sequence: sourceSequence, block, text, final } satisfies PreparationRequest); + return; + } + if (response.type === "diagnostic") { + onDiagnostic(response); + return; + } + if (response.type === "error") { + fail(new Error(response.message)); + return; + } + if (response.type === "chunk") { + const start = performance.now(); + if (!Number.isInteger(response.block) || response.block < 0 || response.block >= blocks.length) + throw new Error("Invalid preparation block"); + const count = response.ranges.length + response.fragments.length + response.matches.length; + if (count > PREPARATION_CHUNK_ITEMS) throw new Error("Preparation result exceeded its chunk bound"); + for (const range of response.ranges) ranges[response.block].push(range); + for (const fragment of response.fragments) fragments[response.block].push(fragment); + for (const match of response.matches) + matches.push({ from: offsets[response.block] + match.from, to: offsets[response.block] + match.to }); + chunks += 1; + largestChunkItems = Math.max(largestChunkItems, count); + clientAssemblyMs += performance.now() - start; + current.postMessage({ type: "ack", generation } satisfies PreparationRequest); + return; + } + if (response.type !== "complete") throw new Error("Unexpected preparation response"); + settled = true; + cleanup(); + resolve({ + syntaxRanges: response.syntaxRanges ?? 0, + sourceChunks, + largestSourceChunkUnits, + sourceUnitsSent, + models: blocks.map((text, index) => ({ text, ranges: ranges[index], fragments: fragments[index] })), + matches, + preparationMs: response.preparationMs, + indexingMs: response.indexingMs, + clientAssemblyMs, + chunks, + largestChunkItems, + }); + } catch (error) { + fail(error); + } + }; + armWatchdog(); + try { + current.postMessage({ + type: "prepare", + generation, + blockCount: blocks.length, + document: Boolean(onDocumentChunk), + } satisfies PreparationRequest); + } catch (error) { + fail(error); + } + }); +} diff --git a/frontend/scripts/prototypes/markdownCodePreparation.worker.ts b/frontend/scripts/prototypes/markdownCodePreparation.worker.ts new file mode 100644 index 000000000..de158a1ea --- /dev/null +++ b/frontend/scripts/prototypes/markdownCodePreparation.worker.ts @@ -0,0 +1,202 @@ +import { createStaticHighlighter } from "../../src/utils/codeMirrorHighlightingCore"; +import { createCodePreviewModel } from "../../src/utils/markdownCodePreviewModel"; +import { + createMarkdownDocumentModel, + DOCUMENT_PUBLICATION_BATCH_ITEMS, + DOCUMENT_PUBLICATION_BATCH_UNITS, + documentSyntaxPage, + type MarkdownDocumentRecord, + markdownDocumentRecords, +} from "../../src/utils/markdownDocumentPreviewModel"; +import type { SyntaxRange } from "../../src/utils/syntaxHighlighting"; +import { + PREPARATION_CHUNK_ITEMS, + PREPARATION_SOURCE_CHUNK_UNITS, + type PreparationRequest, + type PreparationResponse, + type PreviewMatch, +} from "./markdownCodePreparation"; + +const MATCH_QUERY = "let x"; +let acknowledge: (() => void) | null = null; +let activeGeneration: number | null = null; +let sourceBlocks: string[] = []; +let sourceParts: string[] = []; +let expectedBlocks = 0; +let sourceSequence = 0; +let running = false; +let documentMode = false; +let documentGeneration: number | null = null; +const documentSyntax = new Map(); +const highlightingService = createStaticHighlighter(error); + +function send(response: PreparationResponse): void { + postMessage(response); +} + +export function error(message: string, context?: Record, component?: string, failure?: Error): void { + send({ + type: "diagnostic", + generation: activeGeneration ?? -1, + message, + context, + component, + error: failure ? { message: failure.message, name: failure.name } : undefined, + }); +} + +async function prepareDocument(source: string, generation: number): Promise { + const start = performance.now(); + const model = await createMarkdownDocumentModel(source, { normalizeSource: true }); + documentGeneration = generation; + let batch: MarkdownDocumentRecord[] = []; + let batchUnits = 0; + const flush = async () => { + if (!batch.length) return; + const records = batch; + batch = []; + batchUnits = 0; + await new Promise((resolve) => { + acknowledge = resolve; + send({ type: "document-chunk", generation, records }); + }); + }; + const emit = async (record: MarkdownDocumentRecord) => { + const units = record.kind === "text" || record.kind === "attribute" ? record.value.length : 0; + if (batchUnits + units > DOCUMENT_PUBLICATION_BATCH_UNITS) await flush(); + batch.push(record); + batchUnits += units; + if (batch.length >= DOCUMENT_PUBLICATION_BATCH_ITEMS || batchUnits >= DOCUMENT_PUBLICATION_BATCH_UNITS) await flush(); + }; + for (const record of markdownDocumentRecords(model)) await emit(record); + await flush(); + for (const block of model.codeBlocks) { + const [result] = await highlightingService.prepare([block]); + if (!result) throw new Error("Markdown code preparation returned no result"); + if ("ranges" in result) documentSyntax.set(block.id, result.ranges); + else await emit({ kind: "fallback", id: block.id, reason: result.reason }); + } + await flush(); + send({ + type: "complete", + generation, + preparationMs: performance.now() - start, + indexingMs: 0, + syntaxRanges: [...documentSyntax.values()].reduce((count, ranges) => count + ranges.length, 0), + }); +} + +async function prepare(blocks: string[], generation: number): Promise { + let preparationMs = 0; + let indexingMs = 0; + for (let block = 0; block < blocks.length; block += 1) { + const text = blocks[block]; + const start = performance.now(); + const [result] = await highlightingService.prepare([{ text, label: "javascript" }]); + preparationMs += performance.now() - start; + if (!("ranges" in result)) throw new Error(`Prototype block ${block} preparation failed: ${result.reason}`); + const indexStart = performance.now(); + const model = createCodePreviewModel(text, result.ranges); + const matches: PreviewMatch[] = []; + for (let from = text.indexOf(MATCH_QUERY); from !== -1; from = text.indexOf(MATCH_QUERY, from + MATCH_QUERY.length)) { + matches.push({ from, to: from + MATCH_QUERY.length }); + } + indexingMs += performance.now() - indexStart; + let rangeOffset = 0; + let fragmentOffset = 0; + let matchOffset = 0; + while (rangeOffset < model.ranges.length || fragmentOffset < model.fragments.length || matchOffset < matches.length) { + const ranges = model.ranges.slice(rangeOffset, rangeOffset + PREPARATION_CHUNK_ITEMS); + const fragments = model.fragments.slice(fragmentOffset, fragmentOffset + PREPARATION_CHUNK_ITEMS - ranges.length); + const matchChunk = matches.slice(matchOffset, matchOffset + PREPARATION_CHUNK_ITEMS - ranges.length - fragments.length); + rangeOffset += ranges.length; + fragmentOffset += fragments.length; + matchOffset += matchChunk.length; + await new Promise((resolve) => { + acknowledge = resolve; + send({ type: "chunk", generation, block, ranges, fragments, matches: matchChunk }); + }); + } + } + send({ type: "complete", generation, preparationMs, indexingMs }); +} + +globalThis.onmessage = (event: MessageEvent) => { + const request = event.data; + if (request.type === "syntax-window") { + if (request.generation !== documentGeneration) return; + try { + send({ + type: "syntax-page", + generation: request.generation, + requestId: request.requestId, + ...documentSyntaxPage(documentSyntax.get(request.window.id) ?? [], request.window, request.offset), + }); + } catch (failure) { + send({ type: "error", generation: request.generation, message: failure instanceof Error ? failure.message : String(failure) }); + } + return; + } + if (request.type === "prepare") { + if (activeGeneration !== null || !Number.isSafeInteger(request.blockCount) || request.blockCount < 0) { + send({ type: "error", generation: request.generation, message: "Preparation worker received an invalid or overlapping job" }); + return; + } + activeGeneration = request.generation; + documentSyntax.clear(); + documentGeneration = null; + documentMode = request.document === true; + if (documentMode && request.blockCount !== 1) { + send({ type: "error", generation: request.generation, message: "Markdown document preparation requires one source document" }); + return; + } + expectedBlocks = request.blockCount; + sourceBlocks = []; + sourceParts = []; + sourceSequence = 0; + running = false; + send({ type: "source-ack", generation: request.generation, sequence: sourceSequence }); + return; + } + if (request.generation !== activeGeneration) return; + if (request.type === "ack") { + const resume = acknowledge; + acknowledge = null; + resume?.(); + return; + } + if (request.type === "source") { + if ( + running || request.sequence !== sourceSequence + 1 || request.block !== sourceBlocks.length || + request.block >= expectedBlocks || typeof request.text !== "string" || request.text.length > PREPARATION_SOURCE_CHUNK_UNITS || + typeof request.final !== "boolean" || (!request.final && request.text.length === 0) + ) { + send({ type: "error", generation: request.generation, message: "Preparation worker received an invalid source chunk" }); + return; + } + sourceParts.push(request.text); + if (request.final) { + sourceBlocks.push(sourceParts.join("")); + sourceParts = []; + } + sourceSequence = request.sequence; + send({ type: "source-ack", generation: request.generation, sequence: sourceSequence }); + return; + } + if (running || sourceBlocks.length !== expectedBlocks || sourceParts.length > 0) { + send({ type: "error", generation: request.generation, message: "Preparation worker cannot run before source transfer completes" }); + return; + } + running = true; + void (documentMode ? prepareDocument(sourceBlocks[0]!, request.generation) : prepare(sourceBlocks, request.generation)) + .catch((failure: unknown) => { + send({ type: "error", generation: request.generation, message: failure instanceof Error ? failure.message : "Worker preparation failed" }); + }) + .finally(() => { + acknowledge = null; + activeGeneration = null; + sourceBlocks = []; + sourceParts = []; + running = false; + }); +}; diff --git a/frontend/scripts/prototypes/markdownCodePreview.tsx b/frontend/scripts/prototypes/markdownCodePreview.tsx new file mode 100644 index 000000000..da0c3587e --- /dev/null +++ b/frontend/scripts/prototypes/markdownCodePreview.tsx @@ -0,0 +1,777 @@ +import { useVirtualizer } from "@tanstack/react-virtual"; +import { useLayoutEffect, useRef, useState } from "react"; +import { createRoot } from "react-dom/client"; +import { error as logError } from "../../src/services/logger"; +import { syntaxTokenStyles } from "../../src/theme/syntaxStyles"; +import { getDefaultTheme } from "../../src/theme/themes"; +import { yieldHighlightingTask } from "../../src/utils/codeMirrorHighlighting"; +import { highlightingService } from "../../src/utils/highlightingService"; +import { + CODE_PREVIEW_SCAN_MAX_UNITS, + type CodePreviewModel, + createCodePreviewModel, + createCodePreviewModelAsync, + findCodePreviewFragment, +} from "../../src/utils/markdownCodePreviewModel"; +import type { DocumentSearchOptions } from "../../src/utils/markdownDocumentSearch"; +import { TOKEN_CATEGORIES, tokenClasses } from "../../src/utils/syntaxHighlighting"; +import { + createDocumentSyntaxSession, + PREPARATION_CHUNK_ITEMS, + PREPARATION_SOURCE_CHUNK_UNITS, + prepareCodePreviewInWorker, + shutdownCodePreviewWorker, +} from "./markdownCodePreparation"; +import { mountMarkdownDocument, renderMarkdownDocumentBaseline } from "./markdownDocumentPreview"; +import type { DocumentSearchResult } from "./markdownDocumentSearch"; + +const ROW_HEIGHT = 20; +const VIEWPORT_HEIGHT = 480; +const OVERSCAN = 3; +const PUBLICATION_BATCH_ROWS = 64; +const PRIORITY_NEIGHBOR_ROWS = 3; +const SOURCE_INDEX_BLOCK_BATCH = 16; + +interface Match { + from: number; + to: number; +} + +interface Fixture { + sourceIndexingYields: number; + sourceIndexingMs: number; + preparationComplete: boolean; + preparationError: string | null; + refresh?: () => void; + sourceChunks: number; + largestSourceChunkUnits: number; + sourceUnitsSent: number; + execution: "main-thread" | "worker"; + transferChunks: number; + largestChunkItems: number; + clientAssemblyMs: number; + preparationWallMs: number; + generation: number; + loadStartedAt: number; + models: CodePreviewModel[]; + rows: { block: number; fragment: number }[]; + offsets: number[]; + blockBounds: Match[]; + rowStarts: number[]; + matches: Match[]; + preparationMs: number; + indexingMs: number; + text: string; +} + +interface PrototypeControl { + searchDocument: (query: string, options?: DocumentSearchOptions) => Promise; + jumpDocument: (ordinal: number) => Promise<{ pending: boolean }>; + shutdownDocument: () => void; + loadDocument: (source: string) => Promise; + baselineDocument: (source: string) => Promise; + inspect: () => { models: readonly CodePreviewModel[]; matches: readonly Match[] }; + load: ( + blocks: string[], + virtual: boolean | "retained-text" | "retained-text-worker" | "retained-text-worker-progressive" + ) => Promise; + jump: (ordinal: number) => { pending: boolean }; + stats: () => Record; +} + +declare global { + interface Window { + codePreviewPrototype: PrototypeControl; + } +} + +function RetainedTextPreview({ fixture, onCommit }: { fixture: Fixture; onCommit: () => void }) { + const scrollRef = useRef(null); + const nodes = useRef(new Map()); + const active = useRef(-1); + const painted = useRef({ rows: 0, ranges: 0 }); + + useLayoutEffect(() => { + if (typeof Highlight === "undefined" || !CSS.highlights) throw new Error("Retained-text prototype requires the CSS Highlight API"); + const container = scrollRef.current; + if (!container) return; + let frame: number | null = null; + let publicationTimer: ReturnType | null = null; + let publishedRows = 0; + let publicationBatches = 0; + let largestPublicationBatch = 0; + let firstContentMs: number | null = null; + let publicationMs: number | null = null; + let pendingRow: number | null = null; + let activeRow: number | null = null; + let nextSequentialRow = 0; + let priorityRows: number[] = []; + let priorityPublishedRows = 0; + const publishedIndices: number[] = []; + const blocks = new Map(); + const publicationStart = performance.now(); + container.style.overflowAnchor = "none"; + const visible = new Set(); + const paint = () => { + frame = null; + if (fixture.generation !== generation) return; + const categories = new Map(); + const search: Range[] = []; + const selected: Range[] = []; + let rangeCount = 0; + for (const index of visible) { + const element = nodes.current.get(index); + const textNode = element?.firstChild; + if (!(textNode instanceof Text)) continue; + const row = fixture.rows[index]; + const model = fixture.models[row.block]; + const fragment = model.fragments[row.fragment]; + const range = (from: number, to: number) => { + const result = new Range(); + result.setStart(textNode, Math.max(fragment.from, from) - fragment.from); + result.setEnd(textNode, Math.min(fragment.to, to) - fragment.from); + rangeCount += 1; + return result; + }; + for (let tokenIndex = fragment.firstRange; tokenIndex < fragment.rangeEnd; tokenIndex += 1) { + const token = model.ranges[tokenIndex]; + for (const category of token.categories) { + const ranges = categories.get(category) ?? []; + ranges.push(range(token.from, token.to)); + categories.set(category, ranges); + } + } + const blockOffset = fixture.offsets[row.block]; + for ( + let matchIndex = firstOverlap(fixture.matches, blockOffset + fragment.from); + matchIndex < fixture.matches.length && fixture.matches[matchIndex].from < blockOffset + fragment.to; + matchIndex += 1 + ) { + const match = fixture.matches[matchIndex]; + search.push(range(match.from - blockOffset, match.to - blockOffset)); + if (matchIndex === active.current) selected.push(range(match.from - blockOffset, match.to - blockOffset)); + } + } + for (const category of TOKEN_CATEGORIES) + CSS.highlights.set(`preview-${category}`, new Highlight(...(categories.get(category) ?? []))); + const searchHighlight = new Highlight(...search); + searchHighlight.priority = 1; + CSS.highlights.set("preview-search", searchHighlight); + const activeHighlight = new Highlight(...selected); + activeHighlight.priority = 2; + CSS.highlights.set("preview-active", activeHighlight); + painted.current = { rows: visible.size, ranges: rangeCount }; + }; + const schedule = () => { + if (frame === null) frame = requestAnimationFrame(paint); + }; + fixture.refresh = schedule; + const revealPendingJump = () => { + if (pendingRow === null) return; + const element = nodes.current.get(pendingRow); + if (!element?.isConnected) return; + element.scrollIntoView({ block: "center" }); + pendingRow = null; + schedule(); + }; + const observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + const index = Number((entry.target as HTMLElement).dataset.index); + if (entry.isIntersecting) visible.add(index); + else visible.delete(index); + } + schedule(); + }, + { root: container, rootMargin: `${VIEWPORT_HEIGHT}px 0px` } + ); + window.codePreviewPrototype.jump = (ordinal) => { + if (!fixture.preparationComplete) throw new Error("Prototype search preparation is pending"); + const match = fixture.matches[ordinal]; + if (!match) throw new RangeError("Prototype match ordinal is out of bounds"); + const block = firstOverlap(fixture.blockBounds, match.from); + const fragment = findCodePreviewFragment(fixture.models[block], match.from - fixture.offsets[block]); + if (fragment === null) throw new Error("Prototype match has no fragment"); + active.current = ordinal; + pendingRow = fixture.rowStarts[block] + fragment; + activeRow = pendingRow; + priorityRows = []; + for (let distance = 0; distance <= PRIORITY_NEIGHBOR_ROWS; distance += 1) { + const candidates = distance === 0 ? [pendingRow] : [pendingRow - distance, pendingRow + distance]; + for (const index of candidates) { + if (index >= 0 && index < fixture.rows.length && !nodes.current.has(index)) priorityRows.push(index); + } + } + revealPendingJump(); + schedule(); + return { pending: pendingRow !== null }; + }; + window.codePreviewPrototype.stats = () => ({ + preparationMs: fixture.preparationMs, + sourceIndexingMs: fixture.sourceIndexingMs, + sourceIndexingYields: fixture.sourceIndexingYields, + preparationComplete: fixture.preparationComplete, + preparationError: fixture.preparationError, + searchComplete: fixture.preparationComplete, + execution: fixture.execution, + transferChunks: fixture.transferChunks, + largestChunkItems: fixture.largestChunkItems, + transferChunkLimit: PREPARATION_CHUNK_ITEMS, + sourceChunks: fixture.sourceChunks, + largestSourceChunkUnits: fixture.largestSourceChunkUnits, + sourceUnitsSent: fixture.sourceUnitsSent, + sourceChunkLimit: PREPARATION_SOURCE_CHUNK_UNITS, + clientAssemblyMs: fixture.clientAssemblyMs, + preparationWallMs: fixture.preparationWallMs, + indexingMs: fixture.indexingMs, + sourceUnits: fixture.text.length, + generation: fixture.generation, + totalRows: fixture.rows.length, + mountedRows: nodes.current.size, + publishedRows, + publicationBatches, + largestPublicationBatch, + publicationBatchLimit: PUBLICATION_BATCH_ROWS, + publicationComplete: publishedRows === fixture.rows.length, + priorityPublishedRows, + activeRow, + firstContentMs, + publicationMs, + mountedSpans: nodes.current.size, + mountedTokenSpans: 0, + decoratedRows: painted.current.rows, + highlightRanges: painted.current.ranges, + matchCount: fixture.matches.length, + active: active.current, + pendingMatch: pendingRow === null ? null : active.current, + activeMounted: pendingRow === null && (CSS.highlights.get("preview-active")?.size ?? 0) > 0, + scrollTop: container.scrollTop, + oversizedLines: fixture.models.flatMap((model) => model.fragments).filter((fragment) => fragment.oversizedLine).length, + mountedTextUnits: container.textContent?.length ?? 0, + }); + const publish = () => { + publicationTimer = null; + if (fixture.generation !== generation) return; + const anchor = activeRow === null ? null : nodes.current.get(activeRow); + const anchorRect = anchor?.getBoundingClientRect(); + const viewport = anchorRect ? container.getBoundingClientRect() : null; + const anchorTop = + anchorRect && viewport && anchorRect.bottom > viewport.top && anchorRect.top < viewport.bottom ? anchorRect.top : null; + let batchRows = 0; + while (batchRows < PUBLICATION_BATCH_ROWS && publishedRows < fixture.rows.length) { + let index = priorityRows.shift(); + const prioritized = index !== undefined; + if (index === undefined) { + while (nodes.current.has(nextSequentialRow)) nextSequentialRow += 1; + index = nextSequentialRow++; + } + if (nodes.current.has(index)) continue; + const row = fixture.rows[index]; + const model = fixture.models[row.block]; + const fragment = model.fragments[row.fragment]; + const text = model.text.slice(fragment.from, fragment.to); + const lines = Math.max(1, text.split("\n").length - Number(text.endsWith("\n"))); + const element = document.createElement("span"); + element.dataset.index = String(index); + element.textContent = text; + Object.assign(element.style, { + margin: "0", + whiteSpace: "pre-wrap", + overflowWrap: "anywhere", + fontSize: "14px", + lineHeight: `${ROW_HEIGHT}px`, + display: "inline-block", + verticalAlign: "top", + width: "100%", + contain: "layout style", + minHeight: `${lines * ROW_HEIGHT}px`, + }); + let block = blocks.get(row.block); + if (!block) { + block = document.createElement("pre"); + block.dataset.block = String(row.block); + Object.assign(block.style, { margin: "0", whiteSpace: "normal", fontSize: "14px", lineHeight: `${ROW_HEIGHT}px` }); + let successorBlock: HTMLElement | null = null; + for (const child of container.children) { + if (Number((child as HTMLElement).dataset.block) > row.block) { + successorBlock = child as HTMLElement; + break; + } + } + container.insertBefore(block, successorBlock); + blocks.set(row.block, block); + } + let low = 0; + let high = publishedIndices.length; + while (low < high) { + const middle = low + Math.floor((high - low) / 2); + if (publishedIndices[middle] < index) low = middle + 1; + else high = middle; + } + const successor = publishedIndices[low]; + const successorNode = successor === undefined ? null : nodes.current.get(successor); + block.insertBefore(element, successorNode?.parentNode === block ? successorNode : null); + publishedIndices.splice(low, 0, index); + nodes.current.set(index, element); + observer.observe(element); + publishedRows += 1; + batchRows += 1; + if (prioritized) priorityPublishedRows += 1; + } + if (anchorTop !== null && anchor) container.scrollTop += anchor.getBoundingClientRect().top - anchorTop; + revealPendingJump(); + largestPublicationBatch = Math.max(largestPublicationBatch, batchRows); + publicationBatches += 1; + firstContentMs ??= performance.now() - fixture.loadStartedAt; + if (publishedRows < fixture.rows.length) publicationTimer = setTimeout(publish, 0); + else { + container.style.overflowAnchor = "auto"; + publicationMs = performance.now() - publicationStart; + onCommit(); + } + }; + publish(); + return () => { + observer.disconnect(); + delete fixture.refresh; + if (frame !== null) cancelAnimationFrame(frame); + if (publicationTimer !== null) clearTimeout(publicationTimer); + nodes.current.clear(); + blocks.clear(); + container.replaceChildren(); + for (const category of TOKEN_CATEGORIES) CSS.highlights.delete(`preview-${category}`); + CSS.highlights.delete("preview-search"); + CSS.highlights.delete("preview-active"); + }; + }, [fixture, onCommit]); + + return
; +} + +function firstOverlap(matches: readonly Match[], offset: number): number { + let low = 0; + let high = matches.length; + while (low < high) { + const middle = low + Math.floor((high - low) / 2); + if (matches[middle].to <= offset) low = middle + 1; + else high = middle; + } + return low; +} + +function fragmentContent(model: CodePreviewModel, fragmentIndex: number, matches: Match[], active: number, blockOffset: number) { + const fragment = model.fragments[fragmentIndex]; + const tokens = model.ranges.slice(fragment.firstRange, fragment.rangeEnd); + const initialMatch = firstOverlap(matches, blockOffset + fragment.from); + const boundaries = new Set([fragment.from, fragment.to]); + for (const token of tokens) { + boundaries.add(Math.max(fragment.from, token.from)); + boundaries.add(Math.min(fragment.to, token.to)); + } + for (let index = initialMatch; index < matches.length && matches[index].from < blockOffset + fragment.to; index += 1) { + boundaries.add(Math.max(fragment.from, matches[index].from - blockOffset)); + boundaries.add(Math.min(fragment.to, matches[index].to - blockOffset)); + } + const ordered = [...boundaries].sort((left, right) => left - right); + let tokenIndex = 0; + let matchIndex = initialMatch; + return ordered.slice(0, -1).map((from, index) => { + const to = ordered[index + 1]; + while (tokenIndex < tokens.length && tokens[tokenIndex].to <= from) tokenIndex += 1; + while (matchIndex < matches.length && matches[matchIndex].to <= blockOffset + from) matchIndex += 1; + const token = tokens[tokenIndex]; + const matched = matches[matchIndex]?.from <= blockOffset + from; + const text = model.text.slice(from, to); + const content = matched ? {text} : text; + return ( + + {content} + + ); + }); +} + +function Preview({ fixture, virtual }: { fixture: Fixture; virtual: boolean }) { + const scrollRef = useRef(null); + const [active, setActive] = useState(-1); + const virtualizer = useVirtualizer({ + count: fixture.rows.length, + getScrollElement: () => scrollRef.current, + estimateSize: (index) => { + const row = fixture.rows[index]; + const model = fixture.models[row.block]; + const fragment = model.fragments[row.fragment]; + const text = model.text.slice(fragment.from, fragment.to); + return Math.max(1, text.split("\n").length - Number(text.endsWith("\n"))) * ROW_HEIGHT; + }, + overscan: OVERSCAN, + }); + + useLayoutEffect(() => { + window.codePreviewPrototype.jump = (ordinal) => { + const match = fixture.matches[ordinal]; + if (!match) throw new RangeError("Prototype match ordinal is out of bounds"); + const block = firstOverlap(fixture.blockBounds, match.from); + const fragment = findCodePreviewFragment(fixture.models[block], match.from - fixture.offsets[block]); + if (fragment === null) throw new Error("Prototype match has no fragment"); + const rowIndex = fixture.rowStarts[block] + fragment; + setActive(ordinal); + if (virtual) virtualizer.scrollToIndex(rowIndex, { align: "center" }); + else scrollRef.current?.querySelector(`[data-index="${rowIndex}"]`)?.scrollIntoView({ block: "center" }); + return { pending: false }; + }; + window.codePreviewPrototype.stats = () => ({ + preparationMs: fixture.preparationMs, + indexingMs: fixture.indexingMs, + sourceUnits: fixture.text.length, + totalRows: fixture.rows.length, + mountedRows: scrollRef.current?.querySelectorAll("[data-index]").length ?? 0, + mountedSpans: scrollRef.current?.querySelectorAll("span").length ?? 0, + matchCount: fixture.matches.length, + active, + activeMounted: Boolean(scrollRef.current?.querySelector('[data-active="true"]')), + scrollTop: scrollRef.current?.scrollTop, + oversizedLines: fixture.models.flatMap((model) => model.fragments).filter((fragment) => fragment.oversizedLine).length, + mountedTextUnits: scrollRef.current?.textContent?.length ?? 0, + }); + }); + + const rows = virtual ? virtualizer.getVirtualItems() : fixture.rows.map((_, index) => ({ index, start: 0, key: index })); + return ( +
+
+ {rows.map((item) => { + const row = fixture.rows[item.index]; + return ( +
+              {fragmentContent(fixture.models[row.block], row.fragment, fixture.matches, active, fixture.offsets[row.block])}
+            
+ ); + })} +
+
+ ); +} + +const rootElement = document.getElementById("prototype"); +if (!rootElement) throw new Error("Prototype root is missing"); +const root = createRoot(rootElement); +const palette = getDefaultTheme("light").components?.syntax; +if (!palette) throw new Error("Built-in light theme is missing its syntax palette"); +for (const [role, color] of Object.entries(palette)) rootElement.style.setProperty(`--sambee-syntax-${role}`, color); +const style = document.createElement("style"); +style.textContent = `${Object.entries(syntaxTokenStyles) + .map( + ([selector, properties]) => + `${selector}{${Object.entries(properties) + .map(([name, value]) => `${name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}:${value}`) + .join(";")}}` + ) + .join("\n")} +${Object.entries(syntaxTokenStyles) + .map( + ([selector, properties]) => `::highlight(preview-${selector.slice(".sambee-syntax-".length)}){color:${properties.color ?? "inherit"}}` + ) + .join("\n")} +mark,::highlight(preview-search){background:#ffe082}mark[data-active="true"],::highlight(preview-active){background:#ff8a65}body{margin:16px} +@media print{[data-retained-text]{height:auto!important;overflow:visible!important}[data-retained-text] pre{content-visibility:visible!important}}`; +style.textContent += ` +.document-proof{font-size:14px;line-height:20px;overflow-wrap:anywhere} +.document-proof pre{white-space:pre-wrap;overflow-wrap:anywhere;font-size:14px;line-height:20px;contain:layout style} +.document-proof code:has(>.document-code-fragment){white-space:normal} +.document-code-fragment{display:inline-block;vertical-align:top;width:100%;white-space:pre-wrap;overflow-wrap:anywhere;contain:layout style} +.document-proof table{border-collapse:collapse;width:100%;table-layout:fixed}.document-proof th,.document-proof td{border:1px solid #999;padding:4px;vertical-align:top} +::highlight(document-search){background:#ffcc66;color:#171717} +@media print{[data-markdown-document]{height:auto!important;overflow:visible!important}} +${Object.entries(syntaxTokenStyles) + .map( + ([selector, properties]) => `::highlight(document-${selector.slice(".sambee-syntax-".length)}){color:${properties.color ?? "inherit"}}` + ) + .join("\n")}`; +document.head.append(style); +let generation = 0; +let pendingLoad: { controller: AbortController; cancelPublication?: () => void } | null = null; +let disposeDocument: (() => void) | null = null; + +function createFixture( + models: CodePreviewModel[], + matches: Match[] | null, + metadata: Omit +): Fixture { + const offsets: number[] = []; + const rowStarts: number[] = []; + const blockBounds: Match[] = []; + let offset = 0; + let rowStart = 0; + for (const model of models) { + offsets.push(offset); + rowStarts.push(rowStart); + blockBounds.push({ from: offset, to: offset + model.text.length }); + offset += model.text.length; + rowStart += model.fragments.length; + } + const text = models.map((model) => model.text).join(""); + if (matches === null) { + matches = []; + for (let from = text.indexOf("let x"); from !== -1; from = text.indexOf("let x", from + "let x".length)) + matches.push({ from, to: from + "let x".length }); + } + return { + ...metadata, + models, + matches, + offsets, + rowStarts, + blockBounds, + text, + rows: models.flatMap((model, block) => model.fragments.map((_, fragment) => ({ block, fragment }))), + }; +} + +window.codePreviewPrototype = { + async searchDocument() { + throw new Error("Load a Markdown document before searching"); + }, + async jumpDocument() { + throw new Error("Load and search a Markdown document before navigating"); + }, + async loadDocument(source) { + pendingLoad?.controller.abort(); + pendingLoad?.cancelPublication?.(); + disposeDocument?.(); + const request = { controller: new AbortController() }; + pendingLoad = request; + const current = ++generation; + window.codePreviewPrototype.stats = () => ({ complete: false, error: null, batches: 0, generation: current }); + try { + if ( + typeof Highlight === "undefined" || + !CSS.highlights || + typeof IntersectionObserver === "undefined" || + typeof ResizeObserver === "undefined" + ) { + throw new Error("Whole-Markdown preview requires CSS Highlights, IntersectionObserver and ResizeObserver; no fallback was used"); + } + const view = await mountMarkdownDocument(root, request.controller.signal, source); + request.controller.signal.throwIfAborted(); + disposeDocument = view.dispose; + window.codePreviewPrototype.stats = () => ({ ...view.stats(), generation: current }); + window.codePreviewPrototype.inspect = () => ({ models: [], matches: [] }); + window.codePreviewPrototype.searchDocument = view.search; + window.codePreviewPrototype.jumpDocument = view.jump; + window.codePreviewPrototype.jump = () => { + throw new Error("Whole-Markdown navigation is asynchronous; use jumpDocument"); + }; + const result = await prepareCodePreviewInWorker( + [source], + current, + request.controller.signal, + (diagnostic) => logError(diagnostic.message, diagnostic.context, diagnostic.component), + view.consume + ); + request.controller.signal.throwIfAborted(); + view.finish(result.preparationMs, result.syntaxRanges, createDocumentSyntaxSession(current)); + } catch (error) { + if (current === generation) { + const stats = window.codePreviewPrototype.stats(); + window.codePreviewPrototype.stats = () => ({ + ...stats, + complete: false, + error: error instanceof Error ? error.message : String(error), + }); + } + throw error; + } finally { + if (pendingLoad === request) pendingLoad = null; + } + }, + async baselineDocument(source) { + pendingLoad?.controller.abort(); + pendingLoad?.cancelPublication?.(); + disposeDocument?.(); + disposeDocument = null; + pendingLoad = null; + generation += 1; + await renderMarkdownDocumentBaseline(root, source); + }, + shutdownDocument() { + pendingLoad?.controller.abort(); + pendingLoad?.cancelPublication?.(); + pendingLoad = null; + generation += 1; + disposeDocument?.(); + disposeDocument = null; + shutdownCodePreviewWorker(); + window.codePreviewPrototype.stats = () => ({ complete: false, error: null, disposed: true, generation }); + }, + inspect() { + throw new Error("Load a fixture before inspecting prepared data"); + }, + async load(blocks, virtual) { + disposeDocument?.(); + disposeDocument = null; + pendingLoad?.controller.abort(); + pendingLoad?.cancelPublication?.(); + const request: NonNullable = { controller: new AbortController() }; + pendingLoad = request; + const current = ++generation; + const prepareStart = performance.now(); + const progressive = virtual === "retained-text-worker-progressive"; + let earlyFixture: Fixture | null = null; + let publication: Promise | null = null; + const render = (fixture: Fixture) => + new Promise((resolve, reject) => { + request.cancelPublication = () => reject(new DOMException("Prototype publication superseded", "AbortError")); + root.render(); + }); + try { + if (progressive) { + const sourceIndexStart = performance.now(); + let sourceIndexingYields = 0; + const yieldSourceIndexing = async () => { + sourceIndexingYields += 1; + await yieldHighlightingTask(request.controller.signal); + }; + const plainModels: CodePreviewModel[] = []; + let batchUnits = 0; + let batchBlocks = 0; + for (const text of blocks) { + plainModels.push( + await createCodePreviewModelAsync(text, [], { + signal: request.controller.signal, + yieldTask: yieldSourceIndexing, + }) + ); + batchUnits += text.length; + batchBlocks += 1; + if ( + plainModels.length < blocks.length && + (batchUnits >= CODE_PREVIEW_SCAN_MAX_UNITS || batchBlocks >= SOURCE_INDEX_BLOCK_BATCH) + ) { + await yieldSourceIndexing(); + batchUnits = 0; + batchBlocks = 0; + } + } + request.controller.signal.throwIfAborted(); + earlyFixture = createFixture(plainModels, [], { + sourceIndexingYields, + sourceIndexingMs: 0, + preparationComplete: false, + preparationError: null, + sourceChunks: 0, + largestSourceChunkUnits: 0, + sourceUnitsSent: 0, + execution: "worker", + transferChunks: 0, + largestChunkItems: 0, + clientAssemblyMs: 0, + preparationWallMs: 0, + generation: current, + loadStartedAt: prepareStart, + preparationMs: 0, + indexingMs: 0, + }); + earlyFixture.sourceIndexingMs = performance.now() - sourceIndexStart; + publication = render(earlyFixture); + void publication.catch(() => undefined); + } + const workerPrepared = + virtual === "retained-text-worker" || progressive + ? await prepareCodePreviewInWorker(blocks, current, request.controller.signal, (diagnostic) => { + const failure = diagnostic.error ? new Error(diagnostic.error.message) : undefined; + if (failure && diagnostic.error) failure.name = diagnostic.error.name; + logError(diagnostic.message, diagnostic.context, diagnostic.component, failure); + }) + : null; + const prepared = workerPrepared + ? null + : await highlightingService.prepare( + blocks.map((text) => ({ text, label: "javascript" })), + request.controller.signal + ); + if (current !== generation) throw new DOMException("Prototype load superseded", "AbortError"); + const preparationWallMs = performance.now() - prepareStart; + const preparationMs = workerPrepared?.preparationMs ?? preparationWallMs; + const indexStart = performance.now(); + const models = + workerPrepared?.models ?? + blocks.map((text, index) => { + const result = prepared![index]; + if (!("ranges" in result)) throw new Error(`Prototype preparation failed: ${result.reason}`); + return createCodePreviewModel(text, result.ranges); + }); + const fixture = createFixture(models, workerPrepared?.matches ?? null, { + sourceIndexingYields: earlyFixture?.sourceIndexingYields ?? 0, + sourceIndexingMs: earlyFixture?.sourceIndexingMs ?? 0, + preparationComplete: true, + preparationError: null, + sourceChunks: workerPrepared?.sourceChunks ?? 0, + largestSourceChunkUnits: workerPrepared?.largestSourceChunkUnits ?? 0, + sourceUnitsSent: workerPrepared?.sourceUnitsSent ?? 0, + execution: workerPrepared ? "worker" : "main-thread", + transferChunks: workerPrepared?.chunks ?? 0, + largestChunkItems: workerPrepared?.largestChunkItems ?? 0, + clientAssemblyMs: workerPrepared?.clientAssemblyMs ?? 0, + preparationWallMs, + generation: current, + loadStartedAt: prepareStart, + preparationMs, + indexingMs: workerPrepared?.indexingMs ?? 0, + }); + fixture.indexingMs += performance.now() - indexStart; + if (earlyFixture) { + Object.assign(earlyFixture, fixture); + earlyFixture.refresh?.(); + await publication; + } else await render(fixture); + } catch (error) { + if (earlyFixture && current === generation) { + earlyFixture.preparationError = error instanceof Error ? error.message : "Prototype preparation failed"; + earlyFixture.refresh?.(); + await publication; + } + throw error; + } finally { + if (pendingLoad === request) pendingLoad = null; + } + }, + jump() { + throw new Error("Load a fixture before navigating"); + }, + stats() { + throw new Error("Load a fixture before reading metrics"); + }, +}; + +function Commit({ + fixture, + virtual, + onCommit, +}: { + fixture: Fixture; + virtual: boolean | "retained-text" | "retained-text-worker" | "retained-text-worker-progressive"; + onCommit: () => void; +}) { + useLayoutEffect(() => { + window.codePreviewPrototype.inspect = () => ({ models: fixture.models, matches: fixture.matches }); + if (typeof virtual !== "string") onCommit(); + }, [fixture, virtual, onCommit]); + return typeof virtual === "string" ? ( + + ) : ( + + ); +} diff --git a/frontend/scripts/prototypes/markdownDocumentPreview.tsx b/frontend/scripts/prototypes/markdownDocumentPreview.tsx new file mode 100644 index 000000000..9ca3065ed --- /dev/null +++ b/frontend/scripts/prototypes/markdownDocumentPreview.tsx @@ -0,0 +1,511 @@ +export * from "../../src/utils/markdownDocumentPreview";import { useLayoutEffect, useRef } from "react"; +import type { Root } from "react-dom/client"; +import ReactMarkdown, { defaultUrlTransform } from "react-markdown"; +import remarkGfm from "remark-gfm"; +import { normalizeMarkdownTableCellLineBreaks, remarkRenderMarkdownTableCellLineBreaks } from "../../src/components/Viewer/markdownTableCellLineBreaks"; +import { yieldHighlightingTask } from "../../src/utils/codeMirrorHighlighting"; +import { + DOCUMENT_PUBLICATION_BATCH_ITEMS, + DOCUMENT_RECORD_BATCH_ITEMS, + DOCUMENT_SYNTAX_CACHE_RANGES, + DOCUMENT_SYNTAX_WINDOW_PARTS, + type DocumentSyntaxWindow, + type MarkdownDocumentRecord, +} from "../../src/utils/markdownDocumentPreviewModel"; +import { DocumentSearchIndexer, type DocumentSearchOptions } from "../../src/utils/markdownDocumentSearch"; +import { type SyntaxRange, TOKEN_CATEGORIES } from "../../src/utils/syntaxHighlighting"; +import type { createDocumentSyntaxSession } from "./markdownCodePreparation"; +import { createDocumentSearchSession, type DocumentMatch, type DocumentSearchResult } from "./markdownDocumentSearch"; + +const MAX_DECORATION_RANGES = 4_000; +const PUBLICATION_BATCHES_PER_FRAME = 2; +const ALLOWED_TAGS = new Set( + "a blockquote br code del div em h1 h2 h3 h4 h5 h6 hr img input li ol p pre section span strong sup table tbody td th thead tr ul".split( + " " + ) +); + +interface DocumentView { + search: (query: string, options?: DocumentSearchOptions) => Promise; + jump: (ordinal: number) => Promise<{ pending: boolean }>; + consume: (records: readonly MarkdownDocumentRecord[]) => Promise; + finish: (workerPipelineWallMs: number, syntaxRanges: number, session: ReturnType) => void; + dispose: () => void; + stats: () => Record; +} + +async function yieldDocumentPublication(signal: AbortSignal): Promise { + signal.throwIfAborted(); + if (document.hidden) return yieldHighlightingTask(signal); + await new Promise((resolve, reject) => { + const cleanup = () => { + cancelAnimationFrame(frame); + signal.removeEventListener("abort", abort); + document.removeEventListener("visibilitychange", visibility); + }; + const complete = () => { + cleanup(); + resolve(); + }; + const abort = () => { + cleanup(); + reject(signal.reason); + }; + const visibility = () => { + if (document.hidden) complete(); + }; + const frame = requestAnimationFrame(complete); + signal.addEventListener("abort", abort, { once: true }); + document.addEventListener("visibilitychange", visibility); + }); +} + +function DocumentHost({ ready }: { ready: (element: HTMLDivElement) => (() => void) | undefined }) { + const ref = useRef(null); + useLayoutEffect(() => (ref.current ? ready(ref.current) : undefined), [ready]); + return
; +} + +export function mountMarkdownDocument(root: Root | HTMLDivElement, signal: AbortSignal, source: string, options: { hostSearch?: boolean; hostSyntax?: boolean } = {}): Promise { + signal.throwIfAborted(); + const searchSession = createDocumentSearchSession(source); + return new Promise((resolve, reject) => { + const abort = () => { + searchSession.dispose(); + reject(signal.reason); + }; + signal.addEventListener("abort", abort, { once: true }); + const ready = (container: HTMLDivElement) => { + signal.removeEventListener("abort", abort); + if (signal.aborted) { + reject(signal.reason); + return; + } + const started = performance.now(); + const elements = new Map([[0, container]]); + const fragmentCodes = new Map(); + const parts = new Map(); + const tokens = new Map(); + const attributes = new Map(); + const searchIndexer = new DocumentSearchIndexer(); + const searchNodes: { node: Text; from: number; to: number }[] = []; + let searchController: AbortController | null = null; + let searchQuery = ""; + let searchOptions: DocumentSearchOptions = {}; + let searchResult: DocumentSearchResult | null = null; + let searchRevision = 0; + let activeMatch: (DocumentMatch & { ordinal: number; pending: boolean }) | null = null; + const reveal = () => { + if (!activeMatch?.pending || disposed) return; + const locate = (offset: number) => { + let low = 0; + let high = searchNodes.length; + while (low < high) { + const middle = low + Math.floor((high - low) / 2); + if (searchNodes[middle].to < offset) low = middle + 1; + else high = middle; + } + return searchNodes[low]; + }; + const start = locate(activeMatch.from); + const end = locate(activeMatch.to); + if (!start || !end) return; + const range = new Range(); + range.setStart(start.node, Math.max(0, Math.min(start.node.length, activeMatch.from - start.from))); + range.setEnd(end.node, Math.max(0, Math.min(end.node.length, activeMatch.to - end.from))); + CSS.highlights.set("document-search", new Highlight(range)); + activeMatch.pending = false; + const rect = range.getBoundingClientRect(); + const viewport = container.getBoundingClientRect(); + container.scrollTop += rect.top - viewport.top - container.clientHeight / 2; + }; + const visible = new Set(); + let frame: number | null = null; + let disposed = false; + let batches = 0; + let largestBatch = 0; + let firstContentMs: number | null = null; + let complete = false; + let workerPipelineWallMs = 0; + let highlightRanges = 0; + let decorationLimited = false; + let mountedSpans = 0; + let textUnits = 0; + let syntaxSession: ReturnType | null = null; + let workerSyntaxRanges = 0; + let cachedSyntaxRanges = 0; + let peakCachedSyntaxRanges = 0; + let syntaxPages = 0; + let largestSyntaxPage = 0; + let syntaxError: string | null = null; + let syntaxFetching = false; + let syntaxWindowLimited = false; + let geometryProbes = 0; + let desiredWindows: DocumentSyntaxWindow[] = []; + let desiredKey = ""; + let appliedKey = ""; + let syntaxRevision = 0; + let decorationKey = ""; + const fetchWindow = () => { + if (!syntaxSession || syntaxFetching || disposed || desiredKey === appliedKey || syntaxError) return; + syntaxFetching = true; + void (async () => { + while (!disposed && desiredKey !== appliedKey) { + const key = desiredKey; + const windows = desiredWindows; + tokens.clear(); + syntaxRevision += 1; + cachedSyntaxRanges = 0; + syntaxWindowLimited = false; + for (const window of windows) { + let offset: number | null = 0; + do { + const page = await syntaxSession!.page(window, offset); + if (disposed || key !== desiredKey) break; + syntaxPages += 1; + largestSyntaxPage = Math.max(largestSyntaxPage, page.records.length); + const collection = tokens.get(window.id) ?? []; + for (const record of page.records) { + const last = collection.at(-1); + if (last?.from === record.range.from && last.to === record.range.to) continue; + if (cachedSyntaxRanges === DOCUMENT_SYNTAX_CACHE_RANGES) { + syntaxWindowLimited = true; + break; + } + collection.push(record.range); + cachedSyntaxRanges += 1; + } + tokens.set(window.id, collection); + syntaxRevision += 1; + peakCachedSyntaxRanges = Math.max(peakCachedSyntaxRanges, cachedSyntaxRanges); + offset = page.next; + schedule(); + if (cachedSyntaxRanges === DOCUMENT_SYNTAX_CACHE_RANGES) { + syntaxWindowLimited = true; + break; + } + } while (offset !== null); + if (disposed || key !== desiredKey || syntaxWindowLimited) break; + } + if (key === desiredKey) appliedKey = key; + } + })() + .catch((error: unknown) => { + if (!disposed) { + syntaxError = error instanceof Error ? error.message : String(error); + tokens.clear(); + syntaxRevision += 1; + cachedSyntaxRanges = 0; + schedule(); + } + }) + .finally(() => { + syntaxFetching = false; + }); + }; + const fallbacks: { id: number; reason: string }[] = []; + const paint = () => { + frame = null; + if (disposed || signal.aborted) return; + if (visible.size === 0 && highlightRanges === 0 && desiredWindows.length === 0) return; + const categories = new Map(); + const viewport = container.getBoundingClientRect(); + const viewportParts = new Map(); + const windows: DocumentSyntaxWindow[] = []; + geometryProbes = 0; + let inspectedFragments = 0; + for (const id of visible) { + if (inspectedFragments++ === DOCUMENT_SYNTAX_WINDOW_PARTS || windows.length === DOCUMENT_SYNTAX_WINDOW_PARTS) break; + const collection = parts.get(id) ?? []; + const rectFor = (index: number) => { + geometryProbes += 1; + const range = new Range(); + range.selectNodeContents(collection[index].node); + return range.getBoundingClientRect(); + }; + let low = 0; + let high = collection.length; + while (low < high) { + const middle = low + Math.floor((high - low) / 2); + if (rectFor(middle).bottom < viewport.top - container.clientHeight) low = middle + 1; + else high = middle; + } + const selected = []; + for (let index = low; index < collection.length && windows.length < DOCUMENT_SYNTAX_WINDOW_PARTS; index += 1) { + if (rectFor(index).top > viewport.bottom + container.clientHeight) break; + const part = collection[index]; + selected.push(part); + windows.push({ id: fragmentCodes.get(id)!, from: part.from, to: part.to }); + } + viewportParts.set(id, selected); + } + windows.sort((left, right) => left.id - right.id || left.from - right.from); + const key = JSON.stringify(windows); + if (key !== desiredKey) { + desiredWindows = windows; + desiredKey = key; + tokens.clear(); + syntaxRevision += 1; + cachedSyntaxRanges = 0; + } + fetchWindow(); + const nextDecorationKey = `${key}:${syntaxRevision}`; + if (nextDecorationKey === decorationKey) return; + decorationKey = nextDecorationKey; + highlightRanges = 0; + decorationLimited = false; + for (const [id, selected] of viewportParts) { + const ranges = tokens.get(fragmentCodes.get(id)!) ?? []; + for (const part of selected) { + let low = 0; + let high = ranges.length; + while (low < high) { + const middle = low + Math.floor((high - low) / 2); + if (ranges[middle].to <= part.from) low = middle + 1; + else high = middle; + } + for (let index = low; index < ranges.length && ranges[index].from < part.to; index += 1) { + const token = ranges[index]; + for (const category of token.categories) { + if (highlightRanges === MAX_DECORATION_RANGES) { + decorationLimited = true; + break; + } + const range = new Range(); + range.setStart(part.node, Math.max(part.from, token.from) - part.from); + range.setEnd(part.node, Math.min(part.to, token.to) - part.from); + const collection = categories.get(category) ?? []; + collection.push(range); + categories.set(category, collection); + highlightRanges += 1; + } + if (decorationLimited) break; + } + if (decorationLimited) break; + } + if (decorationLimited) break; + } + for (const category of TOKEN_CATEGORIES) + CSS.highlights.set(`document-${category}`, new Highlight(...(categories.get(category) ?? []))); + }; + const schedule = () => { + if (!disposed && frame === null) frame = requestAnimationFrame(paint); + }; + const observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + const id = Number((entry.target as HTMLElement).dataset.documentFragment); + if (entry.isIntersecting) visible.add(id); + else visible.delete(id); + } + schedule(); + }, + { root: container, rootMargin: "480px 0px" } + ); + const resizeObserver = new ResizeObserver(schedule); + resizeObserver.observe(container); + const dispose = () => { + if (disposed) return; + disposed = true; + signal.removeEventListener("abort", dispose); + searchRevision += 1; + searchController?.abort(); + searchSession.dispose(); + syntaxSession?.dispose(); + syntaxSession = null; + searchNodes.length = 0; + searchResult = null; + activeMatch = null; + CSS.highlights.delete("document-search"); + observer.disconnect(); + resizeObserver.disconnect(); + container.removeEventListener("scroll", schedule); + container.removeEventListener("load", schedule, true); + document.fonts.removeEventListener("loadingdone", schedule); + if (frame !== null) cancelAnimationFrame(frame); + for (const category of TOKEN_CATEGORIES) CSS.highlights.delete(`document-${category}`); + elements.clear(); + parts.clear(); + tokens.clear(); + attributes.clear(); + visible.clear(); + fragmentCodes.clear(); + fallbacks.length = 0; + desiredWindows = []; + container.replaceChildren(); + }; + container.addEventListener("scroll", schedule, { passive: true }); + container.addEventListener("load", schedule, true); + document.fonts.addEventListener("loadingdone", schedule); + signal.addEventListener("abort", dispose, { once: true }); + resolve({ + async search(query, options = {}) { + if (disposed) throw new DOMException("Document preview disposed", "AbortError"); + searchController?.abort(); + searchController = new AbortController(); + const revision = ++searchRevision; + searchQuery = query; + searchOptions = { ...options }; + searchResult = null; + activeMatch = null; + CSS.highlights.delete("document-search"); + const result = await searchSession.search(query, searchOptions, searchController.signal); + if (revision !== searchRevision || disposed) throw new DOMException("Document search superseded", "AbortError"); + searchResult = result; + if (result.matches[0]) { + activeMatch = { ...result.matches[0], ordinal: 0, pending: true }; + reveal(); + } + return result; + }, + async jump(ordinal) { + if (disposed) throw new DOMException("Document preview disposed", "AbortError"); + if (!searchResult) throw new Error("Document search results are pending or unavailable"); + if (!Number.isSafeInteger(ordinal) || ordinal < 0 || ordinal >= searchResult.count) + throw new Error("Document match ordinal is out of range"); + searchController?.abort(); + searchController = new AbortController(); + const revision = ++searchRevision; + let result = searchResult; + if (ordinal < result.offset || ordinal >= result.offset + result.matches.length) + result = await searchSession.search(searchQuery, searchOptions, searchController.signal, ordinal); + if (revision !== searchRevision || disposed) throw new DOMException("Document navigation superseded", "AbortError"); + searchResult = result; + const match = result.matches[ordinal - result.offset]; + if (!match) throw new Error("Document search returned no requested match"); + activeMatch = { ...match, ordinal, pending: true }; + reveal(); + return { pending: activeMatch.pending }; + }, + async consume(records) { + signal.throwIfAborted(); + if (disposed) throw new DOMException("Document preview disposed", "AbortError"); + const additions = new Map(); + const append = (parent: HTMLElement, node: Node) => { + let fragment = additions.get(parent); + if (!fragment) { + fragment = document.createDocumentFragment(); + additions.set(parent, fragment); + } + fragment.append(node); + }; + for (const record of records) { + const searchSegment = options.hostSearch ? null : searchIndexer.append(record); + if (record.kind === "element") { + if (!ALLOWED_TAGS.has(record.tag) || !elements.has(record.parent) || elements.has(record.id)) + throw new Error("Invalid Markdown element record"); + const element = document.createElement(record.tag); + if (record.tag === "span") mountedSpans += 1; + if (record.fragment) { + element.dataset.documentFragment = String(record.id); + element.className = "document-code-fragment"; + fragmentCodes.set(record.id, record.parent); + observer.observe(element); + } + append(elements.get(record.parent)!, element); + elements.set(record.id, element); + } else if (record.kind === "text") { + const parent = elements.get(record.parent); + if (!parent) throw new Error("Markdown text has no parent"); + const node = document.createTextNode(record.value); + textUnits += record.value.length; + append(parent, node); + if (searchSegment) searchNodes.push({ node, from: searchSegment.from, to: searchSegment.to }); + if (fragmentCodes.has(record.parent)) { + const collection = parts.get(record.parent) ?? []; + collection.push({ node, from: record.from, to: record.from + record.value.length }); + parts.set(record.parent, collection); + } + } else if (record.kind === "attribute") { + if (/^on/i.test(record.name) || record.name === "style") throw new Error("Unsafe Markdown attribute"); + const key = `${record.id}:${record.name}`; + const value = (attributes.get(key) ?? "") + record.value; + if (!record.final) attributes.set(key, value); + else { + attributes.delete(key); + const element = elements.get(record.id); + if (!element) throw new Error("Markdown attribute has no element"); + const name = + record.name === "className" + ? "class" + : record.name.startsWith("aria") + ? `aria-${record.name.slice(4).toLowerCase()}` + : record.name.startsWith("data") + ? record.name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`) + : record.name.toLowerCase(); + element.setAttribute(name, name === "href" || name === "src" ? defaultUrlTransform(value) : value); + if (name === "align" && ["left", "center", "right"].includes(value)) element.style.textAlign = value; + } + } else if (record.kind === "syntax") { + throw new Error("Full-document syntax records are not accepted by the viewport renderer"); + } else fallbacks.push(record); + } + for (const [parent, fragment] of [...additions].reverse()) parent.append(fragment); + batches += 1; + largestBatch = Math.max(largestBatch, records.length); + if (textUnits > 0) firstContentMs ??= performance.now() - started; + schedule(); + reveal(); + if (batches % PUBLICATION_BATCHES_PER_FRAME === 0) await yieldDocumentPublication(signal); + }, + finish(wallMs, syntaxRanges, session) { + if (!options.hostSearch) searchIndexer.finish(); + syntaxSession = session; + workerSyntaxRanges = syntaxRanges; + workerPipelineWallMs = wallMs; + complete = true; + schedule(); + }, + dispose, + stats: () => ({ + complete, + error: null, + batches, + largestBatch, + batchLimit: DOCUMENT_PUBLICATION_BATCH_ITEMS, + syntaxPageLimit: DOCUMENT_RECORD_BATCH_ITEMS, + firstContentMs, + workerPipelineWallMs, + highlightRanges, + decorationLimited, + cachedSyntaxRanges, + peakCachedSyntaxRanges, + syntaxCacheLimit: DOCUMENT_SYNTAX_CACHE_RANGES, + syntaxPages, + largestSyntaxPage, + syntaxFetching, + syntaxWindowLimited, + syntaxError, + workerSyntaxRanges, + geometryProbes, + fallbacks, + mountedElements: elements.size - 1, + mountedSpans, + textUnits, + search: searchSession.stats(), + activeMatch: activeMatch ? { ...activeMatch } : null, + }), + }); + return dispose; + }; + if (root instanceof HTMLDivElement) ready(root); + else root.render(); + }); +} + +export function renderMarkdownDocumentBaseline(root: Root, source: string): Promise { + return new Promise((resolve) => + root.render( +
{ + if (element) resolve(); + }} + > + {normalizeMarkdownTableCellLineBreaks(source)} +
+ ) + ); +} diff --git a/frontend/scripts/prototypes/markdownDocumentSearch.ts b/frontend/scripts/prototypes/markdownDocumentSearch.ts new file mode 100644 index 000000000..941453498 --- /dev/null +++ b/frontend/scripts/prototypes/markdownDocumentSearch.ts @@ -0,0 +1,179 @@ +export * from "../../src/utils/markdownDocumentSearchWorker";import type { DocumentSearchOptions } from "../../src/utils/markdownDocumentSearch"; +import { PREPARATION_SOURCE_CHUNK_UNITS } from "../../src/utils/markdownCodePreparationProtocol"; + +export const DOCUMENT_SEARCH_WINDOW = 64; +export const DOCUMENT_SEARCH_PROGRESS_MATCHES = 512; +export const DOCUMENT_SEARCH_TIMEOUT_MS = 2_000; + +export interface DocumentMatch { + from: number; + to: number; +} +export interface DocumentSearchResult { + count: number; + offset: number; + matches: DocumentMatch[]; +} +export type DocumentSearchRequest = + | { type: "init" } + | { type: "source"; sequence: number; text: string; final: boolean } + | { type: "search"; id: number; query: string; options: DocumentSearchOptions; ordinal: number } + | { type: "ack"; id: number }; +export type DocumentSearchResponse = + | { type: "source-ack"; sequence: number } + | { type: "ready"; textUnits: number; segments: number; parseMs: number; indexMs: number } + | { type: "progress"; id: number; count: number } + | ({ type: "result"; id: number } & DocumentSearchResult) + | { type: "error"; message: string }; + +export function createDocumentSearchSession(source: string) { + let sourceText: string | null = source; + let worker: Worker | null = null; + let ready = false; + let nextId = 0; + let cancel: (() => void) | null = null; + let status: Record = { complete: false, lowerBound: 0, count: null, error: null, cachedMatches: 0 }; + const dispose = () => { + cancel?.(); + worker?.terminate(); + worker = null; + ready = false; + sourceText = null; + status = { complete: false, disposed: true, cachedMatches: 0 }; + }; + return { + dispose, + stats: () => status, + search(query: string, options: DocumentSearchOptions, signal: AbortSignal, ordinal = 0): Promise { + cancel?.(); + if (signal.aborted) return Promise.reject(signal.reason); + if (sourceText === null) return Promise.reject(new Error("Document search session disposed")); + if (!Number.isSafeInteger(ordinal) || ordinal < 0) return Promise.reject(new Error("Invalid document match ordinal")); + const id = ++nextId; + status = { complete: false, lowerBound: 0, count: null, error: null, cachedMatches: 0 }; + return new Promise((resolve, reject) => { + let current: Worker; + try { + if (typeof Worker === "undefined") throw new Error("Document search requires a worker; no main-thread fallback was used"); + worker ??= new Worker(new URL("./markdownDocumentSearch.worker.ts", import.meta.url), { type: "module" }); + current = worker; + } catch (error) { + status = { ...status, error: error instanceof Error ? error.message : String(error) }; + reject(error); + return; + } + let settled = false; + let timer: ReturnType; + let sequence = 0; + let sourceOffset = 0; + let sourceComplete = false; + const cleanup = () => { + clearTimeout(timer); + signal.removeEventListener("abort", abort); + current.onmessage = null; + current.onerror = null; + current.removeEventListener("messageerror", decodeFailure); + if (cancel === abort) cancel = null; + }; + const fail = (error: unknown) => { + if (settled) return; + settled = true; + cleanup(); + current.terminate(); + if (worker === current) { + worker = null; + ready = false; + } + status = { + ...status, + complete: false, + count: null, + cachedMatches: 0, + error: error instanceof Error ? error.message : String(error), + }; + reject(error); + }; + const abort = () => fail(new DOMException("Document search superseded", "AbortError")); + const decodeFailure = () => fail(new Error("Document search result could not be decoded")); + const arm = () => { + clearTimeout(timer); + timer = setTimeout( + () => fail(new Error("Document search exceeded its prototype progress deadline; worker terminated")), + DOCUMENT_SEARCH_TIMEOUT_MS + ); + }; + const send = (request: DocumentSearchRequest) => { + current.postMessage(request); + }; + const run = () => send({ type: "search", id, query, options, ordinal }); + cancel = abort; + signal.addEventListener("abort", abort, { once: true }); + current.addEventListener("messageerror", decodeFailure); + current.onerror = (event) => { + event.preventDefault(); + fail(new Error(`Document search worker failed: ${event.message}`)); + }; + current.onmessage = (event: MessageEvent) => { + if (settled) return; + try { + const response = event.data; + if (response.type === "error") { + fail(new Error(response.message)); + return; + } + arm(); + if (response.type === "source-ack") { + if (ready || sourceComplete || response.sequence !== sequence) + throw new Error("Invalid document search source acknowledgement"); + const text = sourceText!.slice(sourceOffset, sourceOffset + PREPARATION_SOURCE_CHUNK_UNITS); + sourceOffset += text.length; + const final = sourceOffset === sourceText!.length; + sourceComplete = final; + send({ type: "source", sequence: ++sequence, text, final }); + return; + } + if (response.type === "ready") { + ready = true; + status = { ...status, textUnits: response.textUnits, segments: response.segments, parseMs: response.parseMs, indexMs: response.indexMs }; + run(); + return; + } + if (response.id !== id) return; + if (response.type === "progress") { + status = { ...status, lowerBound: response.count }; + send({ type: "ack", id }); + return; + } + if ( + response.type !== "result" || + !Number.isSafeInteger(response.count) || + response.count < 0 || + response.matches.length > DOCUMENT_SEARCH_WINDOW + ) + throw new Error("Invalid document search result"); + status = { + ...status, + complete: true, + count: response.count, + lowerBound: response.count, + error: null, + cachedMatches: response.matches.length, + }; + settled = true; + cleanup(); + resolve(response); + } catch (error) { + fail(error); + } + }; + arm(); + try { + if (ready) run(); + else send({ type: "init" }); + } catch (error) { + fail(error); + } + }); + }, + }; +} diff --git a/frontend/scripts/prototypes/markdownDocumentSearch.worker.ts b/frontend/scripts/prototypes/markdownDocumentSearch.worker.ts new file mode 100644 index 000000000..710f642e6 --- /dev/null +++ b/frontend/scripts/prototypes/markdownDocumentSearch.worker.ts @@ -0,0 +1,95 @@ +import "../../src/utils/markdownDocumentSearch.worker";import { Text } from "@codemirror/state"; +import { createMarkdownDocumentModel, markdownDocumentRecords } from "../../src/utils/markdownDocumentPreviewModel"; +import { createDocumentSearchIndex, documentSearchMatches } from "../../src/utils/markdownDocumentSearch"; +import { PREPARATION_SOURCE_CHUNK_UNITS } from "../../src/utils/markdownCodePreparationProtocol"; +import { + DOCUMENT_SEARCH_PROGRESS_MATCHES, + DOCUMENT_SEARCH_WINDOW, + type DocumentMatch, + type DocumentSearchRequest, + type DocumentSearchResponse, +} from "./markdownDocumentSearch"; + +let parts: string[] = []; +let sequence = 0; +let received = false; +let index: Text | null = null; +let acknowledge: (() => void) | null = null; +let active: number | null = null; +let cached: { key: string; count: number; offset: number; matches: DocumentMatch[] } | null = null; +const send = (response: DocumentSearchResponse) => postMessage(response); + +async function search(request: Extract) { + if (!index || active !== null) throw new Error("Document search worker is not ready"); + if (!Number.isSafeInteger(request.ordinal) || request.ordinal < 0) throw new Error("Invalid document match ordinal"); + active = request.id; + try { + const key = JSON.stringify([request.query, !!request.options.caseSensitive, !!request.options.regexp, !!request.options.wholeWord]); + if ( + cached?.key === key && + (cached.count === 0 || (request.ordinal >= cached.offset && request.ordinal < cached.offset + cached.matches.length)) + ) { + send({ type: "result", id: request.id, ...cached }); + return; + } + const knownCount = cached?.key === key ? cached.count : null; + const offset = Math.floor(request.ordinal / DOCUMENT_SEARCH_WINDOW) * DOCUMENT_SEARCH_WINDOW; + const matches: DocumentMatch[] = []; + let count = 0; + for (const match of documentSearchMatches(index, request.query, request.options)) { + if (count >= offset && matches.length < DOCUMENT_SEARCH_WINDOW) matches.push(match); + count += 1; + if (knownCount !== null && count >= offset + DOCUMENT_SEARCH_WINDOW) break; + if (count % DOCUMENT_SEARCH_PROGRESS_MATCHES === 0) + await new Promise((resolve) => { + acknowledge = resolve; + send({ type: "progress", id: request.id, count }); + }); + } + cached = { key, count: knownCount ?? count, offset, matches }; + send({ type: "result", id: request.id, ...cached }); + } finally { + active = null; + acknowledge = null; + } +} + +globalThis.onmessage = (event: MessageEvent) => { + const request = event.data; + const run = async () => { + if (request.type === "init") { + if (index || received || parts.length) throw new Error("Document search worker already initialized"); + send({ type: "source-ack", sequence: 0 }); + } else if (request.type === "source") { + if ( + received || + request.sequence !== sequence + 1 || + typeof request.text !== "string" || + request.text.length > PREPARATION_SOURCE_CHUNK_UNITS + ) + throw new Error("Invalid document search source chunk"); + sequence = request.sequence; + parts.push(request.text); + if (!request.final) { + send({ type: "source-ack", sequence }); + return; + } + received = true; + const source = parts.join(""); + parts = []; + const parseStarted = performance.now(); + const model = await createMarkdownDocumentModel(source); + const parseMs = performance.now() - parseStarted; + const indexStarted = performance.now(); + const built = createDocumentSearchIndex(markdownDocumentRecords(model)); + index = Text.of(built.text.split("\n")); + send({ type: "ready", textUnits: index.length, segments: built.segments.length, parseMs, indexMs: performance.now() - indexStarted }); + } else if (request.type === "search") await search(request); + else if (request.type === "ack" && request.id === active) { + const resume = acknowledge; + acknowledge = null; + resume?.(); + } + }; + void run().catch((error: unknown) => send({ type: "error", message: error instanceof Error ? error.message : String(error) })); +}; diff --git a/frontend/scripts/prototypes/sourceSearchContainment.worker.mjs b/frontend/scripts/prototypes/sourceSearchContainment.worker.mjs new file mode 100644 index 000000000..8aa6a15cf --- /dev/null +++ b/frontend/scripts/prototypes/sourceSearchContainment.worker.mjs @@ -0,0 +1,49 @@ +import { history, undo } from "@codemirror/commands"; +import { findNext, findPrevious, replaceAll, replaceNext, SearchQuery, search, setSearchQuery } from "@codemirror/search"; +import { EditorState } from "@codemirror/state"; + +export function executeSourceSearchProof(request) { + let state = EditorState.create({ doc: request.source, extensions: [search(), history()] }); + const query = new SearchQuery({ search: request.query, regexp: true, literal: true, replace: request.replace ?? "" }); + if (!query.valid) throw new Error("Invalid source-search expression"); + state = state.update({ effects: setSearchQuery.of(query), selection: request.selection ?? { anchor: 0 } }).state; + let edits = 0; + const target = { + get state() { + return state; + }, + plugin: () => null, + dispatch: (transaction) => { + const applied = transaction.state ? transaction : state.update(transaction); + if (applied.docChanged) edits += 1; + state = applied.state; + }, + }; + let count = null; + if (["count", "active-range", "visible-matches"].includes(request.operation)) { + count = 0; + const bounds = request.operation === "visible-matches" ? [0, state.doc.length] : []; + const cursor = query.getCursor(state, ...bounds); + for (let result = cursor.next(); !result.done; result = cursor.next()) count += 1; + } else { + const command = { next: findNext, previous: findPrevious, "replace-one": replaceNext, "replace-all": replaceAll }[request.operation]; + if (!command) throw new Error("Unknown source-search operation"); + command(target); + } + const content = state.doc.toString(); + const selection = { from: state.selection.main.from, to: state.selection.main.to }; + const changes = edits; + const undone = request.operation === "replace-all" && undo(target); + return { content, selection, count, edits: changes, undone, undoContent: state.doc.toString() }; +} + +if (typeof globalThis.postMessage === "function") { + globalThis.onmessage = (event) => { + try { + globalThis.postMessage({ result: executeSourceSearchProof(event.data) }); + } catch (error) { + globalThis.postMessage({ error: error instanceof Error ? error.message : String(error) }); + } + }; + globalThis.postMessage({ ready: true }); +} diff --git a/frontend/scripts/run-browser-ci.mjs b/frontend/scripts/run-browser-ci.mjs new file mode 100644 index 000000000..23fef320a --- /dev/null +++ b/frontend/scripts/run-browser-ci.mjs @@ -0,0 +1,24 @@ +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +for (const mode of ["production", "development"]) { + const result = spawnSync( + process.execPath, + [fileURLToPath(new URL("../node_modules/@playwright/test/cli.js", import.meta.url)), "test", ...process.argv.slice(2)], + { + cwd: fileURLToPath(new URL("../", import.meta.url)), + stdio: "inherit", + env: { + ...process.env, + SAMBEE_E2E_CI: "1", + SAMBEE_E2E_PRODUCTION: mode === "production" ? "1" : "0", + SAMBEE_E2E_DEVELOPMENT: mode === "development" ? "1" : "0", + }, + } + ); + if (result.error) console.error(`Could not start ${mode} browser contracts: ${result.error.message}`); + if (result.status !== 0) { + process.exitCode = result.status ?? 1; + break; + } +} diff --git a/frontend/scripts/run-tests-ci.mjs b/frontend/scripts/run-tests-ci.mjs new file mode 100644 index 000000000..0c127a811 --- /dev/null +++ b/frontend/scripts/run-tests-ci.mjs @@ -0,0 +1,21 @@ +import { spawnSync } from "node:child_process"; +import { availableParallelism, totalmem } from "node:os"; +import { fileURLToPath } from "node:url"; +import { ciWorkerCount } from "./ci-test-policy.mjs"; + +const cpus = availableParallelism(); +const memoryBytes = totalmem(); +console.log( + `CI resources: ${cpus} CPUs, ${(memoryBytes / 1024 ** 3).toFixed(1)} GiB RAM; Vitest workers=${ciWorkerCount(cpus, memoryBytes)}` +); +const result = spawnSync( + process.execPath, + [fileURLToPath(new URL("../node_modules/vitest/vitest.mjs", import.meta.url)), "run", ...process.argv.slice(2)], + { + cwd: fileURLToPath(new URL("../", import.meta.url)), + stdio: "inherit", + env: { ...process.env, SAMBEE_TEST_CI: "1" }, + } +); +if (result.error) console.error(`Could not start CI unit tests: ${result.error.message}`); +process.exitCode = result.status ?? 1; diff --git a/frontend/scripts/source-search-containment-proof.mjs b/frontend/scripts/source-search-containment-proof.mjs new file mode 100644 index 000000000..f80bbb4e0 --- /dev/null +++ b/frontend/scripts/source-search-containment-proof.mjs @@ -0,0 +1,491 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { isMainThread, parentPort, Worker } from "node:worker_threads"; +import { executeSourceSearchProof } from "./prototypes/sourceSearchContainment.worker.mjs"; + +const OPERATION_TIMEOUT_MS = 2_000; +const STALLED_OPERATION_TIMEOUT_MS = 200; +const HEARTBEAT_MS = 10; +const NATIVE_ATOMIC_MATCH_COUNT = 6_000; +const NATIVE_ATOMIC_EXPANSION_UNITS = 128; +const NATIVE_ATOMIC_MATCH = "abc 123"; +const NATIVE_TABLE_ROWS = 2_400; +const NATIVE_TABLE_ROW = NATIVE_TABLE_ROWS / 2; +const NATIVE_TABLE_PAYLOAD = "table edit"; +const NATIVE_TABLE_KEY_DELAY_MS = 10; +const NATIVE_TABLE_TIMEOUT_MS = 15_000; +const NATIVE_TABLE_VIEWPORT = { width: 1543, height: 900 }; +const NATIVE_TABLE_OUTER_NEWLINE_UNITS = 2; +const NATIVE_TABLE_INPUT_PERCENTILE = 0.95; +const OPERATIONS = ["count", "active-range", "visible-matches", "next", "previous", "replace-one", "replace-all"]; + +if (!isMainThread) { + parentPort.on("message", (request) => { + try { + parentPort.postMessage({ result: executeSourceSearchProof(request) }); + } catch (error) { + parentPort.postMessage({ error: error instanceof Error ? error.message : String(error) }); + } + }); + parentPort.postMessage({ ready: true }); +} else if (process.argv.includes("--native-table") || process.argv.includes("--native-atomic")) { + const { build } = await import("vite"); + const { chromium, firefox } = await import("playwright"); + const nativeTable = process.argv.includes("--native-table"); + const profiling = nativeTable && process.env.SAMBEE_E2E_TRACE === "1"; + const reportPrefix = nativeTable ? process.env.SAMBEE_NATIVE_TABLE_REPORT : undefined; + assert.ok( + !nativeTable || (process.env.SAMBEE_E2E_BUDGETS !== "1" && process.env.SAMBEE_E2E_MEMORY !== "1"), + "Native table diagnostics cannot enforce product budgets or combine with controlled GC" + ); + assert.ok(!profiling || reportPrefix, "Native table profiling requires SAMBEE_NATIVE_TABLE_REPORT"); + assert.ok( + !profiling || (process.env.MOZ_PROFILER_STARTUP === "1" && process.env.MOZ_PROFILER_SHUTDOWN), + "Native Firefox profiling requires startup profiler configuration and a shutdown profile path" + ); + const entry = "native-atomic-reproduction.js"; + const moduleSource = nativeTable + ? ` + import { EditorState } from '@codemirror/state'; + import { EditorView, keymap } from '@codemirror/view'; + import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'; + import { markdown } from '@codemirror/lang-markdown'; + import { forceParsing } from '@codemirror/language'; + import { GFM } from '@lezer/markdown'; + import { markdownTables } from 'codemirror-markdown-tables'; + const rows = Array.from({ length: ${NATIVE_TABLE_ROWS} }, (_, index) => '| ' + (index + 1) + ' | row ' + (index + 1) + ' needle |'); + const source = '| Key | Value |\\n| --- | --- |\\n' + rows.join('\\n') + '\\n'; + let recording = false; + let previous; + let inputAt; + const inputs = [], gaps = [], transactions = []; + const root = new EditorView({ + parent: document.body, + state: EditorState.create({ doc: source, extensions: [ + history(), keymap.of([...defaultKeymap, ...historyKeymap]), + markdown({ extensions: [GFM] }), + markdownTables({ selectionType: 'codemirror', handlePosition: 'inside', lineWrapping: 'wrap', + extensions: [keymap.of(defaultKeymap)], globalKeyBindings: historyKeymap }) + ] }), + dispatchTransactions(batch, view) { + if (!recording) { view.update(batch); return; } + const startedAt = performance.now(); + for (const transaction of batch) void transaction.state; + const stateReadyAt = performance.now(); + view.update(batch); + const updatedAt = performance.now(); + for (const transaction of batch) if (transaction.docChanged) { + let removedUnits = 0, insertedUnits = 0, changeCount = 0; + transaction.changes.iterChanges((from, to, _fromB, _toB, text) => { + removedUnits += to - from; insertedUnits += text.length; changeCount += 1; + }); + transactions.push({ stateApplyMs: stateReadyAt - startedAt, viewUpdateMs: updatedAt - stateReadyAt, + inputToRootMs: inputAt === undefined ? null : startedAt - inputAt, + beforeUnits: transaction.startState.doc.length, afterUnits: transaction.newDoc.length, + removedUnits, insertedUnits, changeCount }); + } + } + }); + if (!forceParsing(root, root.state.doc.length, ${NATIVE_TABLE_TIMEOUT_MS})) throw Error('Native table syntax did not finish parsing'); + const beforeInput = event => { + if (!recording || !root.dom.contains(event.target)) return; + const start = performance.now(); + inputAt = start; + previous ??= start; + requestAnimationFrame(() => requestAnimationFrame(() => inputs.push(performance.now() - start))); + }; + document.addEventListener('beforeinput', beforeInput, true); + const timer = setInterval(() => { + if (!recording || previous === undefined) return; + const now = performance.now(); gaps.push(now - previous); previous = now; + }, 0); + window.nativeTable = { + start() { performance.mark('sambee:native-table:input-start'); recording = true; }, + source() { return root.state.doc.toString(); }, + async stop() { + await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); + performance.mark('sambee:native-table:input-end'); + recording = false; + return { sourceUnits: source.length, inputs, gaps, transactions }; + }, + dispose() { recording = false; clearInterval(timer); document.removeEventListener('beforeinput', beforeInput, true); root.destroy(); } + }; + ` + : ` + import { EditorState, Text } from '@codemirror/state'; + window.nativeAtomic = (separator, expansion) => { + const match = ${JSON.stringify(NATIVE_ATOMIC_MATCH)}; + const matchCount = ${NATIVE_ATOMIC_MATCH_COUNT}; + const unit = match + ';' + separator; + const source = unit.repeat(matchCount); + const insert = expansion ? 'x'.repeat(${NATIVE_ATOMIC_EXPANSION_UNITS}) : 'replaced-123'; + const state = EditorState.create({ doc: source }); + const specs = Array.from({ length: matchCount }, (_, index) => ({ from: index * unit.length, to: index * unit.length + match.length, insert })); + const prepareAt = performance.now(); + const changes = state.changes(specs); + const preparedMs = performance.now() - prepareAt; + const start = performance.now(); + const result = changes.apply(state.doc); + const applyMs = performance.now() - start; + const assemblyAt = performance.now(); + const parts = []; + let offset = 0; + changes.iterChanges((from, to, _fromB, _toB, text) => { + parts.push(state.doc.sliceString(offset, from), text.toString()); + offset = to; + }); + parts.push(state.doc.sliceString(offset)); + const assembled = Text.of(parts.join('').split('\\n')); + const assemblyMs = performance.now() - assemblyAt; + const expected = (insert + ';' + separator).repeat(matchCount); + if (result.toString() !== expected || assembled.toString() !== expected || !assembled.eq(result)) throw Error('Native/assembled output mismatch'); + if (changes.invert(state.doc).apply(result).toString() !== source) throw Error('Native inverse mismatch'); + return { sourceUnits: source.length, outputUnits: result.length, changeCount: specs.length, preparedMs, applyMs, assemblyMs }; + }; + `; + const result = await build({ + configFile: false, + root: fileURLToPath(new URL("..", import.meta.url)), + logLevel: "silent", + plugins: [ + { + name: "native-atomic-reproduction", + resolveId: (id) => (id.endsWith(entry) ? resolve(fileURLToPath(new URL("..", import.meta.url)), entry) : undefined), + load: (id) => (id.endsWith(entry) ? moduleSource : undefined), + }, + ], + build: { write: false, minify: !nativeTable, lib: { entry, name: "NativeAtomic", formats: ["iife"] } }, + }); + const output = (Array.isArray(result) ? result[0] : result).output.find((item) => item.type === "chunk"); + assert.ok(output, "Native atomic reproduction must build a runnable chunk"); + if (reportPrefix) await writeFile(`${reportPrefix}.bundle.js`, output.code); + for (const [engine, browserType] of Object.entries({ chromium, firefox })) { + const browser = await browserType.launch(); + try { + const page = await browser.newPage(nativeTable ? { viewport: NATIVE_TABLE_VIEWPORT } : undefined); + const approvedUrl = "http://localhost:3000/browse/smb/demo"; + await page.route(approvedUrl, (route) => + route.fulfill({ contentType: "text/html", body: 'Native atomic diagnostic' }) + ); + await page.goto(approvedUrl); + await page.addScriptTag({ content: output.code }); + if (nativeTable) { + page.setDefaultTimeout(NATIVE_TABLE_TIMEOUT_MS); + const errors = []; + let profileSession; + page.on("pageerror", (error) => errors.push(error.message)); + try { + const cell = page.locator(".tbl-data-cell .tbl-cell-view").nth((NATIVE_TABLE_ROW - 1) * 2); + await cell.click(); + const nested = page.locator(".tbl-cell-editor .cm-content"); + await nested.waitFor({ state: "visible" }); + await nested.focus(); + await page.keyboard.press("End"); + if (profiling && engine === "chromium") { + profileSession = await page.context().newCDPSession(page); + await profileSession.send("Profiler.enable"); + await profileSession.send("Profiler.start"); + } + await page.evaluate(() => window.nativeTable.start()); + await page.keyboard.press("Shift+Enter"); + await page.keyboard.type(NATIVE_TABLE_PAYLOAD, { delay: NATIVE_TABLE_KEY_DELAY_MS }); + const sample = await page.evaluate(() => window.nativeTable.stop()); + if (profileSession) { + const { profile } = await profileSession.send("Profiler.stop"); + await writeFile(`${reportPrefix}.${engine}.profile.json`, JSON.stringify(profile)); + await profileSession.detach(); + profileSession = undefined; + } + const saved = await page.evaluate(() => window.nativeTable.source()); + const keyWidth = String(NATIVE_TABLE_ROW).length + "
".length + NATIVE_TABLE_PAYLOAD.length; + const valueWidth = `row ${NATIVE_TABLE_ROWS} needle`.length; + const expectedRows = Array.from({ length: NATIVE_TABLE_ROWS }, (_, index) => { + const key = `${index + 1}${index + 1 === NATIVE_TABLE_ROW ? `
${NATIVE_TABLE_PAYLOAD}` : ""}`; + return `| ${key.padEnd(keyWidth)} | ${`row ${index + 1} needle`.padEnd(valueWidth)} |`; + }); + const expected = `\n| ${"Key".padEnd(keyWidth)} | ${"Value".padEnd(valueWidth)} |\n| ${"-".repeat(keyWidth)} | ${"-".repeat(valueWidth)} |\n${expectedRows.join("\n")}\n`; + assert.equal(saved, expected, "Native table must publish the exact formatted document"); + assert.ok(sample.inputs.length >= NATIVE_TABLE_PAYLOAD.length, "Every typed character must produce an input sample"); + assert.ok( + sample.inputs.every(Number.isFinite) && sample.gaps.length > 0 && sample.gaps.every(Number.isFinite), + "Native timing samples must be present and finite" + ); + assert.ok(sample.transactions.length > 0, "Native typing must publish root transactions"); + for (const transaction of sample.transactions) { + assert.equal(transaction.changeCount, 1, "Native table edits must remain atomic"); + assert.equal( + transaction.removedUnits, + transaction.beforeUnits - NATIVE_TABLE_OUTER_NEWLINE_UNITS, + "Native cell edits replace the full source table" + ); + assert.equal( + transaction.insertedUnits, + transaction.afterUnits - NATIVE_TABLE_OUTER_NEWLINE_UNITS, + "Native replacement must contain the complete updated table" + ); + } + await page.keyboard.press("Control+z"); + assert.notEqual(await page.evaluate(() => window.nativeTable.source()), expected, "Native undo must change the document"); + await page.keyboard.press("Control+Shift+Z"); + await page.waitForFunction((expected) => window.nativeTable.source() === expected, expected); + const sortedInputs = [...sample.inputs].sort((left, right) => left - right); + const evidence = { + engine, + browserVersion: browser.version(), + viewport: NATIVE_TABLE_VIEWPORT, + rows: NATIVE_TABLE_ROWS, + row: NATIVE_TABLE_ROW, + diagnostic: true, + profiling, + payload: NATIVE_TABLE_PAYLOAD, + keyboardTwoFrameP95: sortedInputs[Math.ceil(sortedInputs.length * NATIVE_TABLE_INPUT_PERCENTILE) - 1], + maximumTimerGap: Math.max(...sample.gaps), + outputUnits: saved.length, + ...sample, + scope: + "Diagnostic unminified public CodeMirror/markdownTables only, after activation; no React, application callbacks, normalization, Save or workers. Explicit transaction.state materialization separates state application from view.update; elapsed stages are not pure CPU or product timing acceptance. Exact native output and undo/redo required.", + }; + if (reportPrefix) await writeFile(`${reportPrefix}.${engine}.json`, JSON.stringify(evidence)); + console.log(JSON.stringify(evidence)); + assert.deepEqual(errors, [], "Native table diagnostic must have no page errors"); + } finally { + if (profileSession) await profileSession.detach(); + await page.evaluate(() => window.nativeTable.dispose()); + } + assert.equal(await page.locator(".cm-editor").count(), 0, "Native editor must dispose"); + continue; + } + for (const separator of ["\n", " "]) + for (const expansion of [false, true]) { + const sample = await page.evaluate(({ separator, expansion }) => window.nativeAtomic(separator, expansion), { + separator, + expansion, + }); + console.log( + JSON.stringify({ + engine, + browserVersion: browser.version(), + fixture: separator === "\n" ? "dense" : "long-line", + operation: expansion ? "dense-expansion" : "original", + scope: + "Diagnostic native ChangeSet application and isolated span-assembly candidate; no production patch, EditorView, React, worker or DOM. Not timing acceptance or a mapping/history implementation.", + ...sample, + }) + ); + } + } finally { + await browser.close(); + } + } +} else { + async function run(request, timeout = OPERATION_TIMEOUT_MS) { + const worker = new Worker(new URL(import.meta.url)); + let heartbeats = 0; + let ready = false; + let settled = false; + let timer; + const heartbeat = setInterval(() => (heartbeats += 1), HEARTBEAT_MS); + try { + return await new Promise((resolve, reject) => { + const deadline = () => { + clearTimeout(timer); + timer = setTimeout( + () => { + settled = true; + reject(Object.assign(new Error("Source-search proof deadline exceeded"), { code: "PROOF_TIMEOUT", ready, heartbeats })); + }, + ready ? timeout : OPERATION_TIMEOUT_MS + ); + }; + worker.on("error", reject); + worker.on("exit", (code) => { + if (!settled) reject(new Error(`Source-search worker exited before a result (${code})`)); + }); + worker.on("message", (message) => { + if (settled) return; + if (message.ready) { + ready = true; + heartbeats = 0; + deadline(); + worker.postMessage(request); + } else { + settled = true; + if (message.error) reject(new Error(message.error)); + else resolve(message.result); + } + }); + deadline(); + }); + } finally { + settled = true; + clearTimeout(timer); + clearInterval(heartbeat); + await worker.terminate(); + } + } + + const normal = { source: "one=1 two=2", query: "(one|two)=(\\d)", replace: "$2:$1" }; + const expected = new Map(); + for (const operation of OPERATIONS) { + const result = await run({ ...normal, operation, selection: operation === "replace-one" ? { anchor: 0, head: 5 } : undefined }); + expected.set(operation, result); + if (["count", "active-range", "visible-matches"].includes(operation)) assert.equal(result.count, 2); + if (operation === "next") assert.deepEqual(result.selection, { from: 0, to: 5 }); + if (operation === "previous") assert.deepEqual(result.selection, { from: 6, to: 11 }); + if (operation === "replace-one") assert.equal(result.content, "1:one two=2"); + if (operation === "replace-all") { + assert.equal(result.content, "1:one 2:two"); + assert.equal(result.edits, 1); + assert.equal(result.undone, true); + assert.equal(result.undoContent, normal.source); + } + await assert.rejects( + run({ operation, source: `${"a".repeat(128)}!`, query: "(a+)+$", replace: "replacement" }, STALLED_OPERATION_TIMEOUT_MS), + (error) => error.code === "PROOF_TIMEOUT" && error.ready && error.heartbeats > 0 + ); + const retry = await run({ ...normal, operation: "count" }); + assert.equal(retry.count, 2); + console.log(`${operation}: native matching/command passed; stalled worker terminated; host serviced timers; fresh-worker retry passed`); + } + await assert.rejects(run({ ...normal, operation: "count", query: "(" }), /Invalid source-search expression/); + const zeroWidth = await run({ operation: "replace-all", source: "aa", query: "(?=a)", replace: "x" }); + assert.equal(zeroWidth.content, "xaxa"); + assert.equal(zeroWidth.undoContent, "aa"); + console.log( + "P1 Node-worker feasibility proof passed. Not browser/editor integration, replacement-transfer bounds, or product timeout acceptance." + ); + + if (process.env.SAMBEE_SOURCE_SEARCH_PROOF_BROWSER) { + const { chromium, firefox } = await import("playwright"); + const { build } = await import("vite"); + const browsers = { chromium, firefox }; + const names = process.env.SAMBEE_SOURCE_SEARCH_PROOF_BROWSER.split(","); + assert.ok( + names.every((name) => Object.keys(browsers).includes(name)), + "Unsupported proof browser" + ); + const output = await mkdtemp(resolve(tmpdir(), "sambee-source-search-proof-")); + const approvedUrl = "http://localhost:3000/browse/smb/demo"; + const workerUrl = "/__source_search_proof__/worker.js"; + const browserDeadlineMs = 500; + try { + await build({ + configFile: false, + root: fileURLToPath(new URL("..", import.meta.url)), + logLevel: "warn", + build: { + outDir: output, + emptyOutDir: true, + minify: true, + rollupOptions: { + input: fileURLToPath(new URL("./prototypes/sourceSearchContainment.worker.mjs", import.meta.url)), + output: { entryFileNames: "worker.js", codeSplitting: false }, + }, + }, + }); + const workerBody = await readFile(resolve(output, "worker.js")); + for (const name of names) { + const browser = await browsers[name].launch({ headless: true }); + try { + for (const width of [1543, 390]) { + const page = await browser.newPage({ viewport: { width, height: 900 } }); + const errors = []; + page.on("pageerror", (error) => errors.push(error.message)); + await page.route(`**${workerUrl}`, (route) => route.fulfill({ body: workerBody, contentType: "text/javascript" })); + await page.route(approvedUrl, (route) => + route.fulfill({ + contentType: "text/html", + body: 'P1 source-search proof', + }) + ); + await page.goto(approvedUrl); + const runBrowser = async (request, { stall = false, cancel = false } = {}) => { + await page.evaluate( + async ({ request, workerUrl, timeout, startupTimeout }) => { + window.sourceSearchProofResult = null; + await new Promise((resolveStarted, rejectStarted) => { + const worker = new Worker(workerUrl, { type: "module" }); + let ready = false; + let settled = false; + const finish = (result) => { + if (settled) return; + settled = true; + clearTimeout(timer); + worker.terminate(); + window.sourceSearchProofResult = result; + window.cancelSourceSearchProof = null; + if (!ready) rejectStarted(new Error("Source-search browser worker failed to start")); + }; + let timer = setTimeout(() => finish({ startupTimeout: true }), startupTimeout); + window.cancelSourceSearchProof = () => finish({ cancelled: true }); + worker.onerror = (event) => { + event.preventDefault(); + finish({ error: event.message }); + }; + worker.addEventListener("messageerror", () => finish({ error: "Worker response decode failed" })); + worker.onmessage = (event) => { + if (settled) return; + if (event.data.ready) { + ready = true; + clearTimeout(timer); + timer = setTimeout(() => finish({ timedOut: true }), timeout); + worker.postMessage(request); + resolveStarted(); + } else finish(event.data); + }; + }); + }, + { + request, + workerUrl, + timeout: stall || cancel ? browserDeadlineMs : OPERATION_TIMEOUT_MS, + startupTimeout: OPERATION_TIMEOUT_MS, + } + ); + if (stall || cancel) { + const input = page.locator("#input"); + const before = await input.inputValue(); + await input.press("x"); + const serviced = await page.evaluate(() => ({ + text: document.querySelector("#input").value, + pending: window.sourceSearchProofResult === null, + })); + assert.equal(serviced.text, `${before}x`); + assert.equal(serviced.pending, true, "Keyboard input must be serviced before worker timeout/cancellation"); + if (cancel) await page.evaluate(() => window.cancelSourceSearchProof()); + } + await page.waitForFunction(() => window.sourceSearchProofResult !== null); + return page.evaluate(() => window.sourceSearchProofResult); + }; + for (const operation of OPERATIONS) { + const request = { ...normal, operation, selection: operation === "replace-one" ? { anchor: 0, head: 5 } : undefined }; + const baseline = await runBrowser(request); + assert.deepEqual(baseline.result, expected.get(operation)); + const pathological = { operation, source: `${"a".repeat(128)}!`, query: "(a+)+$", replace: "replacement" }; + assert.deepEqual(await runBrowser(pathological, { stall: true }), { timedOut: true }); + const retry = await runBrowser(request); + assert.deepEqual(retry.result, expected.get(operation)); + assert.deepEqual(await runBrowser(pathological, { cancel: true }), { cancelled: true }); + } + const invalid = await runBrowser({ ...normal, operation: "count", query: "(" }); + assert.match(invalid.error, /Invalid source-search expression/); + const zeroWidthResult = await runBrowser({ operation: "replace-all", source: "aa", query: "(?=a)", replace: "x" }); + assert.deepEqual(zeroWidthResult.result, zeroWidth); + assert.deepEqual(errors, []); + console.log( + `${name} ${browser.version()} width=${width}: all seven native/proxy paths, keyboard-during-stall, timeout, cancellation, restart, capture expansion and undo checks passed` + ); + await page.close(); + } + } finally { + await browser.close(); + } + } + } finally { + await rm(output, { recursive: true, force: true }); + } + } +} diff --git a/frontend/src/__tests__/App.test.tsx b/frontend/src/__tests__/App.test.tsx index 5e2500c0f..b180d9698 100644 --- a/frontend/src/__tests__/App.test.tsx +++ b/frontend/src/__tests__/App.test.tsx @@ -90,7 +90,7 @@ vi.mock("../pages/FileBrowser", () => ({ onKeyDown={(event) => { if (event.key !== "ArrowDown") return; void navigate({ search: "?settings=account" }, { flushSync: true }); - event.currentTarget.dataset.committedSearch = screen.getByTestId("mock-file-browser-location").textContent ?? ""; + event.currentTarget.dataset["committedSearch"] = screen.getByTestId("mock-file-browser-location").textContent ?? ""; }} > Next settings category diff --git a/frontend/src/__tests__/browserBootstrap.test.ts b/frontend/src/__tests__/browserBootstrap.test.ts new file mode 100644 index 000000000..db6cd3bf3 --- /dev/null +++ b/frontend/src/__tests__/browserBootstrap.test.ts @@ -0,0 +1,37 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { loadApp } = vi.hoisted(() => ({ loadApp: vi.fn() })); +vi.mock("../index", () => { + loadApp(); + return {}; +}); + +describe("browser capability gate", () => { + beforeEach(() => { + vi.resetModules(); + loadApp.mockClear(); + vi.stubGlobal("Iterator", { from: vi.fn() }); + document.body.innerHTML = '

'; + }); + + afterEach(() => { + vi.unstubAllGlobals(); + document.body.replaceChildren(); + }); + + it.each([undefined, null, {}])("blocks app loading when Worker is %s", async (worker) => { + vi.stubGlobal("Worker", worker); + await import("../browserBootstrap"); + expect(document.getElementById("root")).toHaveClass("browser-compatibility-unsupported"); + expect(document.getElementById("browser-compatibility-reason")).toHaveTextContent("Missing browser capabilities: Web Workers."); + expect(loadApp).not.toHaveBeenCalled(); + }); + + it("loads the app when the Worker API and other required capabilities are present", async () => { + vi.stubGlobal("Worker", vi.fn()); + await import("../browserBootstrap"); + await vi.waitFor(() => expect(loadApp).toHaveBeenCalledOnce()); + expect(document.getElementById("root")).not.toHaveClass("browser-compatibility-unsupported"); + expect(document.getElementById("root")).toBeEmptyDOMElement(); + }); +}); diff --git a/frontend/src/__tests__/integration/browse-view-flow.test.tsx b/frontend/src/__tests__/integration/browse-view-flow.test.tsx index c4bd79e86..d858705a2 100644 --- a/frontend/src/__tests__/integration/browse-view-flow.test.tsx +++ b/frontend/src/__tests__/integration/browse-view-flow.test.tsx @@ -1,3 +1,4 @@ +import { createMockAuthToken } from "../../test/fixtures/users"; /** * Browse → View Flow Integration Tests (Phase 3) * @@ -94,7 +95,7 @@ describe("Browse → View Flow", () => { beforeEach(() => { // Clear any previous state localStorage.clear(); - authSession.setAuthenticated({ access_token: "mock-token", token_type: "bearer" }, false); + authSession.setAuthenticated(createMockAuthToken({ access_token: "mock-token", token_type: "bearer" }), false); }); const renderMarkdownViewer = (props: { connectionId: string; path: string; onClose: () => void }) => { diff --git a/frontend/src/__tests__/scripts/ci-test-policy.test.ts b/frontend/src/__tests__/scripts/ci-test-policy.test.ts new file mode 100644 index 000000000..fdf4d77ae --- /dev/null +++ b/frontend/src/__tests__/scripts/ci-test-policy.test.ts @@ -0,0 +1,112 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { API } from "typescript/unstable/sync"; +import { describe, expect, it } from "vitest"; +import { ciWorkerCount, expectedBrowserCases, validateBrowserCases } from "../../../scripts/ci-test-policy.mjs"; + +describe("CI resource and browser policy", () => { + it.each([ + [4, 16, 4], + [2, 8, 2], + [12, 31, 4], + [8, 6, 2], + [1, 1, 1], + ])("caps %s CPUs and %s GiB at %s workers", (cpus, gib, expected) => { + expect(ciWorkerCount(cpus, gib * 1024 ** 3)).toBe(expected); + }); + + it.each([ + [0, 8], + [2, 0], + [NaN, 8], + [2, Infinity], + ])("rejects invalid resources", (cpus, memory) => { + expect(() => ciWorkerCount(cpus, memory)).toThrow("positive CPU and memory"); + }); + + for (const mode of ["production", "development"] as const) { + const passing = () => + expectedBrowserCases(mode).map((identity) => { + const [engine, title] = identity.split(": "); + return { engine, title, status: "passed", attempts: 1 }; + }); + it(`requires the complete ${mode} engine/width matrix`, () => { + expect(passing()).toHaveLength(mode === "production" ? 16 : 2); + expect(validateBrowserCases(mode, passing())).toEqual([]); + expect(validateBrowserCases(mode, passing().slice(1))).not.toEqual([]); + expect(validateBrowserCases(mode, [...passing(), passing()[0]!])).not.toEqual([]); + }); + it.each(["skipped", "failed", "timedOut", "interrupted"])(`rejects ${mode} %s results`, (status) => { + const cases = passing(); + cases[0]!.status = status; + expect(validateBrowserCases(mode, cases)).not.toEqual([]); + }); + it(`rejects ${mode} retry-only success`, () => { + const cases = passing(); + cases[0]!.attempts = 2; + expect(validateBrowserCases(mode, cases)).not.toEqual([]); + }); + } + it("rejects unknown modes", () => expect(() => expectedBrowserCases("diagnostic")).toThrow("Unknown")); +}); + +describe("required frontend type correctness", () => { + const root = process.cwd(); + const projectNames = ["tsconfig.app.json", "tsconfig.test.json", "tsconfig.node.json"]; + + it("requires the real solution check before producing normal build assets", () => { + const manifest = JSON.parse(readFileSync(resolve(root, "package.json"), "utf8")); + expect(manifest.scripts.typecheck).toBe("tsc -b --noEmit"); + expect(manifest.scripts.build).toMatch(/^npm run typecheck && /); + const solution = JSON.parse(readFileSync(resolve(root, "tsconfig.json"), "utf8")); + expect(solution.references).toEqual(projectNames.map((path) => ({ path: `./${path}` }))); + }); + + it("uses the real check in the local test runner and VS Code task", () => { + const runner = readFileSync(resolve(root, "../scripts/test"), "utf8"); + expect(runner).toContain('run_check "TypeScript type check passed" "TypeScript type check failed" npm run typecheck'); + const tasks = JSON.parse(readFileSync(resolve(root, "../.vscode/tasks.json"), "utf8")); + expect(tasks.tasks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + label: "Frontend: Type Check + Lint", + command: expect.stringMatching(/^npm run typecheck 2>&1 && /), + }), + ]) + ); + }); + + it("preserves strict options and checks every application, test, helper, and declaration file", () => { + const compiler = new API(); + try { + const projects = projectNames.map((name) => compiler.parseConfigFile(resolve(root, name))); + const strictOptions = [ + "strict", + "noUnusedLocals", + "noUnusedParameters", + "noUncheckedIndexedAccess", + "noPropertyAccessFromIndexSignature", + "noFallthroughCasesInSwitch", + ]; + for (const project of projects) { + for (const option of strictOptions) expect(project.options[option], option).toBe(true); + expect(project.options["noCheck"]).not.toBe(true); + } + const checkedFiles = new Set(projects.flatMap((project) => project.fileNames)); + const sourceFiles = readdirSync(resolve(root, "src"), { recursive: true, withFileTypes: true }) + .filter((entry) => entry.isFile() && /\.tsx?$/.test(entry.name)) + .map((entry) => resolve(entry.parentPath, entry.name)); + expect(sourceFiles.length).toBeGreaterThan(0); + expect(sourceFiles.filter((file) => !checkedFiles.has(file))).toEqual([]); + expect(checkedFiles.has(resolve(root, "vite.config.ts"))).toBe(true); + expect(checkedFiles.has(resolve(root, "vitest.config.ts"))).toBe(true); + expect(projects.map((project) => project.options["types"])).toEqual([ + ["vite/client"], + ["vite/client", "vitest/globals", "@testing-library/jest-dom/vitest", "node"], + ["node"], + ]); + } finally { + compiler.close(); + } + }); +}); diff --git a/frontend/src/browserBootstrap.ts b/frontend/src/browserBootstrap.ts index d6d8925bf..dac10e750 100644 --- a/frontend/src/browserBootstrap.ts +++ b/frontend/src/browserBootstrap.ts @@ -9,6 +9,9 @@ if (!("withResolvers" in Promise) || typeof Promise.withResolvers !== "function" if (!("toReversed" in Array.prototype) || typeof Array.prototype.toReversed !== "function") { missingFeatures.push("Array.prototype.toReversed"); } +if (typeof globalThis.Worker !== "function") { + missingFeatures.push("Web Workers"); +} const simulateUnsupportedBrowser = import.meta.env.DEV && new URLSearchParams(window.location.search).has("simulateUnsupportedBrowser"); if (simulateUnsupportedBrowser) { diff --git a/frontend/src/components/Admin/__tests__/ConnectionDialog.test.tsx b/frontend/src/components/Admin/__tests__/ConnectionDialog.test.tsx index a699a007d..e46d20d5a 100644 --- a/frontend/src/components/Admin/__tests__/ConnectionDialog.test.tsx +++ b/frontend/src/components/Admin/__tests__/ConnectionDialog.test.tsx @@ -1,6 +1,8 @@ import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createMockConnection } from "../../../test/fixtures/connections"; +import { uninitializedTestCallback } from "../../../test/helpers/assertions"; import type { Connection } from "../../../types"; import ConnectionDialog from "../ConnectionDialog"; @@ -36,7 +38,7 @@ const mockVisibilityOptions = [ }, ]; -const mockConnection: Connection = { +const mockConnection: Connection = createMockConnection({ id: "1", name: "Test Server", type: "smb", @@ -50,7 +52,7 @@ const mockConnection: Connection = { can_manage: true, created_at: "2024-01-01T00:00:00", updated_at: "2024-01-01T00:00:00", -}; +}); function mockViewportWidth(width: number) { const originalMatchMedia = window.matchMedia; @@ -445,7 +447,7 @@ describe("ConnectionDialog Component", () => { }); it("keeps the save label visible while the save request is pending", async () => { - let resolveCreate: ((value: Connection) => void) | null = null; + let resolveCreate: (value: Connection) => void = uninitializedTestCallback; vi.mocked(api.createConnection).mockReturnValue( new Promise((resolve) => { resolveCreate = resolve; diff --git a/frontend/src/components/Editor/SourceTextEditor.tsx b/frontend/src/components/Editor/SourceTextEditor.tsx index 7e476293e..e375b7a51 100644 --- a/frontend/src/components/Editor/SourceTextEditor.tsx +++ b/frontend/src/components/Editor/SourceTextEditor.tsx @@ -1,11 +1,17 @@ import { Annotation, Compartment, EditorSelection, EditorState, type TransactionSpec } from "@codemirror/state"; import { type Command, EditorView, type ViewUpdate } from "@codemirror/view"; -import { forwardRef, useEffect, useImperativeHandle, useRef } from "react"; +import { useTheme } from "@mui/material"; +import { forwardRef, useEffect, useImperativeHandle, useLayoutEffect, useRef } from "react"; +import { getSyntaxVariables } from "../../theme/syntaxStyles"; import { CODEMIRROR_EDITOR_HORIZONTAL_INSET_CSS_VARIABLE } from "../../theme/viewerStyles"; import { getCodeMirrorHorizontalInset } from "./getCodeMirrorHorizontalInset"; import type { SourceTextEditorHandle, SourceTextEditorProps } from "./sourceTextEditorTypes"; const EXTERNAL_SYNC_ANNOTATION = Annotation.define(); +export const SOURCE_EDITOR_MEASURES = { + serialize: "sambee:source-editor:serialize", + publish: "sambee:source-editor:publish", +} as const; const sourceTextEditorBaseTheme = EditorView.theme({ "&": { @@ -82,13 +88,16 @@ export const SourceTextEditor = forwardRef { + const muiTheme = useTheme(); const containerRef = useRef(null); const viewRef = useRef(null); + const revisionRef = useRef(0); const preservedSelectionRef = useRef(null); const readOnlyCompartmentRef = useRef(new Compartment()); const extensionsCompartmentRef = useRef(new Compartment()); const contentAttributesCompartmentRef = useRef(new Compartment()); const initialValueRef = useRef(value); + const publishedValueRef = useRef(value); const initialExtensionsRef = useRef(extensions); const appliedExtensionsRef = useRef(extensions); const initialReadOnlyRef = useRef(readOnly); @@ -113,6 +122,7 @@ export const SourceTextEditor = forwardRef { + if (viewUpdate.docChanged) revisionRef.current += 1; onUpdateRef.current?.(viewUpdate, viewUpdate.view); if (!viewUpdate.docChanged) { @@ -126,7 +136,16 @@ export const SourceTextEditor = forwardRef { + useLayoutEffect(() => { const view = viewRef.current; if (!view) { return; } - const currentValue = view.state.doc.toString(); - - if (currentValue === value) { + if (publishedValueRef.current === value) { return; } + publishedValueRef.current = value; const currentSelection = view.state.selection.main; const nextAnchor = Math.min(currentSelection.anchor, value.length); const nextHead = Math.min(currentSelection.head, value.length); @@ -232,6 +249,23 @@ export const SourceTextEditor = forwardRef { viewRef.current?.focus(); }, + captureSnapshot: () => { + const view = viewRef.current; + if (!view) throw new Error("Cannot capture a snapshot after the source editor is disposed"); + if ( + view.compositionStarted || + Array.from(view.dom.querySelectorAll(".cm-editor")).some( + (element) => EditorView.findFromDOM(element)?.compositionStarted + ) + ) { + throw new DOMException( + "Finish the current input method composition before saving or creating a recovery snapshot.", + "InvalidStateError" + ); + } + const doc = view.state.doc; + return Object.freeze({ revision: revisionRef.current, serialize: () => doc.toString() }); + }, getValue: () => viewRef.current?.state.doc.toString() ?? value, getPrimarySelectionText: () => { const view = viewRef.current; @@ -272,14 +306,19 @@ export const SourceTextEditor = forwardRef; + return
; } ); diff --git a/frontend/src/components/Editor/__tests__/SourceTextEditor.test.tsx b/frontend/src/components/Editor/__tests__/SourceTextEditor.test.tsx index e8623ddce..4b10e9d9b 100644 --- a/frontend/src/components/Editor/__tests__/SourceTextEditor.test.tsx +++ b/frontend/src/components/Editor/__tests__/SourceTextEditor.test.tsx @@ -6,7 +6,7 @@ import { EditorSelection, EditorState } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { createRef, useState } from "react"; +import { createRef, useLayoutEffect, useState } from "react"; import { describe, expect, it, vi } from "vitest"; import { buildCommonEditorExtensions } from "../buildCommonEditorExtensions"; import { getSelectionLineSegments, resolveOpaqueSelectionBackground } from "../buildEditorSelectionLayer"; @@ -18,10 +18,12 @@ import { import { buildMarkdownEditorExtensions } from "../buildMarkdownEditorExtensions"; import type { MarkdownEditorThemeOptions } from "../buildMarkdownEditorTheme"; import { buildTextEditorTheme, type TextEditorThemeOptions } from "../buildTextEditorTheme"; -import { SourceTextEditor } from "../SourceTextEditor"; +import { SOURCE_EDITOR_MEASURES, SourceTextEditor } from "../SourceTextEditor"; import type { SourceTextEditorHandle } from "../sourceTextEditorTypes"; const TEST_MARKDOWN_THEME: MarkdownEditorThemeOptions = { + currentSearchMatchBackground: "rgba(244, 196, 48, 0.32)", + otherSearchMatchBackground: "rgba(244, 196, 48, 0.18)", activeLineBackground: "rgba(244, 196, 48, 0.18)", borderColor: "rgba(31, 38, 43, 0.16)", isDarkMode: false, @@ -49,6 +51,137 @@ const TEST_TEXT_THEME: TextEditorThemeOptions = { }; describe("SourceTextEditor", () => { + it("measures serialization and synchronous parent publication without retaining source or repeating controlled echoes", () => { + const editorRef = createRef(); + const onChange = vi.fn(); + const measure = vi.spyOn(performance, "measure"); + const clearMeasures = vi.spyOn(performance, "clearMeasures"); + try { + const { rerender } = render(); + const view = editorRef.current?.getView(); + if (!view) throw new Error("Expected a mounted editor"); + view.dispatch({ changes: { from: view.state.doc.length, insert: " updated" } }); + for (const name of Object.values(SOURCE_EDITOR_MEASURES)) { + expect(clearMeasures).toHaveBeenCalledWith(name); + expect(measure).toHaveBeenCalledWith(name, { + start: expect.any(Number), + detail: { revision: 1, documentUnits: "initial updated".length }, + }); + } + expect(onChange).toHaveBeenCalledOnce(); + measure.mockClear(); + rerender(); + expect(measure).not.toHaveBeenCalled(); + } finally { + measure.mockRestore(); + clearMeasures.mockRestore(); + } + }); + + it("does not serialize again when a controlled parent echoes the latest edit", () => { + const editorRef = createRef(); + const onChange = vi.fn(); + const { rerender } = render(); + const view = editorRef.current?.getView(); + if (!view) throw new Error("Expected a mounted editor"); + const transaction = view.state.update({ changes: { from: 7, insert: " updated" } }); + const serialize = vi.spyOn(transaction.state.doc, "toString"); + try { + view.dispatch(transaction); + expect(onChange).toHaveBeenLastCalledWith("initial updated", expect.anything()); + expect(serialize).toHaveBeenCalledOnce(); + serialize.mockClear(); + rerender(); + expect(serialize).not.toHaveBeenCalled(); + expect(view.state.doc).toBe(transaction.state.doc); + rerender(); + expect(view.state.doc.toString()).toBe("external"); + expect(onChange).toHaveBeenCalledOnce(); + } finally { + serialize.mockRestore(); + } + }); + + it("does not replay a controlled echo over an edit delivered before passive effects", () => { + const editorRef = createRef(); + const onChange = vi.fn(); + function ControlledEditor({ value }: { value: string }) { + useLayoutEffect(() => { + if (value !== "initial updated") return; + const view = editorRef.current?.getView(); + if (!view) throw new Error("Expected a mounted controlled editor"); + view.dispatch({ changes: { from: view.state.doc.length, insert: " next" } }); + }, [value]); + return ; + } + const { rerender } = render(); + const view = editorRef.current?.getView(); + if (!view) throw new Error("Expected a mounted controlled editor"); + view.dispatch({ changes: { from: view.state.doc.length, insert: " updated" } }); + rerender(); + expect(view.state.doc.toString()).toBe("initial updated next"); + expect(onChange).toHaveBeenCalledTimes(2); + }); + + it("rejects snapshots throughout an active composition, including before its first change", () => { + const editorRef = createRef(); + render( {}} />); + const handle = editorRef.current; + const view = handle?.getView(); + if (!handle || !view) throw new Error("Expected a mounted source editor"); + const original = handle.captureSnapshot(); + view.contentDOM.dispatchEvent(new CompositionEvent("compositionstart", { bubbles: true })); + expect(view.compositionStarted).toBe(true); + expect(view.composing).toBe(false); + expect(() => handle.captureSnapshot()).toThrow(/composition/); + view.dispatch({ changes: { from: view.state.doc.length, insert: " composed" }, userEvent: "input.type.compose" }); + expect(() => handle.captureSnapshot()).toThrow(/composition/); + view.contentDOM.dispatchEvent(new CompositionEvent("compositionend", { bubbles: true, data: "composed" })); + + expect(view.compositionStarted).toBe(false); + expect(handle.captureSnapshot().serialize()).toBe("initial composed"); + expect(original.serialize()).toBe("initial"); + }); + + it("captures an immutable revision without serializing the source during capture", () => { + const editorRef = createRef(); + const { unmount } = render( {}} />); + const handle = editorRef.current; + const view = handle?.getView(); + if (!handle || !view) throw new Error("Expected a mounted editor"); + const serialize = vi.spyOn(view.state.doc, "toString"); + try { + const snapshot = handle.captureSnapshot(); + expect(serialize).not.toHaveBeenCalled(); + expect(snapshot.revision).toBe(0); + expect(Object.isFrozen(snapshot)).toBe(true); + view.dispatch({ changes: { from: view.state.doc.length, insert: " updated" } }); + const latest = handle.captureSnapshot(); + expect(latest.revision).toBeGreaterThan(snapshot.revision); + expect(snapshot.serialize()).toBe("initial"); + expect(latest.serialize()).toBe("initial updated"); + unmount(); + expect(snapshot.serialize()).toBe("initial"); + expect(() => handle.captureSnapshot()).toThrow(/disposed/); + } finally { + serialize.mockRestore(); + } + }); + + it("includes external document replacements in snapshot revisions", () => { + const editorRef = createRef(); + const onChange = vi.fn(); + const { rerender } = render(); + const first = editorRef.current?.captureSnapshot(); + rerender(); + const latest = editorRef.current?.captureSnapshot(); + + expect(first?.serialize()).toBe("initial"); + expect(latest?.serialize()).toBe("replacement"); + expect(latest?.revision).toBeGreaterThan(first?.revision ?? -1); + expect(onChange).not.toHaveBeenCalled(); + }); + it("scrolls the viewport with Ctrl+Arrow keys without changing the selection", async () => { const editorRef = createRef(); const initialValue = "Editor content"; @@ -228,6 +361,49 @@ describe("SourceTextEditor", () => { }); }); + it.each(["theme", "language"])("preserves the viewport and selection when %s extensions change", (change) => { + const editorRef = createRef(); + const onUpdate = vi.fn(); + const onChange = vi.fn(); + const value = "first\nsecond\nthird"; + const { rerender } = render( + + ); + const view = editorRef.current?.getView(); + if (!view) throw new Error("Expected a mounted editor"); + view.dispatch({ selection: EditorSelection.range(0, 5) }); + const document = view.state.doc; + const selection = view.state.selection; + onUpdate.mockClear(); + + rerender( + + ); + + expect(editorRef.current?.getView()).toBe(view); + expect(view.state.doc).toBe(document); + expect(view.state.selection).toBe(selection); + expect(onUpdate).toHaveBeenCalled(); + for (const [update] of onUpdate.mock.calls) { + expect(update.transactions.every((transaction: { scrollIntoView: boolean }) => !transaction.scrollIntoView)).toBe(true); + } + expect(onChange).not.toHaveBeenCalled(); + }); + it("retains undo history across controlled value updates", async () => { const user = userEvent.setup(); const editorRef = createRef(); @@ -560,6 +736,9 @@ describe("SourceTextEditor", () => { expect(window.getComputedStyle(editor).color).toBe(TEST_MARKDOWN_THEME.textColor); expect(window.getComputedStyle(activeLine).backgroundColor).toBe(TEST_MARKDOWN_THEME.activeLineBackground); - expect(window.getComputedStyle(linkToken).color).toBe(TEST_MARKDOWN_THEME.linkColor); + expect(linkToken).toHaveClass("sambee-syntax-link"); + expect(window.getComputedStyle(editor.closest(".cm-editor")!).getPropertyValue("--sambee-syntax-link").replaceAll(" ", "")).toBe( + TEST_MARKDOWN_THEME.linkColor.replaceAll(" ", "") + ); }); }); diff --git a/frontend/src/components/Editor/buildCodeMirrorSearchHighlights.ts b/frontend/src/components/Editor/buildCodeMirrorSearchHighlights.ts index 771117938..5da227251 100644 --- a/frontend/src/components/Editor/buildCodeMirrorSearchHighlights.ts +++ b/frontend/src/components/Editor/buildCodeMirrorSearchHighlights.ts @@ -1,6 +1,8 @@ import { getSearchQuery, searchPanelOpen } from "@codemirror/search"; import { type Extension, RangeSetBuilder } from "@codemirror/state"; import { Decoration, type EditorView, ViewPlugin } from "@codemirror/view"; +import { findSelectedCodeMirrorSearchMatch, getCachedCodeMirrorSearchMatches } from "./codeMirrorSearchMatches"; +import { disposeSourceRegexSearch, sourceRegexSearchLifecycle, sourceRegexSearchUpdated } from "./sourceRegexSearch"; export const PASSIVE_SEARCH_MATCH_CLASS = "sambee-passive-search-match"; export const PASSIVE_SELECTED_SEARCH_MATCH_CLASS = "sambee-passive-search-match-selected"; @@ -19,39 +21,17 @@ function rangeEquals(left: SearchMatchRange | null, right: SearchMatchRange): bo return left !== null && left.from === right.from && left.to === right.to; } -function rangeContainedInSelection(range: SearchMatchRange, selection: SearchMatchRange): boolean { - return selection.from <= range.from && selection.to >= range.to; -} - function resolveCurrentSearchRange(view: EditorView): SearchMatchRange | null { - const query = getSearchQuery(view.state); - - if (!query.search) { - return null; + const ranges = getCachedCodeMirrorSearchMatches(view); + const selection = view.state.selection.main; + if (!ranges) { + const query = getSearchQuery(view.state); + if (selection.empty && !query.regexp) return null; + const match = query.getCursor(view.state, selection.from, selection.to).next(); + return match.done ? null : { from: match.value.from, to: match.value.to }; } - - const cursor = query.getCursor(view.state); - const mainSelection = view.state.selection.main; - const mainSelectionRange = { from: mainSelection.from, to: mainSelection.to }; - - let currentRange: SearchMatchRange | null = null; - let containedRange: SearchMatchRange | null = null; - - for (let nextMatch = cursor.next(); !nextMatch.done; nextMatch = cursor.next()) { - const match = nextMatch.value; - const matchRange = { from: match.from, to: match.to }; - - if (match.from === mainSelection.from && match.to === mainSelection.to) { - currentRange = matchRange; - continue; - } - - if (containedRange === null && rangeContainedInSelection(matchRange, mainSelectionRange)) { - containedRange = matchRange; - } - } - - return currentRange ?? containedRange; + const index = findSelectedCodeMirrorSearchMatch(ranges, selection); + return index < 0 ? null : (ranges[index] ?? null); } export function buildPassiveSearchHighlightExtension(): Extension { @@ -59,7 +39,7 @@ export function buildPassiveSearchHighlightExtension(): Extension { class { decorations; - constructor(view: EditorView) { + constructor(private view: EditorView) { this.decorations = this.buildDecorations(view); } @@ -70,6 +50,7 @@ export function buildPassiveSearchHighlightExtension(): Extension { docChanged: boolean; selectionSet: boolean; viewportChanged: boolean; + transactions?: readonly { effects: readonly { is: (effect: typeof sourceRegexSearchUpdated) => boolean }[] }[]; }) { const query = getSearchQuery(update.state); const previousQuery = getSearchQuery(update.startState); @@ -78,6 +59,7 @@ export function buildPassiveSearchHighlightExtension(): Extension { update.docChanged || update.selectionSet || update.viewportChanged || + update.transactions?.some((transaction) => transaction.effects.some((effect) => effect.is(sourceRegexSearchUpdated))) || !query.eq(previousQuery) || searchPanelOpen(update.state) !== searchPanelOpen(update.startState) ) { @@ -107,6 +89,28 @@ export function buildPassiveSearchHighlightExtension(): Extension { const builder = new RangeSetBuilder(); + if (query.regexp) { + const ranges = getCachedCodeMirrorSearchMatches(view) ?? []; + for (const visible of view.visibleRanges) { + let low = 0; + let high = ranges.length; + while (low < high) { + const middle = (low + high) >>> 1; + const range = ranges[middle]; + if (!range) break; + if (range.from < visible.from) low = middle + 1; + else high = middle; + } + for (let index = low; index < ranges.length; index += 1) { + const match = ranges[index]; + if (!match || match.from > visible.to) break; + if (match.from === match.to) continue; + builder.add(match.from, match.to, rangeEquals(currentRange, match) ? passiveSelectedSearchMatchMark : passiveSearchMatchMark); + } + } + return builder.finish(); + } + for (const { from, to } of view.visibleRanges) { const cursor = query.getCursor(view.state, from, to); @@ -125,6 +129,10 @@ export function buildPassiveSearchHighlightExtension(): Extension { return builder.finish(); } + + destroy() { + if (!this.view.plugin(sourceRegexSearchLifecycle)) disposeSourceRegexSearch(this.view); + } }, { decorations: (value) => value.decorations, diff --git a/frontend/src/components/Editor/buildCodeMirrorSurfaceTheme.ts b/frontend/src/components/Editor/buildCodeMirrorSurfaceTheme.ts index fb5bffea3..a55798945 100644 --- a/frontend/src/components/Editor/buildCodeMirrorSurfaceTheme.ts +++ b/frontend/src/components/Editor/buildCodeMirrorSurfaceTheme.ts @@ -52,6 +52,10 @@ export function buildCodeMirrorSurfaceTheme({ [`.${PASSIVE_SELECTED_SEARCH_MATCH_CLASS}`]: { backgroundColor: currentSearchMatchBackground, }, + [`.cm-content .cm-searchMatch, .cm-content .cm-searchMatch *, .cm-content .${PASSIVE_SEARCH_MATCH_CLASS}, .cm-content .${PASSIVE_SEARCH_MATCH_CLASS} *`]: + { + color: textColor, + }, ".cm-cursor": { borderLeft: `2px solid ${textColor}`, marginLeft: 0, @@ -76,6 +80,11 @@ export function buildCodeMirrorSurfaceTheme({ border: `1px solid ${borderColor}`, color: textColor, }, + ".sambee-source-replacement-feedback": { + backgroundColor: surfaceBackground, + border: `1px solid ${borderColor}`, + color: textColor, + }, ".cm-tooltip-autocomplete ul li[aria-selected]": { backgroundColor: activeLineBackground, color: textColor, diff --git a/frontend/src/components/Editor/buildCommonEditorExtensions.ts b/frontend/src/components/Editor/buildCommonEditorExtensions.ts index 72220c0cf..0d3c4c95c 100644 --- a/frontend/src/components/Editor/buildCommonEditorExtensions.ts +++ b/frontend/src/components/Editor/buildCommonEditorExtensions.ts @@ -5,6 +5,8 @@ import { highlightSelectionMatches, search } from "@codemirror/search"; import type { Extension } from "@codemirror/state"; import { drawSelection, EditorView, highlightActiveLine, keymap } from "@codemirror/view"; import { buildSelectionLayerExtension } from "./buildEditorSelectionLayer"; +import { sourceRegexSearchLifecycle } from "./sourceRegexSearch"; +import { sourceReplacementFeedbackExtension } from "./sourceReplacementFeedback"; interface CommonEditorExtensionsOptions { defaultSyntaxHighlighting?: boolean; @@ -28,7 +30,7 @@ function scrollEditorViewport( const scrollDelta = nextScrollTop - previousScrollTop; const selection = view.state.selection.main; - const cursorCoords = selection.empty ? view.coordsAtPos(selection.head, selection.assoc) : null; + const cursorCoords = selection.empty ? view.coordsAtPos(selection.head, selection.assoc === 0 ? undefined : selection.assoc) : null; const scrollerRect = scrollDOM.getBoundingClientRect(); const caretMovement = cursorCoords && direction === EDITOR_VIEWPORT_SCROLL_DIRECTION.DOWN @@ -82,6 +84,8 @@ export function buildCommonEditorExtensions({ ...(defaultSyntaxHighlighting ? [syntaxHighlighting(defaultHighlightStyle, { fallback: true })] : []), bracketMatching(), search({ top: true }), + sourceRegexSearchLifecycle, + sourceReplacementFeedbackExtension, ...(includeSelectionMatches ? [highlightSelectionMatches()] : []), highlightActiveLine(), ...(lineWrapping ? [EditorView.lineWrapping] : []), diff --git a/frontend/src/components/Editor/buildMarkdownEditorExtensions.ts b/frontend/src/components/Editor/buildMarkdownEditorExtensions.ts index 66f479dfe..d18278964 100644 --- a/frontend/src/components/Editor/buildMarkdownEditorExtensions.ts +++ b/frontend/src/components/Editor/buildMarkdownEditorExtensions.ts @@ -1,10 +1,11 @@ +import type { CompletionContext } from "@codemirror/autocomplete"; import { defaultKeymap, historyKeymap } from "@codemirror/commands"; -import { markdown, markdownLanguage, pasteURLAsLink } from "@codemirror/lang-markdown"; -import { languages } from "@codemirror/language-data"; +import { pasteURLAsLink } from "@codemirror/lang-markdown"; import { searchKeymap } from "@codemirror/search"; import type { Extension } from "@codemirror/state"; -import { EditorView, keymap } from "@codemirror/view"; +import { EditorView, keymap, type ViewUpdate } from "@codemirror/view"; import { markdownTableAutocompleter, markdownTables } from "codemirror-markdown-tables"; +import { createMarkdownSupport } from "../../utils/codeMirrorHighlighting"; import { buildPassiveSearchHighlightExtension } from "./buildCodeMirrorSearchHighlights"; import { buildCommonEditorExtensions } from "./buildCommonEditorExtensions"; import { EDITOR_SELECTION_RANGE_CLASS, getSelectionLineSegments, type SelectionLineSegment } from "./buildEditorSelectionLayer"; @@ -22,12 +23,16 @@ export const MARKDOWN_SELECTION_RANGE_CLASS = EDITOR_SELECTION_RANGE_CLASS; export type MarkdownSelectionLineSegment = SelectionLineSegment; export const getMarkdownSelectionLineSegments = getSelectionLineSegments; -export function buildMarkdownEditorExtensions(theme: MarkdownEditorThemeOptions, lineWrapping: boolean = true): Extension[] { - const markdownLanguageSupport = markdown({ base: markdownLanguage, codeLanguages: languages }); +export function buildMarkdownEditorExtensions( + theme: MarkdownEditorThemeOptions, + lineWrapping: boolean = true, + onNestedUpdate?: (update: ViewUpdate) => void +): Extension[] { + const markdownLanguageSupport = createMarkdownSupport(); const snippetAutocompleter = createMarkdownSnippetAutocompleter(); const tableAutocompleter = markdownTableAutocompleter({ options: MARKDOWN_TABLE_AUTOCOMPLETE_OPTIONS }); const markdownAutocompleteData = markdownLanguageSupport.language.data.of({ - autocomplete: (context) => snippetAutocompleter(context) ?? tableAutocompleter(context), + autocomplete: (context: CompletionContext) => snippetAutocompleter(context) ?? tableAutocompleter(context), }); const markdownTableTheme = buildMarkdownTableTheme(theme); @@ -53,7 +58,7 @@ export function buildMarkdownEditorExtensions(theme: MarkdownEditorThemeOptions, selectionType: "codemirror", handlePosition: "inside", lineWrapping: "wrap", - extensions: [keymap.of(defaultKeymap)], + extensions: [keymap.of(defaultKeymap), ...(onNestedUpdate ? [EditorView.updateListener.of(onNestedUpdate)] : [])], globalKeyBindings: [...historyKeymap, ...searchKeymap], markdownConfig: { completeHTMLTags: true, diff --git a/frontend/src/components/Editor/buildMarkdownEditorTheme.ts b/frontend/src/components/Editor/buildMarkdownEditorTheme.ts index dfe54102d..5355a998d 100644 --- a/frontend/src/components/Editor/buildMarkdownEditorTheme.ts +++ b/frontend/src/components/Editor/buildMarkdownEditorTheme.ts @@ -1,6 +1,6 @@ -import { HighlightStyle, syntaxHighlighting } from "@codemirror/language"; import type { Extension } from "@codemirror/state"; -import { tags } from "@lezer/highlight"; +import { EditorView } from "@codemirror/view"; +import { nativeSyntaxHighlighting } from "../../utils/codeMirrorHighlighting"; import { buildCodeMirrorSurfaceTheme } from "./buildCodeMirrorSurfaceTheme"; import { buildSelectionLayerTheme } from "./buildEditorSelectionLayer"; @@ -43,6 +43,7 @@ export function buildMarkdownEditorTheme({ textColor, }), buildSelectionLayerTheme({ selectionBackground, surfaceBackground }), - syntaxHighlighting(HighlightStyle.define([{ tag: [tags.labelName, tags.link, tags.string, tags.url], color: linkColor }])), + nativeSyntaxHighlighting, + EditorView.theme({ "&": { "--sambee-syntax-link": linkColor } }), ]; } diff --git a/frontend/src/components/Editor/buildTextEditorTheme.ts b/frontend/src/components/Editor/buildTextEditorTheme.ts index 8bc572450..fc53c2ab7 100644 --- a/frontend/src/components/Editor/buildTextEditorTheme.ts +++ b/frontend/src/components/Editor/buildTextEditorTheme.ts @@ -1,6 +1,5 @@ -import { HighlightStyle, syntaxHighlighting } from "@codemirror/language"; import type { Extension } from "@codemirror/state"; -import { tags } from "@lezer/highlight"; +import { nativeSyntaxHighlighting } from "../../utils/codeMirrorHighlighting"; import { buildCodeMirrorSurfaceTheme, type CodeMirrorSurfaceThemeOptions } from "./buildCodeMirrorSurfaceTheme"; import { buildSelectionLayerTheme } from "./buildEditorSelectionLayer"; @@ -16,12 +15,6 @@ export function buildTextEditorTheme({ accentColor, ...surfaceOptions }: TextEdi selectionBackground: surfaceOptions.selectionBackground, surfaceBackground: surfaceOptions.surfaceBackground, }), - syntaxHighlighting( - HighlightStyle.define([ - { tag: [tags.keyword, tags.controlKeyword, tags.operatorKeyword], color: accentColor }, - { tag: [tags.string, tags.url], color: accentColor }, - { tag: [tags.comment], color: `${surfaceOptions.textColor}99`, fontStyle: "italic" }, - ]) - ), + nativeSyntaxHighlighting, ]; } diff --git a/frontend/src/components/Editor/codeMirrorSearchMatches.ts b/frontend/src/components/Editor/codeMirrorSearchMatches.ts new file mode 100644 index 000000000..d4dd2d75f --- /dev/null +++ b/frontend/src/components/Editor/codeMirrorSearchMatches.ts @@ -0,0 +1,48 @@ +import { getSearchQuery, type SearchQuery } from "@codemirror/search"; +import type { Text } from "@codemirror/state"; +import type { EditorView } from "@codemirror/view"; +import { getSourceRegexSearch } from "./sourceRegexSearch"; + +interface SearchMatchRange { + from: number; + to: number; +} + +interface CachedSearchMatches { + document: Text; + query: SearchQuery; + ranges: SearchMatchRange[]; +} + +const matchCache = new WeakMap(); + +export function getCachedCodeMirrorSearchMatches(view: EditorView): readonly SearchMatchRange[] | null { + if (getSearchQuery(view.state).regexp) return getSourceRegexSearch(view).ranges; + const cached = matchCache.get(view); + return cached?.document === view.state.doc && cached.query.eq(getSearchQuery(view.state)) ? cached.ranges : null; +} + +export function getCodeMirrorSearchMatches(view: EditorView): readonly SearchMatchRange[] { + const cached = getCachedCodeMirrorSearchMatches(view); + if (cached) return cached; + const query = getSearchQuery(view.state); + const ranges: SearchMatchRange[] = []; + const cursor = query.getCursor(view.state); + for (let match = cursor.next(); !match.done; match = cursor.next()) ranges.push({ from: match.value.from, to: match.value.to }); + matchCache.set(view, { document: view.state.doc, query, ranges }); + return ranges; +} + +export function findSelectedCodeMirrorSearchMatch(ranges: readonly SearchMatchRange[], selection: SearchMatchRange): number { + let low = 0; + let high = ranges.length; + while (low < high) { + const middle = (low + high) >>> 1; + const range = ranges[middle]; + if (!range) return -1; + if (range.from < selection.from) low = middle + 1; + else high = middle; + } + const match = ranges[low]; + return match && match.to <= selection.to ? low : -1; +} diff --git a/frontend/src/components/Editor/sourceRegexSearch.ts b/frontend/src/components/Editor/sourceRegexSearch.ts new file mode 100644 index 000000000..9c336c80a --- /dev/null +++ b/frontend/src/components/Editor/sourceRegexSearch.ts @@ -0,0 +1,296 @@ +import { getSearchQuery, type SearchQuery } from "@codemirror/search"; +import { type ChangeSpec, StateEffect, type Text } from "@codemirror/state"; +import { EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view"; +import { + SOURCE_SEARCH_CHUNK_UNITS, + SOURCE_SEARCH_RANGE_BATCH, + SOURCE_SEARCH_TIMEOUT_MS, + type SourceSearchOperation, + type SourceSearchRange, + type SourceSearchRequest, + type SourceSearchResponse, +} from "./sourceRegexSearchProtocol"; +import { beginSourceReplacementFeedback, isHeavySourceReplacement, isSourceReplacementPending } from "./sourceReplacementFeedback"; + +export const sourceRegexSearchUpdated = StateEffect.define(); + +type NavigationOperation = "next" | "previous"; + +interface RegexSearchSession { + document: Text; + query: SearchQuery; + ranges: SourceSearchRange[]; + pending: boolean; + error?: string; + cancel: () => void; + enqueueNavigation?: (operation: NavigationOperation) => boolean; +} + +const sessions = new WeakMap(); + +export const sourceRegexSearchLifecycle = ViewPlugin.fromClass( + class { + constructor(private view: EditorView) {} + + update(update: ViewUpdate) { + const session = sessions.get(this.view); + if (session && (update.docChanged || !session.query.eq(getSearchQuery(update.state)))) disposeSourceRegexSearch(this.view); + } + + destroy() { + disposeSourceRegexSearch(this.view); + } + } +); + +export function disposeSourceRegexSearch(view: EditorView): void { + sessions.get(view)?.cancel(); + sessions.delete(view); +} + +export function getSourceRegexSearch(view: EditorView): RegexSearchSession { + const query = getSearchQuery(view.state); + const existing = sessions.get(view); + if (existing?.document === view.state.doc && existing.query.eq(query)) return existing; + existing?.cancel(); + const session: RegexSearchSession = { document: view.state.doc, query, ranges: [], pending: true, cancel: () => {} }; + sessions.set(view, session); + if (query.valid && query.regexp) run(view, session, "count"); + else session.pending = false; + return session; +} + +export function runSourceRegexSearchCommand(view: EditorView, operation: Exclude): boolean { + if (!getSearchQuery(view.state).regexp) return false; + if (!getSearchQuery(view.state).valid) return true; + if ((operation === "replace-one" || operation === "replace-all") && view.state.readOnly) return true; + if ((operation === "replace-one" || operation === "replace-all") && isSourceReplacementPending(view)) return true; + const session = getSourceRegexSearch(view); + if ((operation === "next" || operation === "previous") && session.enqueueNavigation?.(operation)) return true; + session.cancel(); + run(view, session, operation); + return true; +} + +function run(view: EditorView, session: RegexSearchSession, operation: SourceSearchOperation): void { + const { document, query } = session; + let selection = view.state.selection; + const isNavigation = operation === "next" || operation === "previous"; + const navigationQueue = new Map(); + let nextNavigation = 0; + let worker: Worker | undefined; + let timer: ReturnType | undefined; + let sourceOffset = 0; + let stopped = false; + const ranges: SourceSearchRange[] = []; + const changes: { from: number; to: number; chunks: string[] }[] = []; + const announcements: string[][] = []; + session.pending = true; + session.error = undefined; + const current = () => sessions.get(view) === session && view.state.doc === document && query.eq(getSearchQuery(view.state)); + const releaseReplacementBuffers = () => { + changes.length = 0; + announcements.length = 0; + }; + const stopTransport = () => { + if (stopped) return; + stopped = true; + clearTimeout(timer); + navigationQueue.clear(); + session.enqueueNavigation = undefined; + if (worker) { + worker.onmessage = null; + worker.onerror = null; + worker.removeEventListener("messageerror", decodeFailure); + worker.terminate(); + worker = undefined; + } + }; + const feedback = + operation === "replace-all" + ? beginSourceReplacementFeedback(view, () => { + stopTransport(); + releaseReplacementBuffers(); + if (session.pending && current() && selection !== view.state.selection) { + session.error = "Source search was cancelled because the selection changed. Try again."; + } + session.pending = false; + }) + : undefined; + const cancel = () => { + stopTransport(); + feedback?.dispose(); + releaseReplacementBuffers(); + }; + session.cancel = cancel; + if (isNavigation) { + session.enqueueNavigation = (next) => { + if (stopped || !current() || selection !== view.state.selection) return false; + navigationQueue.set(nextNavigation++, next); + return true; + }; + } + const notify = () => { + queueMicrotask(() => { + if (current()) view.dispatch({ effects: sourceRegexSearchUpdated.of(null) }); + }); + }; + const fail = (message: string) => { + cancel(); + if (!current()) return; + session.pending = false; + session.error = message; + notify(); + }; + const decodeFailure = () => fail("Source-search response could not be decoded. Try again."); + const send = (message: SourceSearchRequest) => { + clearTimeout(timer); + timer = setTimeout(() => fail("Source search timed out. Simplify the expression or try again."), SOURCE_SEARCH_TIMEOUT_MS); + worker?.postMessage(message); + }; + if (operation !== "count") notify(); + try { + worker = new Worker(new URL("./sourceRegexSearch.worker.ts", import.meta.url), { type: "module" }); + worker.onerror = (event) => { + event.preventDefault(); + fail("Source-search worker failed. Try the search again."); + }; + worker.addEventListener("messageerror", decodeFailure); + worker.onmessage = (event: MessageEvent) => { + if (stopped) return; + if (!current() || (operation !== "count" && selection !== view.state.selection)) { + cancel(); + if (current()) { + session.pending = false; + session.error = "Source search was cancelled because the selection changed. Try again."; + notify(); + } + return; + } + try { + const message = event.data; + if (message.type === "ready") { + send({ + type: "start", + operation, + query: { + wordChars: view.state.languageDataAt("wordChars", selection.main.head).join(""), + search: query.search, + replace: query.replace, + literal: query.literal, + caseSensitive: query.caseSensitive, + wholeWord: query.wholeWord, + }, + selection: { anchor: selection.main.anchor, head: selection.main.head }, + }); + } else if (message.type === "source-ack") { + const text = document.sliceString(sourceOffset, Math.min(document.length, sourceOffset + SOURCE_SEARCH_CHUNK_UNITS)); + sourceOffset += text.length; + send({ type: "source", text, complete: sourceOffset === document.length }); + } else if (message.type === "ranges") { + if (message.ranges.length > SOURCE_SEARCH_RANGE_BATCH) throw new Error("Source-search range batch exceeded its bound"); + for (const range of message.ranges) { + if ( + !Number.isInteger(range.from) || + !Number.isInteger(range.to) || + range.from < 0 || + range.to < range.from || + range.to > document.length + ) { + throw new Error("Source search returned an invalid match range"); + } + ranges.push(range); + } + send({ type: "ack" }); + } else if (message.type === "change") { + if ( + message.insert.length > SOURCE_SEARCH_CHUNK_UNITS || + message.from < 0 || + message.to < message.from || + message.to > document.length + ) { + throw new Error("Source search returned an invalid replacement chunk"); + } + if (message.continued) { + const previous = changes.at(-1); + if (!previous || previous.from !== message.from || previous.to !== message.to) + throw new Error("Source-search replacement lost a chunk"); + previous.chunks.push(message.insert); + } else changes.push({ from: message.from, to: message.to, chunks: [message.insert] }); + send({ type: "ack" }); + } else if (message.type === "announcement") { + if (message.text.length > SOURCE_SEARCH_CHUNK_UNITS) throw new Error("Source-search announcement exceeded its chunk bound"); + if (message.continued) { + const previous = announcements.at(-1); + if (!previous) throw new Error("Source-search announcement lost a chunk"); + previous.push(message.text); + } else announcements.push([message.text]); + send({ type: "ack" }); + } else if (message.type === "error") fail(message.message); + else if (message.type === "complete") { + if (!isNavigation) stopTransport(); + if (operation === "count") { + session.pending = false; + session.ranges = ranges; + notify(); + } else { + const plan: ChangeSpec[] = changes.map((change) => ({ from: change.from, to: change.to, insert: change.chunks.join("") })); + const commit = () => { + session.pending = isNavigation && navigationQueue.size > 0; + if (!current() || selection !== view.state.selection || (plan.length > 0 && view.state.readOnly)) return; + const commitStart = performance.now(); + view.dispatch({ + changes: plan, + selection: message.selection, + scrollIntoView: Boolean(message.selection), + effects: announcements.map((chunks) => EditorView.announce.of(chunks.join(""))), + userEvent: message.userEvent, + }); + performance.clearMeasures("sambee:source-search:commit"); + performance.measure("sambee:source-search:commit", { + start: commitStart, + detail: { operation, documentUnits: document.length, changes: plan.length }, + }); + if (isNavigation && current() && navigationQueue.size > 0) { + selection = view.state.selection; + const [index, next] = navigationQueue.entries().next().value!; + navigationQueue.delete(index); + operation = next; + session.pending = true; + send({ + type: "navigate", + operation, + selection: { anchor: selection.main.anchor, head: selection.main.head }, + wordChars: view.state.languageDataAt("wordChars", selection.main.head).join(""), + }); + } else { + stopTransport(); + if (current() && session.ranges.length === 0) run(view, session, "count"); + } + }; + if (feedback) { + const insertedUnits = changes.reduce( + (total, change) => total + change.chunks.reduce((units, chunk) => units + chunk.length, 0), + 0 + ); + feedback.apply(commit, isHeavySourceReplacement(document.length, changes.length, insertedUnits), (error) => + fail(error instanceof Error ? error.message : "Source replacement failed. Try Replace All again.") + ); + } else { + try { + commit(); + } finally { + releaseReplacementBuffers(); + } + } + } + } else throw new Error("Unexpected source-search worker response"); + } catch (error) { + fail(error instanceof Error ? error.message : "Source-search response failed"); + } + }; + timer = setTimeout(() => fail("Source-search worker did not start. Try again."), SOURCE_SEARCH_TIMEOUT_MS); + } catch { + fail("Source search requires a working dedicated worker in this browser."); + } +} diff --git a/frontend/src/components/Editor/sourceRegexSearch.worker.ts b/frontend/src/components/Editor/sourceRegexSearch.worker.ts new file mode 100644 index 000000000..022b2c789 --- /dev/null +++ b/frontend/src/components/Editor/sourceRegexSearch.worker.ts @@ -0,0 +1,129 @@ +import { findNext, findPrevious, replaceAll, replaceNext, SearchQuery, search, setSearchQuery } from "@codemirror/search"; +import { EditorState, type Text, Transaction, type TransactionSpec } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { + SOURCE_SEARCH_CHUNK_UNITS, + SOURCE_SEARCH_RANGE_BATCH, + type SourceSearchRequest, + type SourceSearchResponse, +} from "./sourceRegexSearchProtocol"; + +let request: Extract | undefined; +let chunks: string[] = []; +let output: Generator | undefined; +let navigationState: EditorState | undefined; + +function* execute(): Generator { + if (!request) throw new Error("Source search started without a request"); + const query = new SearchQuery({ ...request.query, regexp: true }); + if (!query.valid) throw new Error("Invalid source-search expression"); + let state = + navigationState ?? + EditorState.create({ + doc: chunks.join(""), + extensions: [search(), EditorState.languageData.of(() => [{ wordChars: request?.query.wordChars ?? "" }])], + }); + chunks = []; + state = state.update({ effects: setSearchQuery.of(query), selection: request.selection }).state; + if (request.operation === "count") { + const cursor = query.getCursor(state); + let ranges = []; + for (let match = cursor.next(); !match.done; match = cursor.next()) { + ranges.push({ from: match.value.from, to: match.value.to }); + if (ranges.length === SOURCE_SEARCH_RANGE_BATCH) { + yield { type: "ranges", ranges }; + ranges = []; + } + } + if (ranges.length) yield { type: "ranges", ranges }; + yield { type: "complete", announcements: [] }; + return; + } + let transaction: Transaction | undefined; + const target = { + get state() { + return state; + }, + plugin: () => null, + dispatch: (spec: TransactionSpec) => { + transaction = state.update(spec); + }, + }; + const commands = { next: findNext, previous: findPrevious, "replace-one": replaceNext, "replace-all": replaceAll }; + commands[request.operation](target as unknown as EditorView); + if (request.operation === "next" || request.operation === "previous") navigationState = transaction?.state ?? state; + if (!transaction) { + yield { type: "complete", announcements: [] }; + return; + } + const changes: { from: number; to: number; inserted: Text }[] = []; + transaction.changes.iterChanges((from, to, _fromNew, _toNew, inserted) => { + changes.push({ from, to, inserted }); + }, true); + for (const { from, to, inserted } of changes) { + for (let offset = 0; offset < inserted.length || offset === 0; offset += SOURCE_SEARCH_CHUNK_UNITS) { + yield { + type: "change", + from, + to, + insert: inserted.sliceString(offset, Math.min(inserted.length, offset + SOURCE_SEARCH_CHUNK_UNITS)), + continued: offset > 0, + }; + } + } + const selection = transaction.selection?.main; + for (const effect of transaction.effects) { + if (!effect.is(EditorView.announce)) continue; + for (let offset = 0; offset < effect.value.length; offset += SOURCE_SEARCH_CHUNK_UNITS) { + yield { type: "announcement", text: effect.value.slice(offset, offset + SOURCE_SEARCH_CHUNK_UNITS), continued: offset > 0 }; + } + } + yield { + type: "complete", + selection: selection ? { anchor: selection.anchor, head: selection.head } : undefined, + userEvent: transaction.annotation(Transaction.userEvent), + announcements: [], + }; +} + +function publishNext(): void { + const next = output?.next(); + if (next && !next.done) globalThis.postMessage(next.value); +} + +globalThis.onmessage = (event: MessageEvent) => { + try { + const message = event.data; + if (message.type === "start") { + request = message; + chunks = []; + navigationState = undefined; + globalThis.postMessage({ type: "source-ack" } satisfies SourceSearchResponse); + } else if (message.type === "navigate") { + if (!navigationState || !request || (request.operation !== "next" && request.operation !== "previous")) + throw new Error("Source navigation requires a prepared navigation document"); + request = { + ...request, + operation: message.operation, + selection: message.selection, + query: { ...request.query, wordChars: message.wordChars }, + }; + output = execute(); + publishNext(); + } else if (message.type === "source") { + if (message.text.length > SOURCE_SEARCH_CHUNK_UNITS) throw new Error("Source-search transfer exceeded its chunk bound"); + chunks.push(message.text); + if (message.complete) { + output = execute(); + publishNext(); + } else globalThis.postMessage({ type: "source-ack" } satisfies SourceSearchResponse); + } else publishNext(); + } catch (error) { + output = undefined; + chunks = []; + navigationState = undefined; + globalThis.postMessage({ type: "error", message: error instanceof Error ? error.message : "Source search failed" }); + } +}; + +globalThis.postMessage({ type: "ready" } satisfies SourceSearchResponse); diff --git a/frontend/src/components/Editor/sourceRegexSearchProtocol.ts b/frontend/src/components/Editor/sourceRegexSearchProtocol.ts new file mode 100644 index 000000000..927bdd1f6 --- /dev/null +++ b/frontend/src/components/Editor/sourceRegexSearchProtocol.ts @@ -0,0 +1,34 @@ +export const SOURCE_SEARCH_CHUNK_UNITS = 16_384; +export const SOURCE_SEARCH_RANGE_BATCH = 512; +export const SOURCE_SEARCH_TIMEOUT_MS = 2_000; + +export type SourceSearchOperation = "count" | "next" | "previous" | "replace-one" | "replace-all"; + +export interface SourceSearchRange { + from: number; + to: number; +} + +export interface SourceSearchQuery { + wordChars: string; + search: string; + replace: string; + caseSensitive: boolean; + wholeWord: boolean; + literal: boolean; +} + +export type SourceSearchRequest = + | { type: "start"; operation: SourceSearchOperation; query: SourceSearchQuery; selection: { anchor: number; head: number } } + | { type: "source"; text: string; complete: boolean } + | { type: "navigate"; operation: "next" | "previous"; selection: { anchor: number; head: number }; wordChars: string } + | { type: "ack" }; + +export type SourceSearchResponse = + | { type: "ready" } + | { type: "source-ack" } + | { type: "ranges"; ranges: SourceSearchRange[] } + | { type: "change"; from: number; to: number; insert: string; continued: boolean } + | { type: "announcement"; text: string; continued: boolean } + | { type: "complete"; selection?: { anchor: number; head: number }; userEvent?: string; announcements: string[] } + | { type: "error"; message: string }; diff --git a/frontend/src/components/Editor/sourceReplacementFeedback.ts b/frontend/src/components/Editor/sourceReplacementFeedback.ts new file mode 100644 index 000000000..6067dade1 --- /dev/null +++ b/frontend/src/components/Editor/sourceReplacementFeedback.ts @@ -0,0 +1,189 @@ +import { getSearchQuery } from "@codemirror/search"; +import { StateEffect } from "@codemirror/state"; +import { EditorView, ViewPlugin } from "@codemirror/view"; +import { t } from "i18next"; + +export const SOURCE_REPLACEMENT_FEEDBACK_DELAY_MS = 200; +export const SOURCE_REPLACEMENT_HEAVY_MATCHES = 1_000; +export const SOURCE_REPLACEMENT_HEAVY_UNITS = 50_000; +const FEEDBACK_CLASS = "sambee-source-replacement-feedback"; +const feedbackUpdated = StateEffect.define(); + +const pending = new WeakMap(); +const feedbackViews = new WeakSet(); + +const feedbackTheme = EditorView.baseTheme({ + [`& .${FEEDBACK_CLASS}`]: { + position: "absolute", + bottom: "8px", + right: "16px", + zIndex: "5", + display: "flex", + alignItems: "center", + gap: "8px", + padding: "6px 10px", + borderRadius: "4px", + backgroundColor: "Canvas", + color: "CanvasText", + boxShadow: "0 1px 4px #0004", + pointerEvents: "none", + fontSize: "13px", + }, + [`& .${FEEDBACK_CLASS} .sambee-replacement-spinner`]: { + width: "14px", + height: "14px", + flexShrink: "0", + border: "2px solid currentColor", + borderRightColor: "transparent", + borderRadius: "50%", + }, +}); + +const feedbackLifecycle = ViewPlugin.fromClass( + class { + constructor(private view: EditorView) { + feedbackViews.add(view); + } + + update() { + const feedback = pending.get(this.view); + if (feedback && !feedback.current()) feedback.dispose(); + } + + destroy() { + feedbackViews.delete(this.view); + pending.get(this.view)?.dispose(); + } + } +); + +export const sourceReplacementFeedbackExtension = [feedbackLifecycle, feedbackTheme]; + +export function isSourceReplacementPending(view: EditorView): boolean { + return pending.has(view); +} + +export function isHeavySourceReplacement(documentUnits: number, matches: number, insertedUnits: number): boolean { + return matches >= SOURCE_REPLACEMENT_HEAVY_MATCHES && Math.max(documentUnits, insertedUnits) >= SOURCE_REPLACEMENT_HEAVY_UNITS; +} + +export function beginSourceReplacementFeedback(view: EditorView, onDispose?: () => void): SourceReplacementFeedback { + if (!view.plugin(feedbackLifecycle)) view.dispatch({ effects: StateEffect.appendConfig.of(sourceReplacementFeedbackExtension) }); + pending.get(view)?.dispose(); + const feedback = new SourceReplacementFeedback(view, onDispose); + pending.set(view, feedback); + feedback.notify(); + return feedback; +} + +class SourceReplacementFeedback { + private readonly document; + private readonly query; + private readonly selection; + private indicator?: HTMLElement; + private animation?: Animation; + private delayTimer?: ReturnType; + private hiddenTimer?: ReturnType; + private frame?: number; + private disposed = false; + private applyPending?: () => void; + + constructor( + private readonly view: EditorView, + private readonly onDispose?: () => void + ) { + this.document = view.state.doc; + this.query = getSearchQuery(view.state); + this.selection = view.state.selection; + this.delayTimer = setTimeout(() => this.show(), SOURCE_REPLACEMENT_FEEDBACK_DELAY_MS); + } + + current(): boolean { + return ( + !this.disposed && + feedbackViews.has(this.view) && + this.view.state.doc === this.document && + this.view.state.selection === this.selection && + this.query.eq(getSearchQuery(this.view.state)) && + !this.view.state.readOnly + ); + } + + notify(): void { + queueMicrotask(() => { + if (feedbackViews.has(this.view)) this.view.dispatch({ effects: feedbackUpdated.of(null) }); + }); + } + + private show(): void { + if (!this.current() || this.indicator) return; + const indicator = this.view.dom.ownerDocument.createElement("div"); + indicator.className = FEEDBACK_CLASS; + indicator.setAttribute("role", "status"); + indicator.textContent = t("viewer.edit.replacing", { defaultValue: "Replacing matches..." }); + const spinner = this.view.dom.ownerDocument.createElement("span"); + spinner.className = "sambee-replacement-spinner"; + spinner.setAttribute("aria-hidden", "true"); + indicator.prepend(spinner); + this.view.dom.append(indicator); + if (!this.view.dom.ownerDocument.defaultView?.matchMedia("(prefers-reduced-motion: reduce)").matches) { + this.animation = spinner.animate?.([{ transform: "rotate(0deg)" }, { transform: "rotate(360deg)" }], { + duration: 800, + iterations: Number.POSITIVE_INFINITY, + }); + } + this.view.contentDOM.setAttribute("aria-busy", "true"); + this.indicator = indicator; + } + + apply(action: () => void, heavy: boolean, onError: (error: unknown) => void): void { + clearTimeout(this.delayTimer); + const commit = () => { + try { + if (this.current()) action(); + } catch (error) { + onError(error); + } finally { + this.dispose(); + } + }; + if (!this.indicator && !heavy) { + commit(); + return; + } + this.show(); + this.applyPending = commit; + const document = this.view.dom.ownerDocument; + document.addEventListener("visibilitychange", this.onVisibilityChange); + if (document.hidden) { + this.hiddenTimer = setTimeout(commit, 0); + } else { + this.frame = requestAnimationFrame(() => { + this.frame = requestAnimationFrame(commit); + }); + } + } + + private onVisibilityChange = (): void => { + if (!this.view.dom.ownerDocument.hidden || !this.applyPending) return; + if (this.frame !== undefined) cancelAnimationFrame(this.frame); + clearTimeout(this.hiddenTimer); + this.hiddenTimer = setTimeout(this.applyPending, 0); + }; + + dispose(): void { + if (this.disposed) return; + this.disposed = true; + clearTimeout(this.delayTimer); + clearTimeout(this.hiddenTimer); + if (this.frame !== undefined) cancelAnimationFrame(this.frame); + this.view.dom.ownerDocument.removeEventListener("visibilitychange", this.onVisibilityChange); + this.applyPending = undefined; + this.animation?.cancel(); + this.indicator?.remove(); + this.view.contentDOM.removeAttribute("aria-busy"); + if (pending.get(this.view) === this) pending.delete(this.view); + this.onDispose?.(); + this.notify(); + } +} diff --git a/frontend/src/components/Editor/sourceTextEditorTypes.ts b/frontend/src/components/Editor/sourceTextEditorTypes.ts index 6341f9025..a615d1681 100644 --- a/frontend/src/components/Editor/sourceTextEditorTypes.ts +++ b/frontend/src/components/Editor/sourceTextEditorTypes.ts @@ -1,6 +1,11 @@ import type { Extension, TransactionSpec } from "@codemirror/state"; import type { Command, EditorView, ViewUpdate } from "@codemirror/view"; +export interface SourceTextSnapshot { + readonly revision: number; + serialize(): string; +} + export interface SourceTextEditorProps { value: string; extensions?: Extension[]; @@ -16,6 +21,7 @@ export interface SourceTextEditorProps { export interface SourceTextEditorHandle { focus: () => void; + captureSnapshot: () => SourceTextSnapshot; getValue: () => string; getPrimarySelectionText: () => string; getView: () => EditorView | null; diff --git a/frontend/src/components/FileBrowser/DesktopToolbar.tsx b/frontend/src/components/FileBrowser/DesktopToolbar.tsx index aefa6a6a1..267cf1901 100644 --- a/frontend/src/components/FileBrowser/DesktopToolbar.tsx +++ b/frontend/src/components/FileBrowser/DesktopToolbar.tsx @@ -10,7 +10,7 @@ interface DesktopToolbarProps { searchProvider: SearchProvider; searchActivationToken?: number; searchRefreshToken?: number; - searchInputRef?: React.RefObject; + searchInputRef?: React.RefObject; showSearch: boolean; onOpenHelp: () => void; onOpenDocumentation: () => void; diff --git a/frontend/src/components/FileBrowser/FileBrowserAlerts.tsx b/frontend/src/components/FileBrowser/FileBrowserAlerts.tsx index e9a735174..4652f52c3 100644 --- a/frontend/src/components/FileBrowser/FileBrowserAlerts.tsx +++ b/frontend/src/components/FileBrowser/FileBrowserAlerts.tsx @@ -3,6 +3,7 @@ // import { Alert, Box, Button, Link, Stack, Typography } from "@mui/material"; +import type { TFunction } from "i18next"; import { useTranslation } from "react-i18next"; import type { BackendAvailabilityStatus } from "../../services/backendAvailability"; import type { CompanionPairingErrorKind } from "../../services/localDrivePairing"; @@ -10,7 +11,7 @@ import { EmptyStateIllustration } from "./EmptyStateIllustration"; export type CompanionLifecycleStatus = "renewal_required" | "auth_failed" | "lock_lost" | "recovery_required"; -function getCompanionLifecycleMessage(t: ReturnType["t"], status: CompanionLifecycleStatus): string { +function getCompanionLifecycleMessage(t: TFunction, status: CompanionLifecycleStatus): string { switch (status) { case "renewal_required": return t("fileBrowser.chrome.alerts.companionLifecycle.renewal_required"); @@ -115,7 +116,7 @@ export function FileBrowserAlerts({ sx={{ mb: 2, mx: 2 }} action={ companionPairingError && onOpenLocalDrivesSettings ? ( - + {companionPairingError === "signature" && (