diff --git a/examples/reference-host/README.md b/examples/reference-host/README.md new file mode 100644 index 000000000..1595303a5 --- /dev/null +++ b/examples/reference-host/README.md @@ -0,0 +1,160 @@ +# Inkspan reference host + +Status: Active PR / partial reference-host implementation + +This directory is integration evidence for [issue #377](https://github.com/ContextualWisdomLab/inkspan/issues/377). It is intentionally **host code**, not a new Inkspan runtime surface or a production application. Protected `main` remains the shipped product authority. Final reference-host acceptance still requires the complete documented journey against the integrated protected artifact selected by #118. + +The current slice contains deterministic host fixtures and helpers, public-package presentation entrypoints, a buyer-facing native-form component, an SSR/hydration gate, exact-packed application-level SSR evidence, and exact-packed-artifact package/browser verification: + +- `synthetic-document-repository.mjs` demonstrates host-owned strong-validator / `If-Match` persistence behavior. `ambiguous_failure` models a pre-commit failure, while `ambiguous_commit_failure` commits durable state but returns the same ambiguous error without a replacement validator. After either ambiguous outcome, re-read durable state before retrying instead of advancing or blindly reusing the caller's last known validator. A stale validator returns a conflict. A confirmed failure can be retried with the unchanged current validator. A restore is a normal confirmed save against the current validator and advances it only after success. A fork requires the current validator, copies the current document into an independent reference repository, and starts that fork at a fresh validator. +- `delayed-proposal.mjs` demonstrates a provider-free delayed proposal captured against one expected revision. If the current revision changes before application, the proposal returns a conflict instead of overwriting newer content. Host apply callback failures are normalized to a stable payload-free error rather than reflecting private causes through the proposal boundary. +- `delayed-proposal-host.tsx` connects that fixture to an actual public editor. It captures the current draft before preparing a fixed local suggestion, previews the suggestion, requires native confirmation, and uses the public local-revision restore guard when applying. A changed draft causes a visible conflict, including a change during asynchronous application. Nothing is sent to a model or durably saved. +- `autosave-view-model.mjs` projects Inkspan autosave lifecycle snapshots into host-localizable `clean`, `saving`, `queued`, `conflict`, `failed`, `retrying`, `recovered`, `closing`, and `closed` presentation states. Recovery presentation is derived from observed blocked → saving → idle transitions, and validators are never returned as UI data. +- `autosave-recovery-host.tsx` connects the actual editor to the public durable autosave session and the existing in-memory repository. Edit to save automatically; delay confirmation to see newer edits queue; simulate failures or a competing author, then use the recovery controls without discarding the local draft. A reread confirms ambiguous writes before retrying. Matching committed content is not written twice. Conflicts can be saved as an independent copy, after which subsequent edits target that copy and leave the original unchanged. Captures that finish hashing out of order cannot replace newer edits. The host resets its presentation projection when it replaces a blocked session; it does not relabel an old session's retry as a new session's recovery. +- `collaboration-provider-lifecycle.mjs` demonstrates that the embedding host creates one real `Y.Doc`, reuses that same document across provider reconnects, owns provider/document teardown, and replaces a provider whose `connect()` threw rather than retrying an indeterminate resource. The deterministic provider fixture has no provider SDK or network transport and does not treat room or actor identifiers as authorization evidence. +- `local-collaboration-host.tsx` connects the public collaboration editor to two actual host-created local documents. The existing host authorization and lifecycle helpers control each connection generation; a tiny in-memory provider forwards document updates between the views and removes its listeners on replacement or disposal. Failed admission leaves local edits intact for a later authorized reconnect. No presence, remote cursor, network service or durable save is implied. +- `host-authorized-collaboration.mjs` demonstrates a host-owned synchronous admission gate around provider construction. The authorization callback receives only bounded room, actor, and provider-generation identity; only the exact boolean `true` admits construction; thrown, asynchronous, false, or otherwise indeterminate decisions fail closed with a payload-redacted error. The host re-authorizes every provider generation before the host-owned `Y.Doc` reaches the provider constructor. This reference adapter contains no production identity service, credential store, provider SDK, or network transport and does not make Inkspan an authorization authority. +- `single-flight-submission.ts` provides the synchronous host-local admission guard used by the form example so same-turn submit/reset races cannot invoke the durable host callback twice. +- `hydration-gate.tsx` demonstrates an application-facing client hydration gate around the public Inkspan editor contract. It is deterministic boundary evidence, not a complete framework application or browser acceptance journey. +- `reference-host-client.tsx` is the narrow `'use client'` composition boundary: it owns the hydration gate plus native-form editor lifecycle while leaving authorization and durable persistence behind the injected host callback. +- `reference-host-app.tsx` is the server-safe deterministic application shell. It renders host chrome and delegates all browser-only editor behavior to `reference-host-client.tsx`; this remains source-contract evidence rather than complete framework/browser acceptance. +- `browser-host.tsx` loads the local `presentation-full.css` entrypoint and hydrates the deterministic `browser-host.html` shell into the native-form, save-recovery, proposal, or collaboration journey. The browser verifier installs the exact tarball and binds the editor, autosave, CSS, and bundled fonts to that consumer for Chromium/Firefox/WebKit verification. Its in-memory readbacks are local test evidence, not an authorized durable host or final #377 acceptance. +- `presentation-full.css` imports Inkspan's public `styles.css` and complete multilingual `fonts.css` subpaths for hosts that want the bundled offline multilingual font set. +- `presentation-latin.css` imports the same public editor stylesheet plus the smaller public `fonts-latin.css` option for Latin-only hosts. +- `native-form-host.tsx` demonstrates public-package native-form integration: Inkspan synchronizes `message_body` through `formFieldName`, reset behavior is expressed through `formResetValue`, the host reads `FormData` only on submit, and authorization plus durable persistence remain behind the injected `onAuthorizedSubmit` host boundary rather than being embedded in the component. +- `office-handoff.mjs` maps bounded editor Markdown through Inkspan's public React-free Markdown projection into the strict DOCX paragraph-request shape expected by the Office component. It does not render Office bytes, authorize export, choose an output path, persist or distribute artifacts, use network/credential state, or claim Markdown-to-OOXML round-trip fidelity; those remain Office/host responsibilities. +- `verify-office-handoff.mjs` consumes an already extracted exact package entry supplied by repository verification orchestration through `INKSPAN_BROWSER_PACKAGE_ENTRY`, maps bounded Markdown through that public React-free package handoff, renders the request through the local Inkspan Office CLI, validates the resulting DOCX title/body, and removes its temporary output. It does not build, pack, or choose the artifact itself. This is deterministic component-execution evidence only: host export authorization, output-location policy, durable storage, distribution, and broader document-fidelity acceptance remain outside the example's authority. +- `verify-packed-office-journey.mjs` is the self-contained package-authority wrapper for that bounded Office path: `--self-test` builds and packs the current Inkspan source, extracts the exact tarball into an isolated consumer-shaped package root, binds `verify-office-handoff.mjs` to that public package entry, runs the bounded DOCX rendering/validation, and removes the temporary consumer; `--plan` reports the exact-packed-tarball authority without executing it. It still assumes the repository's local Office Python dependencies are installed and makes no production export/storage/distribution claim. +- `verify-packed-artifact.mjs` builds and packs the current Inkspan source, installs that exact tarball into an isolated consumer, proves public ESM/CommonJS/SSR and React-free subpath consumption plus CSS/font resolution, exercises exact packed autosave observer wiring into the host lifecycle projection, and rejects source-tree authority leakage. This is exact package-consumer evidence, not a complete buyer framework application. +- `verify-application-ssr.mjs` builds and packs the current Inkspan source, installs that exact tarball plus host-owned React/ReactDOM dependencies into an isolated buyer-shaped consumer, binds the real `ReferenceHostApp` application shell to the installed packed public entry, server-bundles it with Vite, and requires deterministic host chrome plus a deferred client-editor hydration boundary. The host submit callback must not execute during server rendering and the client-only `message_body` field must not cross the server boundary. The verifier removes its temporary consumer after execution. This is exact-packed application-level SSR evidence, not a complete Next.js or other production framework host. +- `verify-browser-journey.mjs` builds and packs the current Inkspan source, installs that exact tarball and its declared dependencies into an isolated temporary consumer, binds the browser harness to the installed public entries and consumer-owned React peers, and runs the reference-host specs across Chromium, Firefox, and WebKit. Extracting an archive alone is insufficient: the package deliberately externalizes dependencies. Sharing the installed consumer's React and ReactDOM prevents a second hook runtime in the host. `--plan` reports the engines/spec inventory; `--self-test` runs the scoped journey and reports the tarball SHA-256 plus successful dependency installation. The verifier requires the repository's isolated browser-test dependencies and pinned browsers, makes no production transport/auth/persistence claim, and removes its temporary consumer after the run. +- `verify-current-reference-journey.mjs` is the single entrypoint for the **currently implemented partial** journey. `--plan` emits the exact ordered verification contract without running it; the default invocation runs the deterministic repository/proposal/autosave/collaboration checks followed by exact packed-artifact, application SSR, bounded Office, and scoped exact-packed real-browser verification. It does not claim final #377 acceptance or production host integration. + +Executable fixtures and helpers remain reference-only host code, require no service, database, credential, provider SDK, model, or external runtime connection for their deterministic repository checks, and are exercised by repository tests. The browser verifier uses only the local loopback harness at runtime and its browser specs reject unexpected external requests. The presentation/native-form/hydration examples reference only public package entrypoints. The complete reference-host directory is deliberately outside the package `files` inventory so example host logic cannot silently become published Inkspan runtime authority. + +## Copy this, replace that + +| Reference element | Buyer action | +| --- | --- | +| synthetic document repository | Replace with an authorized atomic durable store that enforces the host's RFC 9110 `If-Match` policy, preserves caller validators across confirmed failures, reconciles authoritative state after ambiguous transport outcomes, supports explicit retry/restore/fork recovery under current-validator checks, isolates fork history, and returns a new strong validator only after confirmed success. | +| deterministic delayed proposal | Replace proposal generation with a host-approved model gateway and data-use policy while preserving exact-revision conflict checks and payload-redacted callback-failure handling before applying untrusted proposal data. | +| autosave presentation projection | Preserve the exact packed autosave observer-to-view-model boundary in localized host UI; connect authenticated recovery actions in host code and do not display revision or durable validators as user-facing status. | +| collaboration lifecycle and authorization fixtures | Keep host-owned `Y.Doc` lifecycle control and per-generation fail-closed authorization placement. Replace the deterministic `authorize` callback and provider factory with the host's authenticated policy decision and authorized Yjs transport provider while preserving ambiguous-connect replacement, reconnect, teardown, credential, and room-authorization policy. Credentials and durable authorization evidence remain host-owned and must not enter Inkspan runtime authority. | +| presentation entrypoints | Choose the complete multilingual or Latin-only font entrypoint, keep imports on published package subpaths, and apply any host theme overrides without weakening Inkspan accessibility states. | +| native form host | Keep Inkspan's `formFieldName` / `formResetValue` serialization boundary, then connect `onAuthorizedSubmit` to host-owned authorization and atomic durable persistence. Do not add a second hidden-field serializer or treat submitted form content as authorization evidence. | +| Office handoff helper | Keep the provider-neutral public Markdown projection/request mapping or replace it with a host-approved richer mapping; invoke the strict Inkspan Office renderer only behind host-owned export authorization, output-location policy, storage, and distribution. | +| exact packed-artifact verifier | Preserve exact-tarball build/install/SSR/autosave-observer/package-resolution and real-browser presentation checks in buyer CI, then add the complete framework application journey rather than treating repository test-host success as product acceptance. | +| synthetic document and revision identifiers | Replace with authenticated/authorized host context; never infer tenant or actor authority from an Inkspan digest, form value, or example identifier. | +| reference error handling | Map stable machine outcomes to localized host UX and audited host operations without copying document bodies, prompts, credentials, or private causes into generic telemetry. | + +## Ownership map + +```mermaid +flowchart LR + User[Author / reviewer] --> Host[Embedding host] + Host --> Inkspan[Inkspan editor + deterministic evidence] + Host --> Repo[Host document repository] + Host --> Provider[Host collaboration provider] + Host --> Model[Host-approved model gateway] + Inkspan --> Proposal[Untrusted proposal data] + Proposal --> Host + Repo -->|strong validator / conflict| Host + + classDef host stroke-width:2px; + class Host,Repo,Provider,Model host; +``` + +Inkspan owns deterministic editor/revision/autosave/conversion/package behavior. The host owns authenticated transport, authorization, tenancy, durable persistence, `Y.Doc` and collaboration-provider lifecycle, credentials, model policy, retention, deployment, and durable audit. A successful local editor operation, native-form submission, Yjs update, model response, or status check is not durable authorization or persistence evidence. + +## Executable fixture checks + +### Interactive local suggestion + +Open the same local browser entry with `?journey=proposal` (and optionally `&readOnly=1`). Prepare an example suggestion, review it, and either discard it or confirm application. Edit the draft before applying to exercise the stale-proposal path. This is a fixed local fixture, not generated model advice or a persistence demonstration. + +This journey reuses the recovery screen's visual contract: draft first, announced state next, suggested text in a plain quotation, then native prepare/apply/discard controls. It keeps the same typography, wrapping controls, print behavior, and focus treatment. Loading and application disable repeated actions; read-only disables every mutation. Newer local content must survive a delayed application. No new layout system, component library, model SDK, credentials, or external visual-reference claim is introduced. + +### Interactive save recovery + +After the repository's pinned dependencies are installed, build and open the local reference host: + +```sh +pnpm build +pnpm exec vite --config tests/browser/vite.config.ts --host 127.0.0.1 --port 4173 --strictPort +``` + +Open `http://127.0.0.1:4173/examples/reference-host/browser-host.html?journey=recovery`. +The default URL still opens the native-form journey; add `&readOnly=1` to the recovery URL for its read-only state. Add `&savedDraft=1` to open a previously saved rich-text fixture, or `&savedDraft=invalid` to exercise an unreadable stored draft. The editor restores the saved document before enabling editing or autosave; an unreadable document remains untouched with editing disabled. This development preview resolves public package exports from the local build. The verifier below installs the exact tarball and its declared dependency closure; development-preview success alone is not packed-artifact evidence. + +The screen keeps the existing editor and bundled Noto Sans presentation, uses native controls, announces save outcomes, and separates demo failure controls from recovery actions. Demo control sizing and focus styles apply only to the example's controls; the embedded editor keeps the package's own presentation. No new UI framework, service, or dependency is introduced. Copy, retry, and restore admission is synchronous, independent of the next React render. Demo controls are absent in print; the draft remains readable at 320px and with forced colors. + +After a save failure or conflict, **Use saved version** opens a native confirmation. Cancel keeps the local draft. Confirm replaces unsaved content with the currently saved version only if neither version changed during confirmation; focus then returns to the editor. Loading the saved version does not rewrite storage or advance its version. Subsequent edits save against the freshly read version. In a separate copy, this action restores that copy's saved content, not the original document. It is not a historical-version browser or a durable rollback operation. + +### Saved-version recovery design contract + +| Field | Decision | +| --- | --- | +| Screen job | Resume editing a saved version after a failed or conflicting save. | +| Primary user and action | Author explicitly chooses whether to replace unsaved changes. | +| Content hierarchy | Keep the draft visible, announce the problem, then offer retry, separate copy, or saved version. | +| Navigation and controls | Existing recovery row; one native button and browser confirmation; no modal framework or new page. | +| Visual language | Existing Noto Sans, Canvas/CanvasText colors, wrapping controls, visible focus, and 44px minimum control height. | +| Required states | Save pending and read-only exclude restore; cancel preserves the draft; changed or rejected content stays recoverable; success focuses the editor. | +| Responsive behavior | The existing row wraps at 320px, respects forced colors, and is hidden in print. | +| Evidence used | Existing editor/recovery screens and cross-engine tests. The public [UIZZE catalogue](https://uizze.com/) did not yield inspectable matching screens on 2026-09-05; no external visual-reference match is claimed. | +| Forbidden defaults | New cards, gradients, custom dialog infrastructure, silent discard, or production-storage claims. | +| Acceptance criteria | Cancel, confirmation, native keyboard operation, reentrant clicks, changed local/saved content, malformed/schema-rejected saves, rich content, copy isolation, and unchanged storage on restore. | + +The host lets pending browser input reach the editor after confirmation, then rechecks the local draft and saved version before the synchronous replacement. A rejected restore notice remains visible even if an already-started capture later updates the blocked save state. Invalid JSON or unsupported document content does not replace the local draft. The example's store and final replacement are synchronous; a host with asynchronous storage must preserve local revision preconditions and independently revalidate its durable state. The browser entry's saved-state fault injection remains local test-host evidence, not a production write endpoint. These choices preserve the PRD/TRD/CONTRACTS host-ownership boundary and do not add a public Inkspan runtime contract. + +This is an English-language, single-document, synthetic example, not a localized production application or a latency benchmark. The existing repository limits each stored document to 65,536 code units. Recovery copy creation and registration are synchronous here; an asynchronous production host must preserve the copy and draft until authorized durable saving and registration are confirmed. All contents disappear on reload or tab close, as the screen states. Hosts must supply their own localization, authorization, transport, persistence, retention, and audit policy. + +The browser acceptance checks use actual editing and controls across Chromium, Firefox, and WebKit. They check queued latest-edit saving, confirmed and ambiguous failures before/after commit, reread-before-retry, fork isolation, same-turn retry admission, a competing write after reread, out-of-order digest settlement, read-only behavior, keyboard recovery, forced colors, 320px layout, print, and absence of external requests. A failed capture remains visibly unsaved even when an older save finishes; shortening the draft permits a new save. The synthetic saved-copy readback is test-host evidence only, never a production endpoint. + +The browser verifier reports the exact tarball SHA-256 and the observed passing, failing, skipped, and flaky test counts. It rejects empty, skipped, or flaky acceptance runs as well as failures. Browser asset access stays limited to the repository and installed package directories; the harness does not disable its filesystem guard to load fonts. + +From a repository checkout with the supported Node runtime, root and isolated browser-test dependencies, pinned Playwright browser revisions, and the dependencies required by the exact packed-artifact and local Office verification already installed, the currently implemented partial reference journey can be exercised with one command: + +Prepare Office with `uv sync --project office --extra test`. The handoff uses that project's `.venv` by default, not an unrelated Python environment on the shell path. A controlled CI host may explicitly select its prepared interpreter with `INKSPAN_OFFICE_PYTHON`. + +```sh +node examples/reference-host/verify-current-reference-journey.mjs +``` + +Its deterministic execution plan can be inspected without running the constituent checks: + +```sh +node examples/reference-host/verify-current-reference-journey.mjs --plan +``` + +The constituent deterministic reference-only fixture checks remain directly executable when a narrower causal check is needed: + +```sh +node examples/reference-host/synthetic-document-repository.mjs --self-test +node examples/reference-host/delayed-proposal.mjs --self-test +node examples/reference-host/autosave-view-model.mjs --self-test +node examples/reference-host/collaboration-provider-lifecycle.mjs --self-test +node examples/reference-host/verify-application-ssr.mjs --self-test +node examples/reference-host/verify-packed-office-journey.mjs --plan +node examples/reference-host/verify-packed-office-journey.mjs --self-test +node examples/reference-host/verify-browser-journey.mjs --plan +node examples/reference-host/verify-browser-journey.mjs --self-test +``` + +The repository test suite independently exercises those fixtures plus the host-authorization, hydration/native-form/single-flight/Office-handoff contracts, runs `verify-packed-artifact.mjs` to build, pack, install, and consume the exact current tarball in an isolated consumer, and runs `verify-application-ssr.mjs` to install the same exact current package authority into a buyer-shaped application SSR consumer. The application-level SSR verifier binds the real server-safe reference-host shell to the installed packed editor, requires deterministic host chrome, proves the client editor remains behind the hydration gate, and proves host submit authority is not invoked during server rendering. The one-command helper invokes `verify-packed-office-journey.mjs` after the packed-artifact consumer check and then `verify-browser-journey.mjs`, so the currently implemented partial buyer journey supplies exact tarball authority to both the bounded Office path and the reference-host-only Playwright acceptance. The cross-engine Playwright suite additionally builds and extracts the exact tarball, binds the package root plus public styles/font subpaths to that artifact, and exercises the repository browser-test host shell with real native-form hydration/submission, deterministic read-only and delayed-submission transitions, a 320px narrow-viewport journey, print media, forced colors, and no-unexpected-network acceptance across Chromium, Firefox, and WebKit. The read-only journey proves the editor remains readable while its hidden native form field, Save action, Reset action, and host submission callback all fail closed. The narrow-viewport journey requires the editor and Save/Reset controls to remain visible and in-viewport while rejecting document or body horizontal overflow. The print journey requires the toolbar to be absent from print presentation, checks the package print overflow/border/surface/content contract, and rejects external runtime requests. The suite also asserts stale-write conflict, failure-safe retry, ambiguous pre-commit and post-commit reconciliation, restore, fork isolation, lifecycle recovery, exact packed-package autosave observer-to-host-view-model integration, real host-created `Y.Doc`, provider replacement after ambiguous connect failure, reconnect/teardown, per-generation host authorization before provider construction, exact-synchronous-true admission, authorization-failure redaction, proposal-failure redaction, full public presentation-package behavior, native-form published-package/host-authority boundaries, the bounded React-free Markdown-to-DOCX-request mapping, deterministic exact-packed Office rendering/validation through `verify-packed-office-journey.mjs`, exact-packed application-shell SSR, and exact package authority. These checks do **not** yet satisfy #377's complete packed-artifact framework-application acceptance. The verified Office journey is intentionally bounded to the documented Markdown subset and local renderer contract; broader fidelity and production export operations remain host-owned acceptance work. + +The one-command helper above consolidates only the currently implemented reference-host checks. It is **not** final #377 acceptance: local interactive recovery, proposal and collaboration evidence still needs final clean-checkout verification against the integrated protected release artifact. + +## Deliberate omissions in this partial slice + +Still required before #377 can close: reconcile the complete reference-host application with its current dependency owners, integrate saved-version restore, stale-proposal handling, collaboration recovery, fork recovery, and bounded Office handoff into final acceptance, and pass the full clean-checkout journey against the protected artifact selected by #118. Next.js, production credentials, a live collaboration service, and a durable database are not requirements of #377; the issue explicitly permits replaceable synthetic adapters. Those production responsibilities remain with the embedding host. + +## Interactive local collaboration + +Open `examples/reference-host/browser-host.html?journey=collaboration` in the existing local browser harness. Start the session and edit **Your draft**; **Other local view** is a read-only second document receiving real local updates. Reconnect preserves both documents while replacing the connection. Disallowing the next connection demonstrates admission failure before construction; edits remain local until a later allowed reconnect. **Fail the next connection** demonstrates an indeterminate connection that must be replaced, not retried in place. Closing asks for confirmation, releases both documents, and returns keyboard focus to Start; starting again creates empty drafts. Application unmount also releases the resources. + +Add `&readOnly=1` to prevent editing while retaining connection and lifecycle controls. The screen reuses the recovery example's typography, spacing, focus, forced-color and print rules: explanation, live status, native connection controls, then the two labeled views stacked vertically. No new visual system is introduced. The local permission checkbox is a deterministic fixture for the **next** connection, not a production authorization or immediate revocation API. Hosts must replace it with real admission and revocation policy. Presence/cursors, remote transport, retries, authentication and persistence remain omitted and host-owned. + +The update forwarding uses the installed Yjs contract for [document updates](https://docs.yjs.dev/api/document-updates) and [document destruction](https://docs.yjs.dev/api/y.doc). The installed-package browser harness resolves both editor and host Yjs imports from the isolated consumer, avoiding a second document runtime. These are local integration checks, not proof of communication with another person or durable success. + +Do not use the synthetic repository, synthetic identifiers, deterministic proposal fixture, presentation projection, deterministic collaboration provider, host-authorization adapter, hydration gate, native-form example callback, or Office request helper as a production persistence, authentication, authorization, collaboration, model, deployment, export-authorization, storage, or distribution implementation. diff --git a/examples/reference-host/autosave-recovery-host.tsx b/examples/reference-host/autosave-recovery-host.tsx new file mode 100644 index 000000000..00188d94c --- /dev/null +++ b/examples/reference-host/autosave-recovery-host.tsx @@ -0,0 +1,322 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import { + CwlEditor, + createDocumentEnvelope, + restoreDocumentEnvelope, + serializeDocumentEnvelope, + type CwlEditorHandle, +} from '@contextualwisdomlab/cwl-editor'; +import { + createDocumentAutosaveSession, + type DocumentAutosaveSession, +} from '@contextualwisdomlab/cwl-editor/autosave'; +import { createAutosaveViewModel } from './autosave-view-model.mjs'; +import { createSyntheticDocumentRepository, MAX_DOCUMENT_CODE_UNITS } from './synthetic-document-repository.mjs'; + +type ReferenceRepository = ReturnType; +type ReferenceDocument = { documentId: string; repository: ReferenceRepository }; + +export interface AutosaveRecoveryHostProps extends ReferenceDocument { + readOnly?: boolean; + /** The embedding reference host retains ownership of newly saved copies. */ + onCopySaved: (copy: ReferenceDocument) => void; +} + +const messages: Record = { + loading: 'Opening the saved draft…', + loadFailed: 'The saved draft could not be opened. Nothing was changed.', + clean: 'All changes saved in this demo.', + preparing: 'Preparing changes…', + saving: 'Saving changes…', + queued: 'Saving; newer changes are waiting.', + conflict: 'Another version was saved. Your draft is still here.', + failed: 'Save not confirmed. Your draft is still here.', + retrying: 'Checking and saving your draft…', + recovered: 'Draft recovered and saved in this demo.', + copied: 'Separate copy saved. The original was not changed.', + restored: 'Saved version opened. You can continue editing.', + restoreChanged: 'The draft or saved version changed. Nothing was replaced; try again.', + restoreFailed: 'The saved version could not be opened. Your draft is still here.', + captureFailed: 'Changes could not be prepared. Your draft is still here; shorten it and try again.', + closed: 'Saving is paused. Your draft is still here.', +}; + +/** Reference-only, in-memory save recovery; no authentication or durable storage. */ +export function AutosaveRecoveryHost({ documentId, repository, readOnly = false, onCopySaved }: AutosaveRecoveryHostProps) { + const editorRef = useRef(null); + const [initialRead] = useState(() => { + try { return repository.read(documentId); } + catch { return null; } + }); + const confirmedDocumentRef = useRef(initialRead?.document ?? ''); + const editorReadyRef = useRef(false); + const sessionRef = useRef(null); + const activeDocumentRef = useRef({ documentId, repository }); + const viewModelRef = useRef(createAutosaveViewModel()); + const generationRef = useRef(0); + const capturePendingRef = useRef(false); + const recoveryInFlightRef = useRef(false); + const mountedRef = useRef(false); + const nextOutcomeRef = useRef('saved'); + const finishSaveRef = useRef<(() => void) | null>(null); + const attemptedDocumentRef = useRef(null); + const copyCountRef = useRef(0); + const [viewState, setViewState] = useState('loading'); + const [editorReady, setEditorReady] = useState(false); + const [capturePending, setCapturePending] = useState(false); + const [captureFailed, setCaptureFailed] = useState(false); + const [restoreNotice, setRestoreNotice] = useState(null); + const [recoveryInFlight, setRecoveryInFlight] = useState(false); + const [nextOutcome, setNextOutcome] = useState('saved'); + const [saveWaiting, setSaveWaiting] = useState(false); + + function beginSession(validator: string) { + viewModelRef.current = createAutosaveViewModel(); + const activeDocument = activeDocumentRef.current; + const nextSession = createDocumentAutosaveSession({ + initialStrongEntityTag: validator, + async save({ evidence, ifMatchStrongEntityTag }) { + const document = serializeDocumentEnvelope(evidence.envelope); + attemptedDocumentRef.current = document; + const outcome = nextOutcomeRef.current; + nextOutcomeRef.current = 'saved'; + if (mountedRef.current) setNextOutcome('saved'); + if (outcome === 'deferred') { + await new Promise((resolve) => { + finishSaveRef.current = resolve; + if (mountedRef.current) setSaveWaiting(true); + }); + finishSaveRef.current = null; + if (mountedRef.current) setSaveWaiting(false); + } + if (outcome === 'conflict') { + const current = activeDocument.repository.read(activeDocument.documentId); + activeDocument.repository.save({ + documentId: activeDocument.documentId, + ifMatch: current.validator, + document: serializeDocumentEnvelope(createDocumentEnvelope({ + type: 'doc', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Draft saved elsewhere.' }] }], + })), + }); + } + const result = activeDocument.repository.save({ + documentId: activeDocument.documentId, + document, + ifMatch: ifMatchStrongEntityTag, + outcome: outcome === 'deferred' || outcome === 'conflict' ? 'saved' : outcome, + }); + if (result.status === 'saved' && 'validator' in result) { + confirmedDocumentRef.current = document; + return { status: 'saved', nextStrongEntityTag: result.validator }; + } + return { status: 'conflict' }; + }, + onSnapshotChange(snapshot) { + if (!mountedRef.current || sessionRef.current !== nextSession) return; + const { state, blockedReason, activeStrongEntityTag, pendingStrongEntityTag, lastSavedStrongEntityTag } = snapshot; + setViewState(viewModelRef.current.observe({ + state, blockedReason, activeStrongEntityTag, pendingStrongEntityTag, lastSavedStrongEntityTag, + }).viewState); + }, + }); + sessionRef.current = nextSession; + return nextSession; + } + + useEffect(() => { + mountedRef.current = true; + if (initialRead) beginSession(initialRead.validator); + return () => { + mountedRef.current = false; + generationRef.current += 1; + void sessionRef.current?.close(); + finishSaveRef.current?.(); + }; + // The browser entry mounts one fixed document; navigation creates a new host. + }, []); + + async function queueCurrentDraft() { + const session = sessionRef.current; + if (readOnly || !editorReadyRef.current || !session || !editorRef.current) return false; + const generation = ++generationRef.current; + capturePendingRef.current = true; + setCapturePending(true); + setCaptureFailed(false); + setRestoreNotice(null); + let submitted = false; + try { + const evidence = await editorRef.current.getDocumentEnvelopeRevisionEvidence({ maxUtf8Bytes: MAX_DOCUMENT_CODE_UNITS, maxStringCodeUnits: MAX_DOCUMENT_CODE_UNITS }); + if (!evidence) throw new Error('Editor is not ready.'); + if (!mountedRef.current || generation !== generationRef.current || session !== sessionRef.current) return false; + const document = serializeDocumentEnvelope(evidence.envelope); + if (document.length > MAX_DOCUMENT_CODE_UNITS) throw new Error('Draft exceeds reference storage capacity.'); + capturePendingRef.current = false; + setCapturePending(false); + if (session.getSnapshot().state === 'idle' && document === confirmedDocumentRef.current) return false; + const pendingSave = session.enqueue(evidence); + submitted = true; + const result = await pendingSave; + return mountedRef.current && generation === generationRef.current && result.status === 'saved'; + } catch { + if (mountedRef.current && generation === generationRef.current && session === sessionRef.current) { + if (submitted) setViewState('failed'); + else setCaptureFailed(true); + } + return false; + } finally { + if (mountedRef.current && generation === generationRef.current) { + capturePendingRef.current = false; + setCapturePending(false); + } + } + } + + async function recoverDraft(asCopy: boolean) { + const session = sessionRef.current; + if (readOnly || !session || recoveryInFlightRef.current || capturePendingRef.current || session.getSnapshot().state !== 'blocked') return; + recoveryInFlightRef.current = true; + setRecoveryInFlight(true); + setRestoreNotice(null); + try { + const document = editorRef.current?.getDocumentEnvelopeJson({ maxUtf8Bytes: MAX_DOCUMENT_CODE_UNITS, maxStringCodeUnits: MAX_DOCUMENT_CODE_UNITS }); + if (!document || document.length > MAX_DOCUMENT_CODE_UNITS) throw new Error('Draft is not ready to save.'); + let activeDocument = activeDocumentRef.current; + let current = activeDocument.repository.read(activeDocument.documentId); + if (asCopy) { + const forkDocumentId = `reference-copy-${++copyCountRef.current}`; + const fork = activeDocument.repository.fork({ + documentId: activeDocument.documentId, forkDocumentId, ifMatch: current.validator, + }); + if (fork.status !== 'forked') { setViewState('conflict'); return; } + activeDocument = { documentId: forkDocumentId, repository: fork.repository }; + current = activeDocument.repository.read(forkDocumentId); + // ponytail: synchronous reference storage; an asynchronous host must retain + // this copy and its draft until its own durable save/registration completes. + const saved = activeDocument.repository.save({ documentId: forkDocumentId, document, ifMatch: current.validator }); + if (saved.status !== 'saved') { setViewState('failed'); return; } + onCopySaved(activeDocument); + current = activeDocument.repository.read(forkDocumentId); + } else if (current.validator !== session.getSnapshot().durableStrongEntityTag && current.document !== attemptedDocumentRef.current) { + setViewState('conflict'); + return; + } + // Pending revisions are still in the editor. Close the blocked coordinator + // before adopting a freshly read base; never resume stale work into a conflict. + generationRef.current += 1; + sessionRef.current = null; + void session.close(); + activeDocumentRef.current = activeDocument; + confirmedDocumentRef.current = current.document; + beginSession(current.validator); + if (current.document === document) { + setViewState(asCopy ? 'copied' : 'recovered'); + } else { + setViewState('retrying'); + if (await queueCurrentDraft()) setViewState('recovered'); + } + } catch { + if (mountedRef.current) setViewState('failed'); + } finally { + recoveryInFlightRef.current = false; + if (mountedRef.current) setRecoveryInFlight(false); + } + } + + async function restoreSavedDraft() { + const session = sessionRef.current; + const editor = editorRef.current; + if (readOnly || !editorReadyRef.current || !editor || !session || recoveryInFlightRef.current || capturePendingRef.current || session.getSnapshot().state !== 'blocked') return; + recoveryInFlightRef.current = true; + setRecoveryInFlight(true); + const limits = { maxUtf8Bytes: MAX_DOCUMENT_CODE_UNITS, maxJsonTextCodeUnits: MAX_DOCUMENT_CODE_UNITS, maxStringCodeUnits: MAX_DOCUMENT_CODE_UNITS }; + try { + const activeDocument = activeDocumentRef.current; + const saved = activeDocument.repository.read(activeDocument.documentId); + const draft = editor.getDocumentEnvelopeJson(limits); + const generation = generationRef.current; + if (!window.confirm('Use the saved version? This will replace your unsaved changes. Cancel to keep editing or save your draft as a separate copy instead.')) return; + // Let pending browser input reach the editor before checking the draft. + await Promise.resolve(); + if (!mountedRef.current || sessionRef.current !== session) return; + // ponytail: this store and replacement are synchronous. An asynchronous + // store needs local If-Match restore plus its own durable-state revalidation. + if (generationRef.current !== generation || + editor.getDocumentEnvelopeJson(limits) !== draft || + activeDocument.repository.read(activeDocument.documentId).validator !== saved.validator) { + setRestoreNotice('restoreChanged'); + return; + } + if (!editor.restoreDocumentEnvelope(saved.document, limits)) throw new Error('Editor is not ready.'); + generationRef.current += 1; + sessionRef.current = null; + void session.close(); + confirmedDocumentRef.current = editor.getDocumentEnvelopeJson(limits); + beginSession(saved.validator); + setRestoreNotice(null); + setViewState('restored'); + editor.focus(); + } catch { + if (mountedRef.current) setRestoreNotice('restoreFailed'); + } finally { + recoveryInFlightRef.current = false; + if (mountedRef.current) setRecoveryInFlight(false); + } + } + + const blocked = !captureFailed && (viewState === 'conflict' || viewState === 'failed'); + const displayedState = captureFailed ? 'captureFailed' : restoreNotice ?? (capturePending && !blocked ? 'preparing' : viewState); + return ( +
+

Save and recover a draft

+

Practice saving in this tab. This demo uses memory only; closing or reloading it removes every draft and copy.

+ { void queueCurrentDraft(); }} + onReady={(editor) => { + if (!mountedRef.current) return; + try { + if (!initialRead) throw new Error('Saved draft is unavailable.'); + restoreDocumentEnvelope(editor, initialRead.document, { + maxUtf8Bytes: MAX_DOCUMENT_CODE_UNITS, + maxJsonTextCodeUnits: MAX_DOCUMENT_CODE_UNITS, + maxStringCodeUnits: MAX_DOCUMENT_CODE_UNITS, + }); + confirmedDocumentRef.current = serializeDocumentEnvelope(createDocumentEnvelope(editor.getJSON())); + editorReadyRef.current = true; + setEditorReady(true); + setViewState('clean'); + } catch { setViewState('loadFailed'); } + }} /> + {messages[displayedState] ?? messages.closed} + {blocked && !readOnly && ( +
+ {viewState === 'failed' && } + + +
+ )} +
+ Demo controls + + {saveWaiting && } +
+
+ ); +} diff --git a/examples/reference-host/autosave-recovery.css b/examples/reference-host/autosave-recovery.css new file mode 100644 index 000000000..9e5a40a9a --- /dev/null +++ b/examples/reference-host/autosave-recovery.css @@ -0,0 +1,43 @@ +.reference-recovery { + max-inline-size: 52rem; + margin-inline: auto; + padding: 1rem; + color: CanvasText; + background: Canvas; + font-family: 'Noto Sans', system-ui, sans-serif; + line-height: 1.5; + overflow-wrap: anywhere; +} + +.reference-recovery output { + display: block; + margin-block: 1rem; +} + +.reference-recovery-actions, +.reference-recovery-controls { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin-block: 1rem; +} + +.reference-recovery-controls { min-inline-size: 0; } +.reference-recovery-controls label { min-inline-size: 0; } +.reference-recovery-controls select { display: block; } +.reference-recovery-actions > button, +.reference-recovery-controls button, +.reference-recovery-controls select { + max-inline-size: 100%; + min-block-size: 2.75rem; + padding: 0.5rem; + font: inherit; +} +.reference-recovery-actions :focus-visible, +.reference-recovery-controls :focus-visible { outline: 2px solid Highlight; outline-offset: 2px; } + +@media print { + .reference-recovery-actions, + .reference-recovery-controls { display: none; } + .reference-recovery { max-inline-size: none; padding: 0; } +} diff --git a/examples/reference-host/autosave-view-model.mjs b/examples/reference-host/autosave-view-model.mjs new file mode 100644 index 000000000..4ae25d8d5 --- /dev/null +++ b/examples/reference-host/autosave-view-model.mjs @@ -0,0 +1,311 @@ +const AUTOSAVE_STATES = new Set([ + 'idle', + 'saving', + 'blocked', + 'closing', + 'closed', +]); +const BLOCKED_REASONS = new Set(['conflict', 'failure']); +const MAX_STRONG_ENTITY_TAG_CODE_UNITS = 256; +const STRONG_ENTITY_TAG_PATTERN = /^"[\x21\x23-\x7e\x80-\xff]*"$/u; +const SNAPSHOT_KEYS = [ + 'state', + 'blockedReason', + 'activeStrongEntityTag', + 'pendingStrongEntityTag', + 'lastSavedStrongEntityTag', +]; + +/** Marker used by repository contracts to prevent this host fixture becoming runtime authority. */ +export const REFERENCE_ONLY = true; + +function requireNullableStrongEntityTag(value, label) { + if ( + value !== null && + (typeof value !== 'string' || + value.length > MAX_STRONG_ENTITY_TAG_CODE_UNITS || + !STRONG_ENTITY_TAG_PATTERN.test(value)) + ) { + throw new TypeError(`${label} is invalid.`); + } + return value; +} + +function snapshotData(source) { + try { + if (typeof source !== 'object' || source === null) { + throw new TypeError('autosave snapshot is invalid.'); + } + const values = Object.create(null); + for (const key of SNAPSHOT_KEYS) { + const descriptor = Object.getOwnPropertyDescriptor(source, key); + if ( + descriptor === undefined || + !Object.prototype.hasOwnProperty.call(descriptor, 'value') + ) { + throw new TypeError('autosave snapshot is invalid.'); + } + values[key] = descriptor.value; + } + const ownKeys = Reflect.ownKeys(source); + if ( + ownKeys.length !== SNAPSHOT_KEYS.length || + ownKeys.some( + (key) => typeof key !== 'string' || !SNAPSHOT_KEYS.includes(key), + ) + ) { + throw new TypeError('autosave snapshot is invalid.'); + } + return values; + } catch { + throw new TypeError('autosave snapshot is invalid.'); + } +} + +function readSnapshot(source) { + const snapshot = snapshotData(source); + if ( + !AUTOSAVE_STATES.has(snapshot.state) || + (snapshot.blockedReason !== null && + !BLOCKED_REASONS.has(snapshot.blockedReason)) + ) { + throw new TypeError('autosave snapshot is invalid.'); + } + + if ( + (snapshot.state === 'blocked' && snapshot.blockedReason === null) || + (snapshot.state !== 'blocked' && snapshot.blockedReason !== null) + ) { + throw new TypeError('autosave snapshot lifecycle is inconsistent.'); + } + + return Object.freeze({ + state: snapshot.state, + blockedReason: snapshot.blockedReason, + activeStrongEntityTag: requireNullableStrongEntityTag( + snapshot.activeStrongEntityTag, + 'activeStrongEntityTag', + ), + pendingStrongEntityTag: requireNullableStrongEntityTag( + snapshot.pendingStrongEntityTag, + 'pendingStrongEntityTag', + ), + lastSavedStrongEntityTag: requireNullableStrongEntityTag( + snapshot.lastSavedStrongEntityTag, + 'lastSavedStrongEntityTag', + ), + }); +} + +function presentation(viewState) { + return Object.freeze({ + viewState, + messageKey: `referenceHost.autosave.${viewState}`, + busy: + viewState === 'saving' || + viewState === 'queued' || + viewState === 'retrying', + blocked: viewState === 'conflict' || viewState === 'failed', + canRetry: viewState === 'conflict' || viewState === 'failed', + }); +} + +/** + * Create one host-owned projection of Inkspan autosave lifecycle transitions. + * + * `observe()` consumes only programmatic queue/session snapshots. Snapshot fields + * must be an exact own-data-property shape so presentation never invokes + * caller-owned accessors or silently admits authority-looking metadata. Strong + * entity tags are validated as bounded RFC 9110 strong entity-tags before + * projection so untrusted snapshot metadata cannot masquerade as a durable + * validator or allocate unbounded retained strings at this reference boundary. + * A blocked to saving transition is presented as retrying, and only a later idle + * transition after that observed retry is presented as recovered. A blocked to + * idle transition without an intervening save returns to clean instead of + * manufacturing recovery evidence. The projection never returns local or durable + * validators; hosts localize `messageKey` and keep authenticated recovery controls + * outside Inkspan. + */ +export function createAutosaveViewModel() { + let retryPending = false; + let retryInFlight = false; + + function clearRetryEvidence() { + retryPending = false; + retryInFlight = false; + } + + function observe(snapshot) { + const current = readSnapshot(snapshot); + + if (current.state === 'blocked') { + retryPending = true; + retryInFlight = false; + return presentation( + current.blockedReason === 'conflict' ? 'conflict' : 'failed', + ); + } + if (current.state === 'closing') { + clearRetryEvidence(); + return presentation('closing'); + } + if (current.state === 'closed') { + clearRetryEvidence(); + return presentation('closed'); + } + if (current.state === 'saving' && (retryPending || retryInFlight)) { + retryPending = false; + retryInFlight = true; + return presentation('retrying'); + } + if (current.state === 'saving' && current.pendingStrongEntityTag !== null) { + return presentation('queued'); + } + if (current.state === 'saving') return presentation('saving'); + if (retryInFlight) { + clearRetryEvidence(); + return presentation('recovered'); + } + retryPending = false; + return presentation('clean'); + } + + return Object.freeze({ observe }); +} + +function snapshot({ + state, + blockedReason = null, + activeStrongEntityTag = null, + pendingStrongEntityTag = null, + lastSavedStrongEntityTag = null, +}) { + return Object.freeze({ + state, + blockedReason, + activeStrongEntityTag, + pendingStrongEntityTag, + lastSavedStrongEntityTag, + }); +} + +function runSelfTest() { + const steady = createAutosaveViewModel(); + const clean = steady.observe(snapshot({ state: 'idle' })).viewState; + const saving = steady.observe( + snapshot({ + state: 'saving', + activeStrongEntityTag: '"local-active"', + }), + ).viewState; + const queued = steady.observe( + snapshot({ + state: 'saving', + activeStrongEntityTag: '"local-active"', + pendingStrongEntityTag: '"local-pending"', + }), + ).viewState; + + const recovery = createAutosaveViewModel(); + const conflict = recovery.observe( + snapshot({ state: 'blocked', blockedReason: 'conflict' }), + ).viewState; + const retrying = recovery.observe( + snapshot({ + state: 'saving', + activeStrongEntityTag: '"local-retry"', + }), + ).viewState; + const recovered = recovery.observe( + snapshot({ + state: 'idle', + lastSavedStrongEntityTag: '"local-saved"', + }), + ).viewState; + + const failed = createAutosaveViewModel().observe( + snapshot({ state: 'blocked', blockedReason: 'failure' }), + ).viewState; + const closing = createAutosaveViewModel().observe( + snapshot({ state: 'closing' }), + ).viewState; + const closed = createAutosaveViewModel().observe( + snapshot({ state: 'closed' }), + ).viewState; + + process.stdout.write( + `${JSON.stringify({ + clean, + closed, + closing, + conflict, + failed, + queued, + recovered, + retrying, + saving, + })}\n`, + ); +} + +function runInvalidValidatorSelfTest() { + function observeError(candidate) { + try { + createAutosaveViewModel().observe(candidate); + return null; + } catch (error) { + return error instanceof Error ? error.message : 'unexpected error'; + } + } + + const activeError = observeError( + snapshot({ state: 'saving', activeStrongEntityTag: '' }), + ); + const pendingError = observeError( + snapshot({ + state: 'saving', + activeStrongEntityTag: '"local-active"', + pendingStrongEntityTag: '', + }), + ); + const lastSavedError = observeError( + snapshot({ state: 'idle', lastSavedStrongEntityTag: '' }), + ); + + process.stdout.write( + `${JSON.stringify({ activeError, lastSavedError, pendingError })}\n`, + ); +} + +function runHostileAccessorSelfTest() { + let getterCalls = 0; + let error = null; + const hostile = { + blockedReason: null, + activeStrongEntityTag: null, + pendingStrongEntityTag: null, + lastSavedStrongEntityTag: null, + }; + Object.defineProperty(hostile, 'state', { + enumerable: true, + get() { + getterCalls += 1; + return 'idle'; + }, + }); + try { + createAutosaveViewModel().observe(hostile); + } catch (failure) { + error = failure instanceof Error ? failure.message : 'unexpected error'; + } + process.stdout.write(`${JSON.stringify({ error, getterCalls })}\n`); +} + +const commandArguments = typeof process === 'undefined' ? [] : process.argv; +if (commandArguments.includes('--invalid-validator-self-test')) { + runInvalidValidatorSelfTest(); +} else if (commandArguments.includes('--hostile-accessor-self-test')) { + runHostileAccessorSelfTest(); +} else if (commandArguments.includes('--self-test')) { + runSelfTest(); +} diff --git a/examples/reference-host/browser-host.html b/examples/reference-host/browser-host.html new file mode 100644 index 000000000..86db56edf --- /dev/null +++ b/examples/reference-host/browser-host.html @@ -0,0 +1,12 @@ + + + + + + Inkspan reference host + + +

Inkspan reference host

Loading buyer editor
+ + + diff --git a/examples/reference-host/browser-host.tsx b/examples/reference-host/browser-host.tsx new file mode 100644 index 000000000..6fca39cc9 --- /dev/null +++ b/examples/reference-host/browser-host.tsx @@ -0,0 +1,100 @@ +import './presentation-full.css'; +import './autosave-recovery.css'; +import { hydrateRoot } from 'react-dom/client'; +import { createDocumentEnvelope, serializeDocumentEnvelope } from '@contextualwisdomlab/cwl-editor'; + +import { ReferenceHostApp } from './reference-host-app.js'; +import { ReferenceHostHydrationGate } from './hydration-gate.js'; +import { AutosaveRecoveryHost } from './autosave-recovery-host.js'; +import { DelayedProposalHost } from './delayed-proposal-host.js'; +import { LocalCollaborationHost } from './local-collaboration-host.js'; +import { createSyntheticDocumentRepository } from './synthetic-document-repository.mjs'; + +declare global { + interface Window { + referenceHostSubmissions: string[]; + referenceHostResolveSubmission?: () => void; + referenceHostSavedDocuments: () => { original: string; originalValidator: string; copies: string[] }; + referenceHostSaveElsewhere: (document: string) => void; + referenceHostCollaborationEvents: string[]; + referenceHostUnmount: () => void; + } +} + +const submissions: string[] = []; +window.referenceHostSubmissions = submissions; +window.referenceHostCollaborationEvents = []; +const reportCollaborationEvent = (event: string) => { window.referenceHostCollaborationEvents.push(event); }; + +const root = document.getElementById('reference-host-root'); +if (!root) { + throw new Error('Reference host root is missing.'); +} + +const searchParams = new URLSearchParams(window.location.search); +const readOnly = searchParams.get('readOnly') === '1'; +const recoveryJourney = searchParams.get('journey') === 'recovery'; +const proposalJourney = searchParams.get('journey') === 'proposal'; +const collaborationJourney = searchParams.get('journey') === 'collaboration'; +const documentId = 'reference-draft'; +const savedDraft = searchParams.get('savedDraft'); +const initialEnvelope = createDocumentEnvelope({ + type: 'doc', content: savedDraft === '1' ? [ + { type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: 'Saved heading' }] }, + { type: 'paragraph', content: [{ type: 'text', text: 'Previously saved draft', marks: [{ type: 'bold' }] }] }, + ] : [{ type: 'paragraph', content: [{ type: 'text', text: 'Draft' }] }], +}); +const repository = createSyntheticDocumentRepository({ + documentId, + initialDocument: savedDraft === 'invalid' ? 'Invalid stored draft' + : savedDraft === '1' ? JSON.stringify(initialEnvelope, null, 2) + : serializeDocumentEnvelope(initialEnvelope), +}); +const savedCopies: Array<{ documentId: string; repository: typeof repository }> = []; +window.referenceHostSavedDocuments = () => { + const original = repository.read(documentId); + return { original: original.document, originalValidator: original.validator, + copies: savedCopies.map((copy) => copy.repository.read(copy.documentId).document) }; +}; +// Local test-host fault injection; no transport or production storage authority. +window.referenceHostSaveElsewhere = (document) => { + repository.save({ documentId, document, ifMatch: repository.read(documentId).validator }); +}; +const deferSubmission = searchParams.get('deferSubmission') === '1'; +const controlMode = + searchParams.get('controlMode') === 'controlled' + ? 'controlled' + : 'uncontrolled'; +let resolveDeferredSubmission: (() => void) | undefined; + +window.referenceHostResolveSubmission = () => { + resolveDeferredSubmission?.(); +}; + +const hostRoot = hydrateRoot( + root, + recoveryJourney || proposalJourney || collaborationJourney ? ( +
+

Inkspan reference host

+ ( + collaborationJourney ? : proposalJourney ? : { savedCopies.push(copy); }} /> + )} /> +
+ ) : { + submissions.push(messageBody); + if (deferSubmission) { + await new Promise((resolve) => { + resolveDeferredSubmission = resolve; + }); + resolveDeferredSubmission = undefined; + } + }} + readOnly={readOnly} + />, +); +// Local test-host lifecycle control; does not enter the published package. +window.referenceHostUnmount = () => hostRoot.unmount(); diff --git a/examples/reference-host/collaboration-provider-lifecycle.mjs b/examples/reference-host/collaboration-provider-lifecycle.mjs new file mode 100644 index 000000000..6a4c68f23 --- /dev/null +++ b/examples/reference-host/collaboration-provider-lifecycle.mjs @@ -0,0 +1,571 @@ +import { Doc } from 'yjs'; + +const MAX_CONTEXT_CODE_UNITS = 256; +const MAX_RESOURCE_PROTOTYPE_DEPTH = 64; +const INITIALIZATION_FAILURE = 'collaboration lifecycle initialization failed.'; +const CONNECTION_FAILURE = 'collaboration lifecycle connection failed.'; +const RECONNECT_FAILURE = 'collaboration lifecycle reconnect failed.'; +const TEARDOWN_FAILURE = 'collaboration lifecycle teardown failed.'; +const RESOURCE_VALIDATION_ERRORS = new WeakSet(); + +/** Marker used by repository contracts to keep this lifecycle example out of runtime authority. */ +export const REFERENCE_ONLY = true; + +class ResourceValidationError extends TypeError { + constructor(message) { + super(message); + this.name = 'ResourceValidationError'; + RESOURCE_VALIDATION_ERRORS.add(this); + } +} + +function requireContextString(value, label) { + if ( + typeof value !== 'string' || + value.length === 0 || + value.length > MAX_CONTEXT_CODE_UNITS + ) { + throw new TypeError(`${label} is invalid.`); + } + return value; +} + +function requireFactory(value, label) { + if (typeof value !== 'function') { + throw new TypeError(`${label} is invalid.`); + } + return value; +} + +function readOwnDataRecord(source, keys, message) { + try { + if (typeof source !== 'object' || source === null) { + throw new TypeError(message); + } + const values = Object.create(null); + for (const key of keys) { + const descriptor = Object.getOwnPropertyDescriptor(source, key); + if ( + descriptor === undefined || + !Object.prototype.hasOwnProperty.call(descriptor, 'value') + ) { + throw new TypeError(message); + } + values[key] = descriptor.value; + } + const ownKeys = Reflect.ownKeys(source); + if ( + ownKeys.length !== keys.length || + ownKeys.some( + (key) => + typeof key !== 'string' || + !keys.some((candidate) => candidate === key), + ) + ) { + throw new TypeError(message); + } + return values; + } catch { + throw new TypeError(message); + } +} + +function findDataMethod(source, key, message) { + try { + if ((typeof source !== 'object' && typeof source !== 'function') || source === null) { + throw new ResourceValidationError(message); + } + let cursor = source; + let depth = 0; + while (cursor !== null) { + if (depth >= MAX_RESOURCE_PROTOTYPE_DEPTH) { + throw new ResourceValidationError(message); + } + const descriptor = Object.getOwnPropertyDescriptor(cursor, key); + if (descriptor !== undefined) { + if ( + !Object.prototype.hasOwnProperty.call(descriptor, 'value') || + typeof descriptor.value !== 'function' + ) { + throw new ResourceValidationError(message); + } + return descriptor.value; + } + cursor = Object.getPrototypeOf(cursor); + depth += 1; + } + throw new ResourceValidationError(message); + } catch { + throw new ResourceValidationError(message); + } +} + +function requireDocument(value) { + const message = 'documentFactory returned an invalid document.'; + return Object.freeze({ + value, + destroy: findDataMethod(value, 'destroy', message), + }); +} + +function requireProvider(value) { + const message = 'providerFactory returned an invalid provider.'; + return Object.freeze({ + value, + connect: findDataMethod(value, 'connect', message), + disconnect: findDataMethod(value, 'disconnect', message), + destroy: findDataMethod(value, 'destroy', message), + }); +} + +function initializationFailure() { + return new Error(INITIALIZATION_FAILURE); +} + +function connectionFailure() { + return new Error(CONNECTION_FAILURE); +} + +function reconnectFailure() { + return new Error(RECONNECT_FAILURE); +} + +function teardownFailure() { + return new Error(TEARDOWN_FAILURE); +} + +function consumeNonVoidResult(result) { + if (result === undefined) return false; + void Promise.resolve(result).catch(() => undefined); + return true; +} + +/** + * Create one provider-neutral collaboration lifecycle owned entirely by the host. + * + * The host supplies the document/provider factories and authorized room/actor + * context. Inkspan receives the resulting stable document/provider references; + * it does not create, reconnect, disconnect, or destroy either resource. Option + * fields and resource methods are captured from data descriptors so lifecycle + * validation never executes accessor-backed host objects. Initial document and + * provider callback failures are payload-redacted, acquired documents unwind on + * initial provider failure, reconnect provider-construction failures remain + * retryable, and connect failures quarantine the indeterminate provider until + * reconnect/dispose tears it down. The reference lifecycle is intentionally + * synchronous: any non-void lifecycle callback result is treated as indeterminate, + * its promise/thenable settlement is consumed, and the operation fails closed + * instead of reporting false success. Cleanup failures do not prevent remaining + * teardown attempts. A failed provider or document destruction keeps only the + * incomplete cleanup live so a later dispose() retries it without repeating + * already-successful teardown. Once disposal starts, connect/reconnect stay closed + * while cleanup is pending. + */ +export function createHostCollaborationLifecycle(source) { + const options = readOwnDataRecord( + source, + ['documentFactory', 'providerFactory', 'roomId', 'actorId'], + 'collaboration options are invalid.', + ); + const createDocument = requireFactory(options.documentFactory, 'documentFactory'); + const createProvider = requireFactory(options.providerFactory, 'providerFactory'); + const boundedRoomId = requireContextString(options.roomId, 'roomId'); + const boundedActorId = requireContextString(options.actorId, 'actorId'); + let documentCandidate; + try { + documentCandidate = createDocument(); + } catch { + throw initializationFailure(); + } + const documentResource = requireDocument(documentCandidate); + const document = documentResource.value; + + let providerGeneration = 0; + let providerResource = null; + let connected = false; + let providerConnectionIndeterminate = false; + let documentDestroyed = false; + let disposeStarted = false; + let disposed = false; + + function makeProvider(privateFailure) { + providerGeneration += 1; + let candidate; + try { + candidate = createProvider({ + document, + roomId: boundedRoomId, + actorId: boundedActorId, + generation: providerGeneration, + }); + } catch { + throw privateFailure(); + } + providerResource = requireProvider(candidate); + connected = false; + providerConnectionIndeterminate = false; + } + + function requireLive() { + if (disposed) { + throw new Error('collaboration lifecycle is disposed.'); + } + if (disposeStarted) { + throw teardownFailure(); + } + } + + function connect() { + requireLive(); + if (providerConnectionIndeterminate) throw connectionFailure(); + if (connected) return false; + let connectionResult; + try { + connectionResult = providerResource.connect.call(providerResource.value); + } catch { + providerConnectionIndeterminate = true; + throw connectionFailure(); + } + if (consumeNonVoidResult(connectionResult)) { + providerConnectionIndeterminate = true; + throw connectionFailure(); + } + connected = true; + return true; + } + + function teardownProvider() { + const resource = providerResource; + if (resource === null) return; + let failed = false; + let destroyFailed = false; + if (connected || providerConnectionIndeterminate) { + connected = false; + try { + const disconnectResult = resource.disconnect.call(resource.value); + if (consumeNonVoidResult(disconnectResult)) { + failed = true; + providerConnectionIndeterminate = true; + } else { + providerConnectionIndeterminate = false; + } + } catch { + failed = true; + providerConnectionIndeterminate = true; + } + } + try { + const destroyResult = resource.destroy.call(resource.value); + if (consumeNonVoidResult(destroyResult)) { + failed = true; + destroyFailed = true; + } + } catch { + failed = true; + destroyFailed = true; + } + if (!destroyFailed) { + providerResource = null; + providerConnectionIndeterminate = false; + } + if (failed) throw teardownFailure(); + } + + function reconnect() { + requireLive(); + teardownProvider(); + makeProvider(reconnectFailure); + connect(); + return getSnapshot(); + } + + function dispose() { + if (disposed) return false; + disposeStarted = true; + let failed = false; + try { + teardownProvider(); + } catch { + failed = true; + } + if (!documentDestroyed) { + try { + const destroyResult = documentResource.destroy.call(document); + if (consumeNonVoidResult(destroyResult)) { + failed = true; + } else { + documentDestroyed = true; + } + } catch { + failed = true; + } + } + disposed = providerResource === null && documentDestroyed; + if (failed || !disposed) throw teardownFailure(); + return true; + } + + function getSnapshot() { + return Object.freeze({ + status: disposed ? 'disposed' : connected ? 'connected' : 'disconnected', + providerGeneration, + }); + } + + try { + makeProvider(initializationFailure); + } catch (error) { + let cleanupFailed = false; + try { + cleanupFailed = consumeNonVoidResult(documentResource.destroy.call(document)); + } catch { + cleanupFailed = true; + } + if (RESOURCE_VALIDATION_ERRORS.has(error) && !cleanupFailed) { + throw error; + } + throw initializationFailure(); + } + + return Object.freeze({ + document, + connect, + reconnect, + dispose, + getSnapshot, + }); +} + +function runSelfTest() { + const events = []; + let providerCounter = 0; + let firstProviderDocument = null; + let sameDocumentAcrossReconnect = true; + const lifecycle = createHostCollaborationLifecycle({ + documentFactory() { + events.push('document:create'); + const document = new Doc(); + document.getText('document').insert(0, 'Buyer draft'); + document.on('destroy', () => { + events.push('document:destroy'); + }); + return document; + }, + providerFactory({ document }) { + providerCounter += 1; + const generation = providerCounter; + if (!(document instanceof Doc)) { + throw new TypeError('reference host must supply a Y.Doc.'); + } + if (firstProviderDocument === null) { + firstProviderDocument = document; + } else if (document !== firstProviderDocument) { + sameDocumentAcrossReconnect = false; + } + events.push(`provider:create:${generation}`); + return { + connect() { + events.push(`provider:connect:${generation}`); + }, + disconnect() { + events.push(`provider:disconnect:${generation}`); + }, + destroy() { + events.push(`provider:destroy:${generation}`); + }, + }; + }, + roomId: 'reference-room', + actorId: 'reference-actor', + }); + + lifecycle.connect(); + lifecycle.reconnect(); + const hostDocumentIsYjs = lifecycle.document instanceof Doc; + const yjsText = lifecycle.document.getText('document').toString(); + lifecycle.dispose(); + lifecycle.dispose(); + + process.stdout.write( + `${JSON.stringify({ + events, + hostDocumentIsYjs, + ...lifecycle.getSnapshot(), + sameDocumentAcrossReconnect, + yjsText, + })}\n`, + ); +} + +function runHostileAccessorSelfTest() { + let optionsGetterCalls = 0; + let optionsError = null; + const hostileOptions = { + providerFactory() { + return { connect() {}, disconnect() {}, destroy() {} }; + }, + roomId: 'reference-room', + actorId: 'reference-actor', + }; + Object.defineProperty(hostileOptions, 'documentFactory', { + enumerable: true, + get() { + optionsGetterCalls += 1; + return () => ({ destroy() {} }); + }, + }); + try { + createHostCollaborationLifecycle(hostileOptions); + } catch (error) { + optionsError = error instanceof Error ? error.message : 'unexpected error'; + } + + let documentGetterCalls = 0; + let documentError = null; + try { + createHostCollaborationLifecycle({ + documentFactory() { + const hostileDocument = {}; + Object.defineProperty(hostileDocument, 'destroy', { + enumerable: true, + get() { + documentGetterCalls += 1; + return () => undefined; + }, + }); + return hostileDocument; + }, + providerFactory() { + return { connect() {}, disconnect() {}, destroy() {} }; + }, + roomId: 'reference-room', + actorId: 'reference-actor', + }); + } catch (error) { + documentError = error instanceof Error ? error.message : 'unexpected error'; + } + + let providerGetterCalls = 0; + let providerError = null; + try { + createHostCollaborationLifecycle({ + documentFactory() { + return { destroy() {} }; + }, + providerFactory() { + const hostileProvider = { disconnect() {}, destroy() {} }; + Object.defineProperty(hostileProvider, 'connect', { + enumerable: true, + get() { + providerGetterCalls += 1; + return () => undefined; + }, + }); + return hostileProvider; + }, + roomId: 'reference-room', + actorId: 'reference-actor', + }); + } catch (error) { + providerError = error instanceof Error ? error.message : 'unexpected error'; + } + + process.stdout.write( + `${JSON.stringify({ + documentError, + documentGetterCalls, + optionsError, + optionsGetterCalls, + providerError, + providerGetterCalls, + })}\n`, + ); +} + +function runInitializationFailureSelfTest() { + const privateCause = 'private-provider-construction-cause'; + const events = []; + let error = null; + try { + createHostCollaborationLifecycle({ + documentFactory() { + events.push('document:create'); + return { + destroy() { + events.push('document:destroy'); + }, + }; + }, + providerFactory() { + events.push('provider:create'); + throw new Error(privateCause); + }, + roomId: 'reference-room', + actorId: 'reference-actor', + }); + } catch (failure) { + error = failure instanceof Error ? failure.message : 'unexpected error'; + } + process.stdout.write( + `${JSON.stringify({ + error, + events, + leakedPrivateCause: typeof error === 'string' && error.includes(privateCause), + })}\n`, + ); +} + +function runCleanupFailureSelfTest() { + const privateCause = 'private-provider-disconnect-cause'; + const events = []; + const lifecycle = createHostCollaborationLifecycle({ + documentFactory() { + return { + destroy() { + events.push('document:destroy'); + }, + }; + }, + providerFactory() { + return { + connect() { + events.push('provider:connect'); + }, + disconnect() { + events.push('provider:disconnect'); + throw new Error(privateCause); + }, + destroy() { + events.push('provider:destroy'); + }, + }; + }, + roomId: 'reference-room', + actorId: 'reference-actor', + }); + lifecycle.connect(); + let error = null; + try { + lifecycle.dispose(); + } catch (failure) { + error = failure instanceof Error ? failure.message : 'unexpected error'; + } + process.stdout.write( + `${JSON.stringify({ + error, + events, + leakedPrivateCause: typeof error === 'string' && error.includes(privateCause), + status: lifecycle.getSnapshot().status, + })}\n`, + ); +} + +const commandArguments = typeof process === 'undefined' ? [] : process.argv; +if (commandArguments.includes('--cleanup-failure-self-test')) { + runCleanupFailureSelfTest(); +} else if (commandArguments.includes('--initialization-failure-self-test')) { + runInitializationFailureSelfTest(); +} else if (commandArguments.includes('--hostile-accessor-self-test')) { + runHostileAccessorSelfTest(); +} else if (commandArguments.includes('--self-test')) { + runSelfTest(); +} diff --git a/examples/reference-host/delayed-proposal-host.tsx b/examples/reference-host/delayed-proposal-host.tsx new file mode 100644 index 000000000..49ef1804d --- /dev/null +++ b/examples/reference-host/delayed-proposal-host.tsx @@ -0,0 +1,115 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import { CwlEditor, createDocumentEnvelope, serializeDocumentEnvelope, type CwlEditorHandle } from '@contextualwisdomlab/cwl-editor'; +import { createDelayedProposal, applyDelayedProposal } from './delayed-proposal.mjs'; +import { MAX_DOCUMENT_CODE_UNITS } from './synthetic-document-repository.mjs'; + +// ponytail: fixed local fixture; a model-backed host must preview its actual +// validated candidate before requesting approval, never this example text. +const suggestionText = 'An example suggestion for this draft.'; +const limits = { maxUtf8Bytes: MAX_DOCUMENT_CODE_UNITS, maxJsonTextCodeUnits: MAX_DOCUMENT_CODE_UNITS, maxStringCodeUnits: MAX_DOCUMENT_CODE_UNITS }; +const messages: Record = { + idle: 'Edit your draft or prepare an example suggestion.', + preparing: 'Preparing a local suggestion…', + ready: 'Suggestion ready. Review it before applying.', + applying: 'Checking your draft before applying…', + applied: 'Suggestion applied. Nothing has been saved.', + conflict: 'Your draft changed. Prepare a new suggestion.', + failed: 'The suggestion could not be used. Your draft is still here.', +}; + +/** Local, provider-free proposal review; no model call or durable save. */ +export function DelayedProposalHost({ readOnly = false }: { readOnly?: boolean }) { + const editorRef = useRef(null); + const mountedRef = useRef(false); + const busyRef = useRef(false); + const [ready, setReady] = useState(false); + const [busy, setBusy] = useState(false); + const [status, setStatus] = useState('idle'); + const [proposal, setProposal] = useState> | null>(null); + + useEffect(() => { + mountedRef.current = true; + return () => { mountedRef.current = false; }; + }, []); + + async function prepareSuggestion() { + const editor = editorRef.current; + if (readOnly || !ready || !editor || busyRef.current) return; + busyRef.current = true; + setBusy(true); + setProposal(null); + setStatus('preparing'); + try { + const evidence = await editor.getDocumentEnvelopeRevisionEvidence(limits); + if (!evidence) throw new Error('Editor is unavailable.'); + const candidate = await createDelayedProposal({ + expectedRevision: evidence.revision.strongEntityTag, + replacement: serializeDocumentEnvelope(createDocumentEnvelope({ + type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: suggestionText }] }], + })), + }); + if (mountedRef.current) { setProposal(candidate); setStatus('ready'); } + } catch { + if (mountedRef.current) setStatus('failed'); + } finally { + busyRef.current = false; + if (mountedRef.current) setBusy(false); + } + } + + async function applySuggestion() { + const editor = editorRef.current; + if (readOnly || !ready || !editor || !proposal || busyRef.current) return; + busyRef.current = true; + setBusy(true); + let moved = false; + try { + if (!window.confirm('Replace the draft with this suggestion? Your current text will be replaced.')) return; + setStatus('applying'); + const current = await editor.getDocumentEnvelopeRevision(limits); + if (!current || !mountedRef.current) return; + const result = await applyDelayedProposal({ + proposal, currentRevision: current.strongEntityTag, + async apply(replacement: string) { + const restored = await editor.restoreDocumentEnvelopeIfMatch(proposal.expectedRevision, replacement, limits); + if (restored?.status !== 'restored') { + moved = true; + throw new Error('Draft changed.'); + } + }, + }); + if (mountedRef.current) { + setStatus(result.status === 'applied' ? 'applied' : 'conflict'); + setProposal(null); + editor.focus(); + } + } catch { + if (mountedRef.current) setStatus(moved ? 'conflict' : 'failed'); + } finally { + busyRef.current = false; + if (mountedRef.current) setBusy(false); + } + } + + return
+

Review a suggested change

+

This demo prepares a fixed suggestion locally. Nothing is sent to a model or saved; closing or reloading the tab removes the draft.

+ setReady(true)} /> + {messages[status]} + {proposal &&
{suggestionText}
} +
+ Local suggestion + + {proposal && <> + + + } +
+
; +} diff --git a/examples/reference-host/delayed-proposal.mjs b/examples/reference-host/delayed-proposal.mjs new file mode 100644 index 000000000..1846b0a32 --- /dev/null +++ b/examples/reference-host/delayed-proposal.mjs @@ -0,0 +1,394 @@ +const MAX_REVISION_CODE_UNITS = 256; +const MAX_PROPOSAL_CODE_UNITS = 65_536; + +/** Marker used by repository contracts to prevent this fixture being mistaken for a production model adapter. */ +export const REFERENCE_ONLY = true; + +function requireBoundedString(value, maximumCodeUnits, label) { + if ( + typeof value !== 'string' || + value.length === 0 || + value.length > maximumCodeUnits + ) { + throw new TypeError(`${label} is invalid.`); + } + return value; +} + +function requireBoundedReplacement(value) { + if (typeof value !== 'string' || value.length > MAX_PROPOSAL_CODE_UNITS) { + throw new TypeError('replacement is invalid.'); + } + return value; +} + +function readPlainDataRecord(source, keys, message) { + try { + if ( + typeof source !== 'object' || + source === null || + Object.getPrototypeOf(source) !== Object.prototype + ) { + throw new TypeError(message); + } + + const values = Object.create(null); + for (const key of keys) { + const descriptor = Object.getOwnPropertyDescriptor(source, key); + if ( + descriptor === undefined || + !Object.prototype.hasOwnProperty.call(descriptor, 'value') + ) { + throw new TypeError(message); + } + values[key] = descriptor.value; + } + + const ownKeys = Reflect.ownKeys(source); + if ( + ownKeys.length !== keys.length || + ownKeys.some( + (key) => + typeof key !== 'string' || + !keys.some((candidate) => candidate === key), + ) + ) { + throw new TypeError(message); + } + + return values; + } catch { + throw new TypeError(message); + } +} + +/** + * Produce one deterministic asynchronous proposal bound to the revision captured by the host. + * + * This fixture deliberately contains no provider SDK, credential, prompt log, or remote call. + * Real hosts replace proposal generation with an approved model boundary while preserving the + * expectedRevision conflict gate before applying untrusted proposal data. Candidate fields are + * snapshotted from an exact own-data-property shape without invoking caller-owned accessors. + */ +export async function createDelayedProposal(source) { + const input = readPlainDataRecord( + source, + ['expectedRevision', 'replacement'], + 'proposal creation is invalid.', + ); + const boundedRevision = requireBoundedString( + input.expectedRevision, + MAX_REVISION_CODE_UNITS, + 'expectedRevision', + ); + const boundedReplacement = requireBoundedReplacement(input.replacement); + + await Promise.resolve(); + return Object.freeze({ + expectedRevision: boundedRevision, + replacement: boundedReplacement, + }); +} + +/** + * Apply one untrusted proposal only when the host's current revision still matches its capture. + * Top-level application metadata and model proposal fields must have an exact own-data-property + * shape so validation never executes accessor-backed untrusted proposal data or silently admits + * unknown authority-looking metadata. Host apply failures are normalized at this reference + * boundary so private callback causes are not reflected outward. + */ +export function applyDelayedProposal(source) { + const application = readPlainDataRecord( + source, + ['proposal', 'currentRevision', 'apply'], + 'proposal application is invalid.', + ); + if (typeof application.apply !== 'function') { + throw new TypeError('proposal application is invalid.'); + } + const proposal = readPlainDataRecord( + application.proposal, + ['expectedRevision', 'replacement'], + 'proposal application is invalid.', + ); + const boundedCurrentRevision = requireBoundedString( + application.currentRevision, + MAX_REVISION_CODE_UNITS, + 'currentRevision', + ); + const expectedRevision = requireBoundedString( + proposal.expectedRevision, + MAX_REVISION_CODE_UNITS, + 'expectedRevision', + ); + const replacement = requireBoundedReplacement(proposal.replacement); + + if (expectedRevision !== boundedCurrentRevision) { + return Object.freeze({ status: 'conflict' }); + } + + let applicationResult; + try { + applicationResult = application.apply(replacement); + } catch { + throw new Error('proposal application failed.'); + } + + if ( + (typeof applicationResult === 'object' && applicationResult !== null) || + typeof applicationResult === 'function' + ) { + let then; + try { + then = applicationResult.then; + } catch { + throw new Error('proposal application failed.'); + } + + if (typeof then === 'function') { + return new Promise((resolve, reject) => { + try { + then.call( + applicationResult, + () => resolve(), + () => reject(), + ); + } catch { + reject(); + } + }).then( + () => Object.freeze({ status: 'applied' }), + () => { + throw new Error('proposal application failed.'); + }, + ); + } + } + + return Object.freeze({ status: 'applied' }); +} + +async function runSelfTest() { + let staleDocument = 'Original draft'; + let staleRevision = 'revision-v1'; + const staleProposalPromise = createDelayedProposal({ + expectedRevision: staleRevision, + replacement: 'Stale proposal', + }); + + staleDocument = 'User typed newer text'; + staleRevision = 'revision-v2'; + const staleProposal = await staleProposalPromise; + const staleResult = applyDelayedProposal({ + proposal: staleProposal, + currentRevision: staleRevision, + apply(replacement) { + staleDocument = replacement; + }, + }); + + let acceptedDocument = 'Current draft'; + const acceptedRevision = 'revision-v3'; + const acceptedProposal = await createDelayedProposal({ + expectedRevision: acceptedRevision, + replacement: 'Accepted proposal', + }); + const acceptedResult = applyDelayedProposal({ + proposal: acceptedProposal, + currentRevision: acceptedRevision, + apply(replacement) { + acceptedDocument = replacement; + }, + }); + + process.stdout.write( + `${JSON.stringify({ + acceptedDocument, + acceptedStatus: acceptedResult.status, + staleDocument, + staleStatus: staleResult.status, + })}\n`, + ); +} + +async function runEmptyProposalSelfTest() { + const proposal = await createDelayedProposal({ + expectedRevision: 'revision-v1', + replacement: '', + }); + let appliedDocument = 'Non-empty draft'; + const result = applyDelayedProposal({ + proposal, + currentRevision: 'revision-v1', + apply(replacement) { + appliedDocument = replacement; + }, + }); + + let emptyRevisionError = null; + try { + await createDelayedProposal({ + expectedRevision: '', + replacement: '', + }); + } catch (error) { + emptyRevisionError = error instanceof Error ? error.message : 'unexpected error'; + } + + process.stdout.write( + `${JSON.stringify({ + appliedDocument, + appliedStatus: result.status, + emptyRevisionError, + proposalReplacement: proposal.replacement, + })}\n`, + ); +} + +async function runHostileAccessorSelfTest() { + let creationGetterCalls = 0; + let creationError = null; + const hostileCreation = { replacement: 'Hostile proposal' }; + Object.defineProperty(hostileCreation, 'expectedRevision', { + enumerable: true, + get() { + creationGetterCalls += 1; + return 'revision-v1'; + }, + }); + try { + await createDelayedProposal(hostileCreation); + } catch (error) { + creationError = error instanceof Error ? error.message : 'unexpected error'; + } + + const validProposal = await createDelayedProposal({ + expectedRevision: 'revision-v1', + replacement: 'Valid proposal', + }); + let applicationGetterCalls = 0; + let applicationError = null; + const hostileApplication = { + proposal: validProposal, + currentRevision: 'revision-v1', + }; + Object.defineProperty(hostileApplication, 'apply', { + enumerable: true, + get() { + applicationGetterCalls += 1; + return () => undefined; + }, + }); + try { + applyDelayedProposal(hostileApplication); + } catch (error) { + applicationError = error instanceof Error ? error.message : 'unexpected error'; + } + + let proposalGetterCalls = 0; + let proposalError = null; + const hostileProposal = { replacement: 'Hostile proposal' }; + Object.defineProperty(hostileProposal, 'expectedRevision', { + enumerable: true, + get() { + proposalGetterCalls += 1; + return 'revision-v1'; + }, + }); + try { + applyDelayedProposal({ + proposal: hostileProposal, + currentRevision: 'revision-v1', + apply() {}, + }); + } catch (error) { + proposalError = error instanceof Error ? error.message : 'unexpected error'; + } + + process.stdout.write( + `${JSON.stringify({ + applicationError, + applicationGetterCalls, + creationError, + creationGetterCalls, + proposalError, + proposalGetterCalls, + })}\n`, + ); +} + +async function runUnknownFieldSelfTest() { + let creationError = null; + try { + await createDelayedProposal({ + expectedRevision: 'revision-v1', + replacement: 'Untrusted proposal', + authorization: 'owner', + }); + } catch (error) { + creationError = error instanceof Error ? error.message : 'unexpected error'; + } + + const validProposal = await createDelayedProposal({ + expectedRevision: 'revision-v1', + replacement: 'Valid proposal', + }); + let applicationApplyCalls = 0; + let applicationError = null; + const applicationWithHiddenField = { + proposal: validProposal, + currentRevision: 'revision-v1', + apply() { + applicationApplyCalls += 1; + }, + }; + Object.defineProperty(applicationWithHiddenField, 'authorization', { + value: 'owner', + enumerable: false, + }); + try { + applyDelayedProposal(applicationWithHiddenField); + } catch (error) { + applicationError = error instanceof Error ? error.message : 'unexpected error'; + } + + let proposalApplyCalls = 0; + let proposalError = null; + const authorityKey = Symbol('authorization'); + try { + applyDelayedProposal({ + proposal: { + expectedRevision: 'revision-v1', + replacement: 'Untrusted proposal', + [authorityKey]: 'owner', + }, + currentRevision: 'revision-v1', + apply() { + proposalApplyCalls += 1; + }, + }); + } catch (error) { + proposalError = error instanceof Error ? error.message : 'unexpected error'; + } + + process.stdout.write( + `${JSON.stringify({ + applicationApplyCalls, + applicationError, + creationError, + proposalApplyCalls, + proposalError, + })}\n`, + ); +} + +const commandArguments = typeof process === 'undefined' ? [] : process.argv; +if (commandArguments.includes('--empty-proposal-self-test')) { + await runEmptyProposalSelfTest(); +} else if (commandArguments.includes('--hostile-accessor-self-test')) { + await runHostileAccessorSelfTest(); +} else if (commandArguments.includes('--unknown-field-self-test')) { + await runUnknownFieldSelfTest(); +} else if (commandArguments.includes('--self-test')) { + await runSelfTest(); +} diff --git a/examples/reference-host/host-authorized-collaboration.mjs b/examples/reference-host/host-authorized-collaboration.mjs new file mode 100644 index 000000000..dfe7313b5 --- /dev/null +++ b/examples/reference-host/host-authorized-collaboration.mjs @@ -0,0 +1,205 @@ +const AUTHORIZATION_FAILURE = 'collaboration provider authorization failed.'; +const OPTIONS_FAILURE = 'collaboration authorization options are invalid.'; +const CONTEXT_FAILURE = 'collaboration provider context is invalid.'; + +/** Marker used by repository contracts to keep this host example out of runtime authority. */ +export const REFERENCE_ONLY = true; + +function readExactOwnDataRecord(source, keys, message) { + try { + if (typeof source !== 'object' || source === null) { + throw new TypeError(message); + } + const values = Object.create(null); + for (const key of keys) { + const descriptor = Object.getOwnPropertyDescriptor(source, key); + if ( + descriptor === undefined || + !Object.prototype.hasOwnProperty.call(descriptor, 'value') + ) { + throw new TypeError(message); + } + values[key] = descriptor.value; + } + const ownKeys = Reflect.ownKeys(source); + if ( + ownKeys.length !== keys.length || + ownKeys.some( + (key) => + typeof key !== 'string' || + !keys.some((candidate) => candidate === key), + ) + ) { + throw new TypeError(message); + } + return values; + } catch { + throw new TypeError(message); + } +} + +function requireFunction(value, message) { + if (typeof value !== 'function') { + throw new TypeError(message); + } + return value; +} + +function requireContextString(value) { + if (typeof value !== 'string' || value.length === 0 || value.length > 256) { + throw new TypeError(CONTEXT_FAILURE); + } + return value; +} + +function requireGeneration(value) { + if (!Number.isSafeInteger(value) || value < 1) { + throw new TypeError(CONTEXT_FAILURE); + } + return value; +} + +/** + * Wrap one host-owned collaboration provider factory with a synchronous host + * authorization decision. The authorization callback receives only the bounded + * room/actor/generation identity needed for admission; the host-owned Y.Doc is + * supplied only to the provider constructor after authorization succeeds. + * + * Inkspan does not authenticate or authorize here. This reference-only adapter + * demonstrates where an integrating host must enforce its own policy before a + * provider is constructed. Only the exact boolean `true` admits construction; + * false, thrown, asynchronous, malformed, or otherwise indeterminate decisions + * fail closed with a payload-redacted error. + */ +export function createHostAuthorizedProviderFactory(source) { + const options = readExactOwnDataRecord( + source, + ['authorize', 'createProvider'], + OPTIONS_FAILURE, + ); + const authorize = requireFunction(options.authorize, OPTIONS_FAILURE); + const createProvider = requireFunction(options.createProvider, OPTIONS_FAILURE); + + return function authorizedProviderFactory(sourceContext) { + const context = readExactOwnDataRecord( + sourceContext, + ['document', 'roomId', 'actorId', 'generation'], + CONTEXT_FAILURE, + ); + const roomId = requireContextString(context.roomId); + const actorId = requireContextString(context.actorId); + const generation = requireGeneration(context.generation); + const authorizationContext = Object.freeze({ roomId, actorId, generation }); + + let decision; + try { + decision = authorize(authorizationContext); + } catch { + throw new Error(AUTHORIZATION_FAILURE); + } + if (decision !== true) { + throw new Error(AUTHORIZATION_FAILURE); + } + + return createProvider( + Object.freeze({ + document: context.document, + roomId, + actorId, + generation, + }), + ); + }; +} + +function runSelfTest() { + const events = []; + const document = Object.freeze({ reference: 'host-owned-document' }); + const authorizedFactory = createHostAuthorizedProviderFactory({ + authorize(context) { + if (Object.prototype.hasOwnProperty.call(context, 'document')) { + throw new Error('authorization context must not receive the host document.'); + } + events.push( + `authorize:${context.actorId}:${context.roomId}:${context.generation}`, + ); + return true; + }, + createProvider(context) { + if (context.document !== document) { + throw new Error('provider construction lost the host-owned document.'); + } + events.push(`provider:create:${context.generation}`); + return Object.freeze({ generation: context.generation }); + }, + }); + + const provider1 = authorizedFactory({ + document, + roomId: 'reference-room', + actorId: 'reference-actor', + generation: 1, + }); + const provider2 = authorizedFactory({ + document, + roomId: 'reference-room', + actorId: 'reference-actor', + generation: 2, + }); + + let deniedProviderConstructed = false; + let deniedError = null; + const deniedFactory = createHostAuthorizedProviderFactory({ + authorize() { + events.push('authorize:denied'); + return false; + }, + createProvider() { + deniedProviderConstructed = true; + return {}; + }, + }); + try { + deniedFactory({ + document, + roomId: 'reference-room', + actorId: 'denied-actor', + generation: 3, + }); + } catch (error) { + deniedError = error instanceof Error ? error.message : 'unexpected error'; + } + + const expectedEvents = [ + 'authorize:reference-actor:reference-room:1', + 'provider:create:1', + 'authorize:reference-actor:reference-room:2', + 'provider:create:2', + 'authorize:denied', + ]; + if ( + provider1.generation !== 1 || + provider2.generation !== 2 || + deniedProviderConstructed || + deniedError !== AUTHORIZATION_FAILURE || + events.length !== expectedEvents.length || + events.some((event, index) => event !== expectedEvents[index]) + ) { + throw new Error('host-authorized collaboration self-test failed.'); + } + + process.stdout.write( + `${JSON.stringify({ + authorizationBeforeConstruction: true, + deniedProviderConstructed, + deniedError, + events, + hostDocumentPreserved: true, + status: 'completed', + })}\n`, + ); +} + +if (typeof process !== 'undefined' && process.argv.includes('--self-test')) { + runSelfTest(); +} diff --git a/examples/reference-host/hydration-gate.tsx b/examples/reference-host/hydration-gate.tsx new file mode 100644 index 000000000..d027a6d1f --- /dev/null +++ b/examples/reference-host/hydration-gate.tsx @@ -0,0 +1,25 @@ +'use client'; + +import { useEffect, useState, type ReactNode } from 'react'; + +export interface ReferenceHostHydrationGateProps { + loadingLabel: string; + renderEditor: () => ReactNode; +} + +export function ReferenceHostHydrationGate({ + loadingLabel, + renderEditor, +}: ReferenceHostHydrationGateProps) { + const [hydrated, setHydrated] = useState(false); + + useEffect(() => { + setHydrated(true); + }, []); + + if (!hydrated) { + return
{loadingLabel}
; + } + + return <>{renderEditor()}; +} diff --git a/examples/reference-host/local-collaboration-host.tsx b/examples/reference-host/local-collaboration-host.tsx new file mode 100644 index 000000000..dd6080abf --- /dev/null +++ b/examples/reference-host/local-collaboration-host.tsx @@ -0,0 +1,143 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import { flushSync } from 'react-dom'; +import { Doc, applyUpdate, encodeStateAsUpdate } from 'yjs'; +import { CollaborativeCwlEditor } from '@contextualwisdomlab/cwl-editor/collaboration'; +import { createHostCollaborationLifecycle } from './collaboration-provider-lifecycle.mjs'; +import { createHostAuthorizedProviderFactory } from './host-authorized-collaboration.mjs'; + +type LocalSession = { lifecycle: ReturnType; peer: Doc }; +const messages = { + idle: 'Start a local session to try two views of the same draft.', + connected: 'Local views connected. Nothing is saved or sent to a server.', + denied: 'The local connection is not allowed. Enable the demo permission and try again.', + failed: 'The connection could not be confirmed. Reconnect to try again; your local draft is still here.', + closed: 'Local session closed. Its drafts have been removed from memory.', +}; + +/** Two local Yjs documents; no server, awareness, credential or durable store. */ +export function LocalCollaborationHost({ readOnly = false, onEvent }: { + readOnly?: boolean; + onEvent: (event: string) => void; +}) { + const sessionRef = useRef(null); + const busyRef = useRef(false); + const allowRef = useRef(true); + const failRef = useRef(false); + const startRef = useRef(null); + const [session, setSession] = useState(null); + const [busy, setBusy] = useState(false); + const [allowed, setAllowed] = useState(true); + const [failNext, setFailNext] = useState(false); + const [status, setStatus] = useState('idle'); + + function disposeSession() { + const current = sessionRef.current; + if (!current) return; + try { current.lifecycle.dispose(); } + finally { current.peer.destroy(); } + sessionRef.current = null; + } + + useEffect(() => () => { disposeSession(); }, []); + useEffect(() => { + if (!busy && status === 'closed') startRef.current?.focus(); + }, [busy, status]); + + function createSession(): LocalSession { + const peer = new Doc(); + peer.on('destroy', () => onEvent('document:destroy:peer')); + try { + const lifecycle = createHostCollaborationLifecycle({ + roomId: 'local-demo-room', actorId: 'local-demo-author', + documentFactory() { + const document = new Doc(); + document.on('destroy', () => onEvent('document:destroy:local')); + return document; + }, + providerFactory: createHostAuthorizedProviderFactory({ + authorize({ generation }: { generation: number }) { + onEvent(`authorize:${generation}`); + return allowRef.current; + }, + createProvider({ document, generation }: { document: Doc; generation: number }) { + onEvent(`provider:create:${generation}`); + // ponytail: in-memory pair only; an authorized network provider must + // replace this transport and own presence, retries and persistence. + const forward = (update: Uint8Array, origin: unknown) => { + if (origin !== peer) { applyUpdate(peer, update, document); onEvent(`provider:forward:${generation}`); } + }; + const reverse = (update: Uint8Array, origin: unknown) => { + if (origin !== document) applyUpdate(document, update, peer); + }; + const detach = () => { document.off('update', forward); peer.off('update', reverse); }; + return { + connect() { + onEvent(`provider:connect:${generation}`); + document.on('update', forward); peer.on('update', reverse); + if (failRef.current) { + failRef.current = false; setFailNext(false); + throw new Error('private local connection fixture cause'); + } + applyUpdate(peer, encodeStateAsUpdate(document), document); + applyUpdate(document, encodeStateAsUpdate(peer), peer); + }, + disconnect() { detach(); onEvent(`provider:disconnect:${generation}`); }, + destroy() { detach(); onEvent(`provider:destroy:${generation}`); }, + }; + }, + }), + }); + return { lifecycle, peer }; + } catch (error) { peer.destroy(); throw error; } + } + + function perform(action: 'start' | 'reconnect' | 'close') { + if (busyRef.current) return; + busyRef.current = true; setBusy(true); + try { + if (action === 'close') { + if (!sessionRef.current || !window.confirm('Close this local session? Both unsaved local drafts will be lost.')) return; + // Detach both editor bindings before destroying their host-owned docs. + flushSync(() => setSession(null)); + disposeSession(); setStatus('closed'); + } else if (action === 'start') { + if (sessionRef.current) return; + const current = createSession(); + sessionRef.current = current; setSession(current); + current.lifecycle.connect(); setStatus('connected'); + } else { + if (!sessionRef.current) return; + sessionRef.current.lifecycle.reconnect(); setStatus('connected'); + } + } catch { setStatus(allowRef.current ? 'failed' : 'denied'); } + finally { + // Keep the synchronous admission latch through the initiating event turn. + queueMicrotask(() => { busyRef.current = false; setBusy(false); }); + } + } + + const connectionStatus = status === 'connected' ? 'connected' : status === 'denied' ? 'disconnected' : undefined; + return
+

Try a local collaboration session

+

Both views run in this tab. Nothing is sent to another person or saved; closing the session or reloading removes the drafts.

+ {messages[status]} +
+ Local connection demo + + + + + +
+ {session && <> +

Your draft

+ +

Other local view

+ + } +
; +} diff --git a/examples/reference-host/native-form-host.tsx b/examples/reference-host/native-form-host.tsx new file mode 100644 index 000000000..9a17803c6 --- /dev/null +++ b/examples/reference-host/native-form-host.tsx @@ -0,0 +1,184 @@ +import { useRef, useState, type FormEvent } from 'react'; +import { CwlEditor } from '@contextualwisdomlab/cwl-editor'; +import { + createSingleFlightSubmission, + shouldBlockReferenceHostFormMutation, + type ReferenceHostSubmissionState, +} from './single-flight-submission.js'; + +type SubmissionState = 'idle' | ReferenceHostSubmissionState; + +export interface NativeFormHostProps { + /** + * Host-owned authorization and durable persistence boundary. + * The reference component deliberately does not choose transport, + * credentials, tenancy, or storage for the embedding application. + */ + onAuthorizedSubmit(messageBody: string): Promise | void; + /** + * Demonstration mode for the public controlled and uncontrolled editor APIs. + * This changes only local React ownership of the current value; durable + * persistence and authorization remain host-owned through onAuthorizedSubmit. + */ + controlMode?: 'controlled' | 'uncontrolled'; + /** + * Host-owned write permission presentation. + * + * A read-only host keeps the editor readable while fail-closing native form + * submission/reset and disabling the named form field. This is presentation + * evidence only; the embedding host remains responsible for authorization at + * the durable write boundary. + */ + readOnly?: boolean; +} + +/** + * Buyer-facing native-form integration example. + * + * Inkspan owns synchronization of the editor document into the native form + * control. The host reads FormData at submit time and then applies its own + * authorization and durable-persistence policy through onAuthorizedSubmit. + * Both the public controlled and uncontrolled editor compositions are supported + * without changing that boundary. Overlapping submissions and form resets are + * blocked by the synchronous single-flight admission gate while the host + * callback is in flight; React presentation state is not used as the mutation + * authority. Host read-only state additionally fail-closes native form writes + * and disables the named field without moving authorization authority into + * Inkspan. Document edits invalidate stale saved/failed presentation, including + * when a newer edit occurs while an older host-owned persistence attempt is in + * flight. The host observes both the editor value callback and bubbling native + * input as independent local mutation signals; either signal advances the + * monotonic document generation, and duplicate advances for one logical edit + * are harmless because generation equality—not the numeric delta—is the + * persistence freshness invariant. A later successful reset returns the host + * presentation to an explicitly unsaved state and restores the controlled + * example value when that mode is selected. + */ +export function NativeFormHost({ + onAuthorizedSubmit, + controlMode = 'uncontrolled', + readOnly = false, +}: NativeFormHostProps) { + const [submissionState, setSubmissionState] = + useState('idle'); + const [controlledValue, setControlledValue] = useState('# Draft'); + const documentGenerationRef = useRef(0); + const onAuthorizedSubmitRef = useRef(onAuthorizedSubmit); + onAuthorizedSubmitRef.current = onAuthorizedSubmit; + + const submitAuthorizedRef = useRef< + ReturnType | null + >(null); + const existingSubmitAuthorized = submitAuthorizedRef.current; + const submitAuthorized = + existingSubmitAuthorized ?? + createSingleFlightSubmission( + (messageBody) => onAuthorizedSubmitRef.current(messageBody), + setSubmissionState, + ); + if (existingSubmitAuthorized === null) { + submitAuthorizedRef.current = submitAuthorized; + } + + function markDocumentDirty() { + documentGenerationRef.current += 1; + setSubmissionState((state) => (state === 'saving' ? state : 'idle')); + } + + function handleDocumentChange(nextValue: string) { + markDocumentDirty(); + if (controlMode === 'controlled') { + setControlledValue(nextValue); + } + } + + function handleNativeInput() { + markDocumentDirty(); + } + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + if ( + shouldBlockReferenceHostFormMutation( + readOnly, + submitAuthorized.isInFlight, + ) + ) { + return; + } + + const messageBodyEntry = new FormData(event.currentTarget).get( + 'message_body', + ); + if (typeof messageBodyEntry !== 'string') { + setSubmissionState('failed'); + return; + } + + const submittedGeneration = documentGenerationRef.current; + await submitAuthorized(messageBodyEntry); + if (documentGenerationRef.current !== submittedGeneration) { + setSubmissionState('idle'); + } + } + + function handleReset(event: FormEvent) { + if ( + shouldBlockReferenceHostFormMutation( + readOnly, + submitAuthorized.isInFlight, + ) + ) { + event.preventDefault(); + return; + } + documentGenerationRef.current += 1; + if (controlMode === 'controlled') { + setControlledValue('# Draft'); + } + setSubmissionState('idle'); + } + + return ( +
+ +
+ + +
+ + {submissionState === 'saving' + ? 'Saving…' + : submissionState === 'saved' + ? 'Saved' + : submissionState === 'failed' + ? 'Save failed' + : 'Not saved yet'} + + + ); +} diff --git a/examples/reference-host/office-handoff.mjs b/examples/reference-host/office-handoff.mjs new file mode 100644 index 000000000..99716c3ac --- /dev/null +++ b/examples/reference-host/office-handoff.mjs @@ -0,0 +1,54 @@ +import { markdownToPlainText } from '@contextualwisdomlab/cwl-editor/markdown'; + +function requireNonEmptyString(value, fieldName) { + if (typeof value !== 'string' || !/\S/u.test(value)) { + throw new TypeError(`${fieldName} must be a non-empty string.`); + } + return value; +} + +function readOfficeHandoffInput(input) { + try { + return { + title: input.title, + markdown: input.markdown, + }; + } catch { + throw new TypeError('Office handoff input is invalid.'); + } +} + +/** + * Create a bounded reference-only DOCX request from editor Markdown. + * + * The host deliberately projects Markdown to plain text through Inkspan's + * React-free public package surface, preserves that projection's deterministic + * block boundaries as separate DOCX paragraphs, and then constructs the strict + * Office request. This example does not claim Markdown-to-OOXML round-trip + * fidelity, perform Office rendering, authorize export, choose a filesystem + * path, or persist/distribute the resulting artifact; those remain host + * responsibilities. Host input reflection failures are normalized at this + * boundary instead of exposing caller-controlled exception values. + * + * @param {{ title: string, markdown: string }} input host-owned export input + * @returns {{ format: 'docx', title: string, blocks: readonly { type: 'paragraph', text: string }[] }} + */ +export function createReferenceDocxRequest(input) { + const { title, markdown } = readOfficeHandoffInput(input); + const acceptedTitle = requireNonEmptyString(title, 'title'); + if (typeof markdown !== 'string') { + throw new TypeError('markdown must be a string.'); + } + + const text = markdownToPlainText(markdown); + const paragraphs = text.split('\n\n'); + const blocks = Object.freeze( + paragraphs.map((paragraphText) => Object.freeze({ type: 'paragraph', text: paragraphText })), + ); + + return Object.freeze({ + format: 'docx', + title: acceptedTitle, + blocks, + }); +} diff --git a/examples/reference-host/presentation-full.css b/examples/reference-host/presentation-full.css new file mode 100644 index 000000000..34f8fdd9f --- /dev/null +++ b/examples/reference-host/presentation-full.css @@ -0,0 +1,2 @@ +@import '@contextualwisdomlab/cwl-editor/styles.css'; +@import '@contextualwisdomlab/cwl-editor/fonts.css'; diff --git a/examples/reference-host/presentation-latin.css b/examples/reference-host/presentation-latin.css new file mode 100644 index 000000000..17f95d3eb --- /dev/null +++ b/examples/reference-host/presentation-latin.css @@ -0,0 +1,2 @@ +@import '@contextualwisdomlab/cwl-editor/styles.css'; +@import '@contextualwisdomlab/cwl-editor/fonts-latin.css'; diff --git a/examples/reference-host/reference-host-app.tsx b/examples/reference-host/reference-host-app.tsx new file mode 100644 index 000000000..8b3a268ac --- /dev/null +++ b/examples/reference-host/reference-host-app.tsx @@ -0,0 +1,33 @@ +import { + ReferenceHostClient, + type ReferenceHostClientProps, +} from './reference-host-client.js'; + +export interface ReferenceHostAppProps extends ReferenceHostClientProps {} + +/** + * Minimal buyer-facing application composition for the reference host. + * + * This module is deliberately server-safe: it owns only deterministic host + * chrome and delegates browser-only hydration/editor behavior to the narrow + * ReferenceHostClient boundary. Authorization and durable persistence remain + * behind the host-supplied onAuthorizedSubmit callback. + */ +export function ReferenceHostApp({ + loadingLabel, + onAuthorizedSubmit, + controlMode = 'uncontrolled', + readOnly = false, +}: ReferenceHostAppProps) { + return ( +
+

Inkspan reference host

+ +
+ ); +} diff --git a/examples/reference-host/reference-host-client.tsx b/examples/reference-host/reference-host-client.tsx new file mode 100644 index 000000000..2be2db2f3 --- /dev/null +++ b/examples/reference-host/reference-host-client.tsx @@ -0,0 +1,40 @@ +'use client'; + +import { ReferenceHostHydrationGate } from './hydration-gate.js'; +import { + NativeFormHost, + type NativeFormHostProps, +} from './native-form-host.js'; + +export interface ReferenceHostClientProps extends NativeFormHostProps { + /** Host-localized label rendered before the interactive editor hydrates. */ + loadingLabel: string; +} + +/** + * Narrow client boundary for the reference-host editor composition. + * + * Browser-only hydration and the native form/editor lifecycle stay behind this + * boundary. The embedding host still owns authorization and durable persistence + * through onAuthorizedSubmit; this component adds no transport, credential, or + * storage authority. + */ +export function ReferenceHostClient({ + loadingLabel, + onAuthorizedSubmit, + controlMode, + readOnly, +}: ReferenceHostClientProps) { + return ( + ( + + )} + /> + ); +} diff --git a/examples/reference-host/single-flight-submission.ts b/examples/reference-host/single-flight-submission.ts new file mode 100644 index 000000000..838a60700 --- /dev/null +++ b/examples/reference-host/single-flight-submission.ts @@ -0,0 +1,73 @@ +export type ReferenceHostSubmissionState = 'saving' | 'saved' | 'failed'; + +export type AuthorizedSubmit = ( + messageBody: string, +) => Promise | void; + +export type SubmissionStateObserver = ( + state: ReferenceHostSubmissionState, +) => void; + +/** + * Return whether a reference-host native-form write must be rejected now. + * + * Read-only host policy and the synchronous durable-submission admission gate + * are the only authority inputs. Presentation state is deliberately excluded so + * a submit/reset event in the same turn cannot race a deferred React commit. + */ +export function shouldBlockReferenceHostFormMutation( + readOnly: boolean, + isDurableSubmissionInFlight: () => boolean, +): boolean { + return readOnly || isDurableSubmissionInFlight(); +} + +/** + * Serialize host-owned authorized persistence attempts without assuming any + * transport or storage authority in Inkspan. + * + * Overlapping attempts are rejected while one host callback is in flight. + * The returned callable also exposes the synchronous admission gate so event + * handlers can reject same-turn mutations without depending on deferred UI + * state. Host failures are reduced to a stable boolean/state signal so private + * durable-store details do not cross the reference component boundary. + * Presentation-state observer failures are best-effort and cannot block, + * reclassify, or wedge the host-owned persistence attempt. + */ +export function createSingleFlightSubmission( + onAuthorizedSubmit: AuthorizedSubmit, + onStateChange: SubmissionStateObserver, +) { + let inFlight = false; + + const notifyState = (state: ReferenceHostSubmissionState) => { + try { + onStateChange(state); + } catch { + // Presentation observation must not acquire persistence authority. + } + }; + + const submit = async (messageBody: string): Promise => { + if (inFlight) { + return false; + } + + inFlight = true; + notifyState('saving'); + try { + await onAuthorizedSubmit(messageBody); + notifyState('saved'); + return true; + } catch { + notifyState('failed'); + return false; + } finally { + inFlight = false; + } + }; + + return Object.assign(submit, { + isInFlight: (): boolean => inFlight, + }); +} diff --git a/examples/reference-host/synthetic-document-repository.mjs b/examples/reference-host/synthetic-document-repository.mjs new file mode 100644 index 000000000..697287b6b --- /dev/null +++ b/examples/reference-host/synthetic-document-repository.mjs @@ -0,0 +1,560 @@ +const MAX_DOCUMENT_ID_CODE_UNITS = 256; +export const MAX_DOCUMENT_CODE_UNITS = 65_536; + +/** Marker used by repository contracts to prevent this fixture being mistaken for a production adapter. */ +export const REFERENCE_ONLY = true; + +/** Stable failure raised by the synthetic reference persistence adapter. */ +export class ReferencePersistenceError extends Error { + constructor(code) { + super(`Reference persistence ${code}.`); + this.name = 'ReferencePersistenceError'; + this.code = code; + Object.freeze(this); + } +} + +function requireBoundedString(value, maximumCodeUnits, code) { + if ( + typeof value !== 'string' || + value.length === 0 || + value.length > maximumCodeUnits + ) { + throw new ReferencePersistenceError(code); + } + return value; +} + +function requireBoundedDocument(value, code) { + if (typeof value !== 'string' || value.length > MAX_DOCUMENT_CODE_UNITS) { + throw new ReferencePersistenceError(code); + } + return value; +} + +function readPlainDataRecord(source, requiredKeys, optionalKeys, code) { + try { + if ( + typeof source !== 'object' || + source === null || + Object.getPrototypeOf(source) !== Object.prototype + ) { + throw new ReferencePersistenceError(code); + } + + const values = Object.create(null); + for (const key of requiredKeys) { + const descriptor = Object.getOwnPropertyDescriptor(source, key); + if ( + descriptor === undefined || + !Object.prototype.hasOwnProperty.call(descriptor, 'value') + ) { + throw new ReferencePersistenceError(code); + } + values[key] = descriptor.value; + } + for (const key of optionalKeys) { + const descriptor = Object.getOwnPropertyDescriptor(source, key); + if (descriptor === undefined) continue; + if (!Object.prototype.hasOwnProperty.call(descriptor, 'value')) { + throw new ReferencePersistenceError(code); + } + values[key] = descriptor.value; + } + + const allowedKeys = [...requiredKeys, ...optionalKeys]; + if ( + Reflect.ownKeys(source).some( + (key) => typeof key !== 'string' || !allowedKeys.includes(key), + ) + ) { + throw new ReferencePersistenceError(code); + } + + return values; + } catch { + throw new ReferencePersistenceError(code); + } +} + +function validatorForVersion(version, validatorNamespace) { + const prefix = validatorNamespace === '' ? '' : `${validatorNamespace}-`; + return `"${prefix}v${version}"`; +} + +function frozenRead(document, validator) { + return Object.freeze({ document, validator }); +} + +function frozenSave(status, validator) { + return Object.freeze({ status, validator }); +} + +function frozenConflict(currentValidator) { + return Object.freeze({ status: 'conflict', currentValidator }); +} + +function createRepository(configuration, validatorNamespace, forkAuthority) { + const documentId = requireBoundedString( + configuration.documentId, + MAX_DOCUMENT_ID_CODE_UNITS, + 'invalid_document_id', + ); + let document = requireBoundedDocument( + configuration.initialDocument, + 'invalid_document', + ); + let version = 1; + let validator = validatorForVersion(version, validatorNamespace); + + function assertDocumentId(candidate) { + if (candidate !== documentId) { + throw new ReferencePersistenceError('document_not_found'); + } + } + + function read(candidateDocumentId) { + assertDocumentId(candidateDocumentId); + return frozenRead(document, validator); + } + + function save(request) { + const candidate = readPlainDataRecord( + request, + ['documentId', 'document', 'ifMatch'], + ['outcome'], + 'invalid_request', + ); + + assertDocumentId(candidate.documentId); + const nextDocument = requireBoundedDocument( + candidate.document, + 'invalid_document', + ); + const ifMatch = requireBoundedString( + candidate.ifMatch, + 256, + 'invalid_if_match', + ); + const outcome = candidate.outcome ?? 'saved'; + if ( + outcome !== 'saved' && + outcome !== 'ambiguous_failure' && + outcome !== 'ambiguous_commit_failure' && + outcome !== 'failure' + ) { + throw new ReferencePersistenceError('invalid_outcome'); + } + + if (outcome === 'ambiguous_failure') { + throw new ReferencePersistenceError('ambiguous_failure'); + } + if (outcome === 'failure') { + throw new ReferencePersistenceError('failure'); + } + if (ifMatch !== validator) { + return frozenConflict(validator); + } + + document = nextDocument; + version += 1; + validator = validatorForVersion(version, validatorNamespace); + if (outcome === 'ambiguous_commit_failure') { + throw new ReferencePersistenceError('ambiguous_failure'); + } + return frozenSave('saved', validator); + } + + function fork(request) { + const candidate = readPlainDataRecord( + request, + ['documentId', 'forkDocumentId', 'ifMatch'], + [], + 'invalid_fork_request', + ); + + assertDocumentId(candidate.documentId); + const forkDocumentId = requireBoundedString( + candidate.forkDocumentId, + MAX_DOCUMENT_ID_CODE_UNITS, + 'invalid_fork_document_id', + ); + const ifMatch = requireBoundedString( + candidate.ifMatch, + 256, + 'invalid_if_match', + ); + if (ifMatch !== validator) { + return frozenConflict(validator); + } + if (forkDocumentId === documentId) { + throw new ReferencePersistenceError('invalid_fork_document_id'); + } + if (!Number.isSafeInteger(forkAuthority.nextNamespace)) { + throw new ReferencePersistenceError('fork_namespace_exhausted'); + } + + const childNamespace = `f${forkAuthority.nextNamespace}`; + forkAuthority.nextNamespace += 1; + return Object.freeze({ + status: 'forked', + repository: createRepository( + { + documentId: forkDocumentId, + initialDocument: document, + }, + childNamespace, + forkAuthority, + ), + }); + } + + return Object.freeze({ fork, read, save }); +} + +/** + * Create an in-memory host-owned reference repository with exact If-Match semantics. + * + * This adapter is synthetic acquisition/support evidence only. Buyers must replace + * it with an authorized atomic durable store. Confirmed failures and the + * `ambiguous_failure` pre-commit fixture leave durable state unchanged. The + * `ambiguous_commit_failure` fixture deliberately commits before returning the + * same ambiguous error so consumers must re-read durable state instead of + * advancing or blindly reusing their last known validator. A confirmed fork + * requires the current strong validator and starts an independent repository at a + * fresh validator so source and fork cannot silently share revision authority. + * Configuration, save, and fork request fields are snapshotted from exact own + * data-property shapes without invoking caller-owned accessors or admitting + * unknown authority-looking metadata. + */ +export function createSyntheticDocumentRepository(options) { + const configuration = readPlainDataRecord( + options, + ['documentId', 'initialDocument'], + [], + 'invalid_options', + ); + return createRepository(configuration, '', { nextNamespace: 1 }); +} + +function runSelfTest() { + const repository = createSyntheticDocumentRepository({ + documentId: 'buyer-document', + initialDocument: 'Buyer draft v1', + }); + const initial = repository.read('buyer-document'); + + let ambiguousFailureObserved = false; + try { + repository.save({ + documentId: 'buyer-document', + document: 'Uncertain write', + ifMatch: initial.validator, + outcome: 'ambiguous_failure', + }); + } catch (error) { + ambiguousFailureObserved = + error instanceof ReferencePersistenceError && + error.code === 'ambiguous_failure'; + } + if (!ambiguousFailureObserved) { + throw new Error('Synthetic ambiguous-failure evidence was not observed.'); + } + + const afterAmbiguous = repository.read('buyer-document'); + if ( + afterAmbiguous.document !== initial.document || + afterAmbiguous.validator !== initial.validator + ) { + throw new Error('Ambiguous failure advanced synthetic durable state.'); + } + + const saved = repository.save({ + documentId: 'buyer-document', + document: 'Buyer draft v2', + ifMatch: initial.validator, + }); + if (saved.status !== 'saved') { + throw new Error('Synthetic save did not report success.'); + } + + const conflict = repository.save({ + documentId: 'buyer-document', + document: 'Stale overwrite', + ifMatch: initial.validator, + }); + if (conflict.status !== 'conflict') { + throw new Error('Synthetic stale If-Match write did not conflict.'); + } + + let failureObserved = false; + try { + repository.save({ + documentId: 'buyer-document', + document: 'Buyer draft v3', + ifMatch: saved.validator, + outcome: 'failure', + }); + } catch (error) { + failureObserved = + error instanceof ReferencePersistenceError && error.code === 'failure'; + } + if (!failureObserved) { + throw new Error('Synthetic failure evidence was not observed.'); + } + + const afterFailure = repository.read('buyer-document'); + if ( + afterFailure.document !== 'Buyer draft v2' || + afterFailure.validator !== saved.validator + ) { + throw new Error('Failure advanced synthetic durable state.'); + } + + const retried = repository.save({ + documentId: 'buyer-document', + document: 'Buyer draft v3', + ifMatch: afterFailure.validator, + }); + if (retried.status !== 'saved') { + throw new Error('Synthetic retry did not report success.'); + } + + const restored = repository.save({ + documentId: 'buyer-document', + document: initial.document, + ifMatch: retried.validator, + }); + if (restored.status !== 'saved') { + throw new Error('Synthetic restore did not report success.'); + } + + const forked = repository.fork({ + documentId: 'buyer-document', + forkDocumentId: 'buyer-document-fork', + ifMatch: restored.validator, + }); + if (forked.status !== 'forked') { + throw new Error('Synthetic fork did not report success.'); + } + const forkInitial = forked.repository.read('buyer-document-fork'); + const forkSaved = forked.repository.save({ + documentId: 'buyer-document-fork', + document: 'Fork-only edit', + ifMatch: forkInitial.validator, + }); + if (forkSaved.status !== 'saved') { + throw new Error('Synthetic fork save did not report success.'); + } + + const forkFinal = forked.repository.read('buyer-document-fork'); + const sourceAfterFork = repository.read('buyer-document'); + process.stdout.write( + `${JSON.stringify({ + afterAmbiguousValidator: afterAmbiguous.validator, + afterFailureValidator: afterFailure.validator, + conflictCurrentValidator: conflict.currentValidator, + forkDocument: forkInitial.document, + forkFinalDocument: forkFinal.document, + forkInitialValidator: forkInitial.validator, + forkSavedValidator: forkSaved.validator, + initialValidator: initial.validator, + restoredValidator: restored.validator, + retrySavedValidator: retried.validator, + savedValidator: saved.validator, + sourceDocumentAfterFork: sourceAfterFork.document, + sourceValidatorAfterFork: sourceAfterFork.validator, + })}\n`, + ); +} + +function runEmptyDocumentSelfTest() { + const emptyRepository = createSyntheticDocumentRepository({ + documentId: 'buyer-empty', + initialDocument: '', + }); + const initialEmptyDocument = emptyRepository.read('buyer-empty').document; + + const repository = createSyntheticDocumentRepository({ + documentId: 'buyer-document', + initialDocument: 'Not empty', + }); + const initial = repository.read('buyer-document'); + const cleared = repository.save({ + documentId: 'buyer-document', + document: '', + ifMatch: initial.validator, + }); + if (cleared.status !== 'saved') { + throw new Error('Synthetic empty-document save did not report success.'); + } + const afterClear = repository.read('buyer-document'); + + let emptyDocumentIdError = null; + try { + createSyntheticDocumentRepository({ + documentId: '', + initialDocument: '', + }); + } catch (error) { + emptyDocumentIdError = + error instanceof ReferencePersistenceError ? error.code : 'unexpected_error'; + } + + process.stdout.write( + `${JSON.stringify({ + clearedDocument: afterClear.document, + clearedValidator: afterClear.validator, + emptyDocumentIdError, + initialEmptyDocument, + })}\n`, + ); +} + +function runHostileAccessorSelfTest() { + let optionGetterCalls = 0; + let optionErrorCode = null; + const hostileOptions = { initialDocument: 'Buyer draft v1' }; + Object.defineProperty(hostileOptions, 'documentId', { + enumerable: true, + get() { + optionGetterCalls += 1; + return 'buyer-document'; + }, + }); + try { + createSyntheticDocumentRepository(hostileOptions); + } catch (error) { + optionErrorCode = + error instanceof ReferencePersistenceError ? error.code : 'unexpected_error'; + } + + const repository = createSyntheticDocumentRepository({ + documentId: 'buyer-document', + initialDocument: 'Buyer draft v1', + }); + const initial = repository.read('buyer-document'); + let requestGetterCalls = 0; + let requestErrorCode = null; + const hostileRequest = { + documentId: 'buyer-document', + ifMatch: initial.validator, + }; + Object.defineProperty(hostileRequest, 'document', { + enumerable: true, + get() { + requestGetterCalls += 1; + return 'Hostile write'; + }, + }); + try { + repository.save(hostileRequest); + } catch (error) { + requestErrorCode = + error instanceof ReferencePersistenceError ? error.code : 'unexpected_error'; + } + + let forkGetterCalls = 0; + let forkErrorCode = null; + const hostileFork = { + documentId: 'buyer-document', + ifMatch: initial.validator, + }; + Object.defineProperty(hostileFork, 'forkDocumentId', { + enumerable: true, + get() { + forkGetterCalls += 1; + return 'buyer-document-fork'; + }, + }); + try { + repository.fork(hostileFork); + } catch (error) { + forkErrorCode = + error instanceof ReferencePersistenceError ? error.code : 'unexpected_error'; + } + + process.stdout.write( + `${JSON.stringify({ + forkErrorCode, + forkGetterCalls, + optionErrorCode, + optionGetterCalls, + requestErrorCode, + requestGetterCalls, + })}\n`, + ); +} + +function runUnknownFieldSelfTest() { + let optionErrorCode = null; + try { + createSyntheticDocumentRepository({ + documentId: 'buyer-document', + initialDocument: 'Buyer draft v1', + authorization: 'owner', + }); + } catch (error) { + optionErrorCode = + error instanceof ReferencePersistenceError ? error.code : 'unexpected_error'; + } + + const repository = createSyntheticDocumentRepository({ + documentId: 'buyer-document', + initialDocument: 'Buyer draft v1', + }); + const initial = repository.read('buyer-document'); + + let saveErrorCode = null; + const saveWithHiddenField = { + documentId: 'buyer-document', + document: 'Unauthorized write', + ifMatch: initial.validator, + }; + Object.defineProperty(saveWithHiddenField, 'authorization', { + value: 'owner', + enumerable: false, + }); + try { + repository.save(saveWithHiddenField); + } catch (error) { + saveErrorCode = + error instanceof ReferencePersistenceError ? error.code : 'unexpected_error'; + } + + let forkErrorCode = null; + const authorityKey = Symbol('authorization'); + try { + repository.fork({ + documentId: 'buyer-document', + forkDocumentId: 'buyer-document-fork', + ifMatch: initial.validator, + [authorityKey]: 'owner', + }); + } catch (error) { + forkErrorCode = + error instanceof ReferencePersistenceError ? error.code : 'unexpected_error'; + } + + const afterRejectedSave = repository.read('buyer-document'); + process.stdout.write( + `${JSON.stringify({ + forkErrorCode, + optionErrorCode, + saveErrorCode, + savedDocument: afterRejectedSave.document, + savedValidator: afterRejectedSave.validator, + })}\n`, + ); +} + +const commandArguments = typeof process === 'undefined' ? [] : process.argv; +if (commandArguments.includes('--empty-document-self-test')) { + runEmptyDocumentSelfTest(); +} else if (commandArguments.includes('--hostile-accessor-self-test')) { + runHostileAccessorSelfTest(); +} else if (commandArguments.includes('--unknown-field-self-test')) { + runUnknownFieldSelfTest(); +} else if (commandArguments.includes('--self-test')) { + runSelfTest(); +} diff --git a/examples/reference-host/verify-application-ssr.mjs b/examples/reference-host/verify-application-ssr.mjs new file mode 100644 index 000000000..330b31b11 --- /dev/null +++ b/examples/reference-host/verify-application-ssr.mjs @@ -0,0 +1,210 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { + mkdtempSync, + mkdirSync, + readFileSync, + readdirSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, relative, resolve, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { build } from 'vite'; + +const repositoryRoot = resolve( + dirname(fileURLToPath(import.meta.url)), + '..', + '..', +); +const packageMetadata = JSON.parse( + readFileSync(join(repositoryRoot, 'package.json'), 'utf8'), +); +const packageName = packageMetadata.name; +const packageVersion = packageMetadata.version; + +function run(command, argumentsList, cwd) { + return execFileSync(command, argumentsList, { + cwd, + encoding: 'utf8', + env: process.env, + stdio: ['ignore', 'pipe', 'pipe'], + }); +} + +function isContained(parentPath, childPath) { + const relation = relative(realpathSync(parentPath), realpathSync(childPath)); + return relation === '' || (!relation.startsWith(`..${sep}`) && relation !== '..'); +} + +const requestedMode = process.argv.slice(2); +assert.equal( + requestedMode.length === 0 || + (requestedMode.length === 1 && requestedMode[0] === '--self-test'), + true, + 'Expected no arguments or --self-test.', +); + +const temporaryRoot = mkdtempSync(join(tmpdir(), 'inkspan-reference-host-app-ssr-')); + +try { + run('pnpm', ['build'], repositoryRoot); + + const packDirectory = join(temporaryRoot, 'pack'); + mkdirSync(packDirectory, { recursive: true }); + run('pnpm', ['pack', '--pack-destination', packDirectory], repositoryRoot); + + const tarballs = readdirSync(packDirectory).filter((name) => name.endsWith('.tgz')); + assert.equal(tarballs.length, 1, 'Expected exactly one packed Inkspan tarball.'); + const tarballPath = join(packDirectory, tarballs[0]); + + const hostDirectory = join(temporaryRoot, 'host'); + mkdirSync(hostDirectory, { recursive: true }); + writeFileSync( + join(hostDirectory, 'package.json'), + `${JSON.stringify( + { + name: 'inkspan-reference-host-app-ssr-consumer', + private: true, + type: 'module', + packageManager: packageMetadata.packageManager, + dependencies: { + [packageName]: `file:${tarballPath}`, + react: packageMetadata.devDependencies.react, + 'react-dom': packageMetadata.devDependencies['react-dom'], + }, + }, + null, + 2, + )}\n`, + 'utf8', + ); + run( + 'pnpm', + ['install', '--prefer-offline', '--ignore-scripts', '--no-frozen-lockfile'], + hostDirectory, + ); + + const installedPackageDirectory = join( + hostDirectory, + 'node_modules', + ...packageName.split('/'), + ); + const installedMetadata = JSON.parse( + readFileSync(join(installedPackageDirectory, 'package.json'), 'utf8'), + ); + assert.equal(installedMetadata.name, packageName); + assert.equal(installedMetadata.version, packageVersion); + assert.equal( + isContained(join(hostDirectory, 'node_modules'), installedPackageDirectory), + true, + 'Installed application dependency escaped the isolated consumer.', + ); + + const packedEntry = join(installedPackageDirectory, 'dist', 'cwl-editor.js'); + assert.equal( + relative(realpathSync(installedPackageDirectory), realpathSync(packedEntry)).startsWith( + `dist${sep}`, + ), + true, + 'Installed application dependency did not resolve through packed dist/.', + ); + + const applicationEntry = join(hostDirectory, 'application-ssr.tsx'); + const referenceHostApplication = join( + repositoryRoot, + 'examples', + 'reference-host', + 'reference-host-app.tsx', + ); + writeFileSync( + applicationEntry, + `import React from 'react'; +import { renderToString } from 'react-dom/server'; +import { ReferenceHostApp } from ${JSON.stringify(referenceHostApplication)}; + +const loadingLabel = 'Loading exact-packed Inkspan editor'; +const serverHtml = renderToString( + React.createElement(ReferenceHostApp, { + loadingLabel, + onAuthorizedSubmit() { + throw new Error('Host submit authority must not execute during server rendering.'); + }, + }), +); + +const applicationServerRendered = + serverHtml.includes(' + name.endsWith('.tgz'), + ); + assert.equal(tarballs.length, 1, 'Expected exactly one packed Inkspan tarball.'); + + const tarballPath = join(packDirectory, tarballs[0]); + const packageSha256 = createHash('sha256').update(readFileSync(tarballPath)).digest('hex'); + const consumerDirectory = join(temporaryRoot, 'consumer'); + mkdirSync(consumerDirectory); + // Match the existing isolated SSR consumer: install the packed artifact and + // its declared dependency closure, never resolve dependencies from source. + writeFileSync(join(consumerDirectory, 'package.json'), JSON.stringify({ + name: 'inkspan-reference-browser-consumer', + private: true, + type: 'module', + packageManager: packageMetadata.packageManager, + dependencies: { + [packageMetadata.name]: `file:${tarballPath}`, + react: packageMetadata.devDependencies.react, + 'react-dom': packageMetadata.devDependencies['react-dom'], + }, + })); + run('pnpm', ['install', '--prefer-offline', '--ignore-scripts', '--no-frozen-lockfile'], { cwd: consumerDirectory }); + const extractedDirectory = realpathSync(join(consumerDirectory, 'node_modules', ...packageMetadata.name.split('/'))); + assert.ok(extractedDirectory.startsWith(`${realpathSync(consumerDirectory)}${sep}`), 'Installed package escaped the isolated consumer.'); + + const extractedMetadata = JSON.parse( + readFileSync(join(extractedDirectory, 'package.json'), 'utf8'), + ); + assert.equal(extractedMetadata.name, packageMetadata.name); + assert.equal(extractedMetadata.version, packageMetadata.version); + + const packageEntry = join(extractedDirectory, 'dist/cwl-editor.js'); + assert.equal( + existsSync(packageEntry), + true, + 'Packed browser journey is missing the public ESM entrypoint.', + ); + + for (const spec of specs) { + assert.equal( + existsSync(join(browserDirectory, 'specs', spec)), + true, + `Reference-host browser journey is missing ${spec}.`, + ); + } + + const playwrightArgs = [ + '--dir', + 'tests/browser', + 'exec', + 'playwright', + 'test', + '--config', + 'playwright.config.ts', + '--reporter=json', + ...specs, + ...projects.flatMap((project) => ['--project', project]), + ]; + const browserReportPath = join(temporaryRoot, 'browser-report.json'); + run('pnpm', playwrightArgs, { + env: { + ...process.env, + INKSPAN_BROWSER_PACKAGE_ENTRY: packageEntry, + PLAYWRIGHT_JSON_OUTPUT_FILE: browserReportPath, + }, + timeout: 300_000, + }); + const browserReport = JSON.parse(readFileSync(browserReportPath, 'utf8')); + assert.deepEqual(browserReport.errors, []); + assert.ok(browserReport.stats.expected > 0, 'No browser journeys passed.'); + for (const outcome of ['unexpected', 'skipped', 'flaky']) { + assert.equal(browserReport.stats[outcome], 0, `Browser journeys included ${outcome} tests.`); + } + + writeJson({ + contractVersion: 1, + packageAuthority: 'exact-packed-tarball', + packageSha256, + installedDependencyClosure: true, + projects: projects.length, + specs: specs.length, + tests: browserReport.stats, + status: 'completed', + }); + } finally { + rmSync(temporaryRoot, { recursive: true, force: true }); + } +} + +function main(argv) { + if (argv.length === 1 && argv[0] === '--plan') { + writeJson(planReceipt()); + return; + } + if (argv.length === 1 && argv[0] === '--self-test') { + verifyBrowserJourney(); + return; + } + throw new Error(`Usage: ${command} --plan | --self-test`); +} + +try { + main(process.argv.slice(2)); +} catch (error) { + const message = + error instanceof Error + ? error.message + : 'Reference-host browser journey verification failed.'; + process.stderr.write(`${message}\n`); + process.exitCode = 1; +} diff --git a/examples/reference-host/verify-current-reference-journey.mjs b/examples/reference-host/verify-current-reference-journey.mjs new file mode 100644 index 000000000..551af993d --- /dev/null +++ b/examples/reference-host/verify-current-reference-journey.mjs @@ -0,0 +1,111 @@ +import { spawnSync } from 'node:child_process'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const referenceHostDirectory = dirname(fileURLToPath(import.meta.url)); +const repositoryRoot = resolve(referenceHostDirectory, '..', '..'); +const command = 'node examples/reference-host/verify-current-reference-journey.mjs'; +const steps = Object.freeze([ + Object.freeze({ + args: Object.freeze(['--self-test']), + path: 'examples/reference-host/synthetic-document-repository.mjs', + }), + Object.freeze({ + args: Object.freeze(['--self-test']), + path: 'examples/reference-host/delayed-proposal.mjs', + }), + Object.freeze({ + args: Object.freeze(['--self-test']), + path: 'examples/reference-host/autosave-view-model.mjs', + }), + Object.freeze({ + args: Object.freeze(['--self-test']), + path: 'examples/reference-host/collaboration-provider-lifecycle.mjs', + }), + Object.freeze({ + args: Object.freeze(['--self-test']), + path: 'examples/reference-host/host-authorized-collaboration.mjs', + }), + Object.freeze({ + args: Object.freeze([]), + path: 'examples/reference-host/verify-packed-artifact.mjs', + }), + Object.freeze({ + args: Object.freeze(['--self-test']), + path: 'examples/reference-host/verify-application-ssr.mjs', + }), + Object.freeze({ + args: Object.freeze(['--self-test']), + path: 'examples/reference-host/verify-packed-office-journey.mjs', + }), + Object.freeze({ + args: Object.freeze(['--self-test']), + path: 'examples/reference-host/verify-browser-journey.mjs', + }), +]); + +function writeJson(value) { + process.stdout.write(`${JSON.stringify(value)}\n`); +} + +function planReceipt() { + return { + command, + contractVersion: 1, + status: 'plan', + steps, + }; +} + +function runStep(step) { + const result = spawnSync( + process.execPath, + [resolve(repositoryRoot, step.path), ...step.args], + { + cwd: repositoryRoot, + encoding: 'utf8', + maxBuffer: 4 * 1024 * 1024, + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 360_000, + }, + ); + + if ( + result.error !== undefined || + result.signal !== null || + result.status !== 0 + ) { + throw new Error('Reference-host current journey verification failed.'); + } +} + +function main(argv) { + if (argv.length === 1 && argv[0] === '--plan') { + writeJson(planReceipt()); + return; + } + if (argv.length !== 0) { + throw new Error(`Usage: ${command} [--plan]`); + } + + for (const step of steps) { + runStep(step); + } + + writeJson({ + contractVersion: 1, + status: 'completed', + steps: steps.length, + }); +} + +try { + main(process.argv.slice(2)); +} catch (error) { + const message = + error instanceof Error + ? error.message + : 'Reference-host current journey verification failed.'; + process.stderr.write(`${message}\n`); + process.exitCode = 1; +} diff --git a/examples/reference-host/verify-office-handoff.mjs b/examples/reference-host/verify-office-handoff.mjs new file mode 100644 index 000000000..a8944f870 --- /dev/null +++ b/examples/reference-host/verify-office-handoff.mjs @@ -0,0 +1,138 @@ +import { spawnSync } from 'node:child_process'; +import { copyFile, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { dirname, delimiter, resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const TITLE = 'Inkspan acquisition handoff'; +const MARKDOWN = '**Buyer-ready** body.\n\nSecond acquisition paragraph.'; +const EXPECTED_BODY = ['Buyer-ready body.', 'Second acquisition paragraph.']; + +function requiredEnvironment(name) { + const value = process.env[name]?.trim(); + if (!value) { + throw new Error(`${name} is required`); + } + return value; +} + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { + cwd: options.cwd, + env: options.env, + encoding: 'utf8', + shell: false, + }); + if (result.error) throw result.error; + if (result.status !== 0) { + throw new Error( + [ + `${command} exited with status ${result.status}`, + result.stdout?.trim(), + result.stderr?.trim(), + ] + .filter(Boolean) + .join('\n'), + ); + } +} + +async function main() { + const repositoryRoot = resolve( + dirname(fileURLToPath(import.meta.url)), + '../..', + ); + const packageEntry = resolve(requiredEnvironment('INKSPAN_BROWSER_PACKAGE_ENTRY')); + const packageRoot = resolve(dirname(packageEntry), '..'); + const expectedEntry = resolve(packageRoot, 'dist/cwl-editor.js'); + if (packageEntry !== expectedEntry) { + throw new Error('packed package entry must resolve to dist/cwl-editor.js'); + } + + const packageMetadata = JSON.parse( + await readFile(resolve(packageRoot, 'package.json'), 'utf8'), + ); + if (packageMetadata.name !== '@contextualwisdomlab/cwl-editor') { + throw new Error('packed package has an unexpected package identity'); + } + + const nodeModulesRoot = resolve(packageRoot, '../..'); + const consumerRoot = dirname(nodeModulesRoot); + const temporaryDirectory = await mkdtemp( + resolve(consumerRoot, '.inkspan-office-handoff-'), + ); + + try { + const copiedHandoff = resolve(temporaryDirectory, 'office-handoff.mjs'); + const requestPath = resolve(temporaryDirectory, 'request.json'); + const outputPath = resolve(temporaryDirectory, 'handoff.docx'); + await copyFile( + resolve(repositoryRoot, 'examples/reference-host/office-handoff.mjs'), + copiedHandoff, + ); + + const { createReferenceDocxRequest } = await import( + pathToFileURL(copiedHandoff).href + ); + const request = createReferenceDocxRequest({ + title: TITLE, + markdown: MARKDOWN, + }); + if ( + request.format !== 'docx' || + request.title !== TITLE || + request.blocks?.length !== EXPECTED_BODY.length || + request.blocks.some( + (block, index) => + block?.type !== 'paragraph' || block.text !== EXPECTED_BODY[index], + ) + ) { + throw new Error('packed Markdown handoff produced an unexpected Office request'); + } + await writeFile(requestPath, `${JSON.stringify(request)}\n`, 'utf8'); + + const python = process.env.INKSPAN_OFFICE_PYTHON?.trim() || resolve( + repositoryRoot, 'office/.venv', + process.platform === 'win32' ? 'Scripts/python.exe' : 'bin/python', + ); + const pythonPath = [ + resolve(repositoryRoot, 'office/src'), + process.env.PYTHONPATH, + ] + .filter(Boolean) + .join(delimiter); + const pythonEnvironment = { + ...process.env, + PYTHONPATH: pythonPath, + }; + + run( + python, + ['-m', 'inkspan_office.cli', requestPath, outputPath], + { cwd: repositoryRoot, env: pythonEnvironment }, + ); + + const validation = ` +from pathlib import Path +import sys +from docx import Document + +output = Path(sys.argv[1]) +if not output.read_bytes().startswith(b"PK"): + raise SystemExit("Office output is not an OOXML package") +document = Document(output) +if document.core_properties.title != ${JSON.stringify(TITLE)}: + raise SystemExit("DOCX title metadata does not match the handoff") +paragraphs = [paragraph.text for paragraph in document.paragraphs] +if paragraphs != ${JSON.stringify([TITLE, ...EXPECTED_BODY])}: + raise SystemExit(f"unexpected DOCX paragraphs: {paragraphs!r}") +`; + run(python, ['-c', validation, outputPath], { + cwd: repositoryRoot, + env: pythonEnvironment, + }); + } finally { + await rm(temporaryDirectory, { recursive: true, force: true }); + } +} + +await main(); diff --git a/examples/reference-host/verify-packed-artifact.mjs b/examples/reference-host/verify-packed-artifact.mjs new file mode 100644 index 000000000..4547130b7 --- /dev/null +++ b/examples/reference-host/verify-packed-artifact.mjs @@ -0,0 +1,331 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { + copyFileSync, + mkdtempSync, + mkdirSync, + readFileSync, + readdirSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, relative, resolve, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repositoryRoot = resolve( + dirname(fileURLToPath(import.meta.url)), + '..', + '..', +); +const packageMetadata = JSON.parse( + readFileSync(join(repositoryRoot, 'package.json'), 'utf8'), +); +const packageName = packageMetadata.name; +const packageVersion = packageMetadata.version; + +function run(command, argumentsList, cwd) { + return execFileSync(command, argumentsList, { + cwd, + encoding: 'utf8', + env: process.env, + stdio: ['ignore', 'pipe', 'pipe'], + }); +} + +function isContained(parentPath, childPath) { + const relation = relative(realpathSync(parentPath), realpathSync(childPath)); + return relation === '' || (!relation.startsWith(`..${sep}`) && relation !== '..'); +} + +const temporaryRoot = mkdtempSync(join(tmpdir(), 'inkspan-reference-host-')); + +try { + run('pnpm', ['build'], repositoryRoot); + + const packDirectory = join(temporaryRoot, 'pack'); + mkdirSync(packDirectory, { recursive: true }); + run('pnpm', ['pack', '--pack-destination', packDirectory], repositoryRoot); + + const tarballs = readdirSync(packDirectory).filter((name) => name.endsWith('.tgz')); + assert.equal(tarballs.length, 1, 'Expected exactly one packed Inkspan tarball.'); + const tarballPath = join(packDirectory, tarballs[0]); + + const hostDirectory = join(temporaryRoot, 'host'); + mkdirSync(hostDirectory, { recursive: true }); + copyFileSync( + join(repositoryRoot, 'examples/reference-host/autosave-view-model.mjs'), + join(hostDirectory, 'autosave-view-model.mjs'), + ); + writeFileSync( + join(hostDirectory, 'package.json'), + `${JSON.stringify( + { + name: 'inkspan-reference-host-packed-consumer', + private: true, + type: 'module', + packageManager: packageMetadata.packageManager, + dependencies: { + [packageName]: `file:${tarballPath}`, + react: packageMetadata.devDependencies.react, + 'react-dom': packageMetadata.devDependencies['react-dom'], + }, + }, + null, + 2, + )}\n`, + 'utf8', + ); + + // A published library consumer resolves the dependency ranges declared in the + // packed manifest. Prefer the clean-checkout store, but permit the package + // manager to fetch a transitive version that is valid for the packed consumer + // even when that version is not present in Inkspan's development lockfile. + run( + 'pnpm', + ['install', '--prefer-offline', '--ignore-scripts', '--no-frozen-lockfile'], + hostDirectory, + ); + + const consumerPath = join(hostDirectory, 'consumer.mjs'); + writeFileSync( + consumerPath, + `import assert from 'node:assert/strict'; +import React from 'react'; +import { renderToString } from 'react-dom/server'; +import { CwlEditor } from '${packageName}'; +import { createDocumentAutosaveQueue } from '${packageName}/autosave'; +import { dataUriToBytes } from '${packageName}/converter'; +import { markdownToHtml, markdownToPlainText } from '${packageName}/markdown'; +import { createAutosaveViewModel } from './autosave-view-model.mjs'; + +const serverHtml = renderToString( + React.createElement(CwlEditor, { + mode: 'markdown', + defaultValue: '# Packed draft', + formFieldName: 'message_body', + hideToolbar: true, + }), +); +assert.match(serverHtml, /name="message_body"/u); +assert.match(serverHtml, /value="# Packed draft"/u); +assert.equal(typeof createDocumentAutosaveQueue, 'function'); +assert.deepEqual(Array.from(dataUriToBytes('data:text/plain;base64,SGk=').bytes), [72, 105]); +const markdownSource = '# Packed handoff\\n\\nBuyer text'; +const projectedHtml = markdownToHtml(markdownSource); +assert.equal(projectedHtml, markdownToHtml(markdownSource)); +assert.match(markdownToPlainText(markdownSource), /Packed handoff/u); +assert.match(markdownToPlainText(markdownSource), /Buyer text/u); + +const digestHex = '41'.repeat(32); +const evidence = Object.freeze({ + envelope: Object.freeze({ + schemaId: 'https://inkspan.io/schemas/document-envelope/v1', + schemaVersion: 1, + documentJson: Object.freeze({ type: 'doc' }), + }), + revision: Object.freeze({ + algorithm: 'SHA-256', + digestHex, + strongEntityTag: \`"sha256-\${digestHex}"\`, + }), +}); +const autosaveViewModel = createAutosaveViewModel(); +const autosaveViewStates = []; +const autosaveQueue = createDocumentAutosaveQueue({ + save() { + return { status: 'saved' }; + }, + onSnapshotChange(snapshot) { + autosaveViewStates.push(autosaveViewModel.observe(snapshot).viewState); + }, +}); +assert.equal(autosaveViewModel.observe(autosaveQueue.getSnapshot()).viewState, 'clean'); +const autosaveOutcome = await autosaveQueue.enqueue(evidence); +await autosaveQueue.flush(); +await Promise.resolve(); +assert.equal(autosaveOutcome.status, 'saved'); +assert.ok(autosaveViewStates.includes('saving')); +assert.equal(autosaveViewStates.at(-1), 'clean'); +assert.equal((await autosaveQueue.close()).state, 'closed'); + +const rootEntry = import.meta.resolve('${packageName}'); +const autosaveEntry = import.meta.resolve('${packageName}/autosave'); +const converterEntry = import.meta.resolve('${packageName}/converter'); +const markdownEntry = import.meta.resolve('${packageName}/markdown'); +const styleEntry = import.meta.resolve('${packageName}/styles.css'); +const fullFontEntry = import.meta.resolve('${packageName}/fonts.css'); +const latinFontEntry = import.meta.resolve('${packageName}/fonts-latin.css'); +for (const entry of [ + rootEntry, + autosaveEntry, + converterEntry, + markdownEntry, + styleEntry, + fullFontEntry, + latinFontEntry, +]) { + assert.ok(entry.startsWith('file:'), 'Packed package export did not resolve to a file URL.'); +} + +process.stdout.write(JSON.stringify({ + serverRenderedNamedField: true, + autosaveObserverWired: true, + converterRoundTrip: true, + markdownProjection: true, + rootEntry, + autosaveEntry, + converterEntry, + markdownEntry, + styleEntry, + fullFontEntry, + latinFontEntry, +})); +`, + 'utf8', + ); + + const consumerResult = JSON.parse(run(process.execPath, [consumerPath], hostDirectory)); + + const commonJsConsumerPath = join(hostDirectory, 'consumer.cjs'); + writeFileSync( + commonJsConsumerPath, + `const assert = require('node:assert/strict'); +const React = require('react'); +const { renderToString } = require('react-dom/server'); +const { CwlEditor } = require('${packageName}'); +const { createDocumentAutosaveQueue } = require('${packageName}/autosave'); +const { dataUriToBytes } = require('${packageName}/converter'); +const { markdownToHtml, markdownToPlainText } = require('${packageName}/markdown'); + +const serverHtml = renderToString( + React.createElement(CwlEditor, { + mode: 'markdown', + defaultValue: '# Packed CommonJS draft', + formFieldName: 'message_body', + hideToolbar: true, + }), +); +assert.match(serverHtml, /name="message_body"/u); +assert.match(serverHtml, /value="# Packed CommonJS draft"/u); +assert.equal(typeof createDocumentAutosaveQueue, 'function'); +assert.deepEqual(Array.from(dataUriToBytes('data:text/plain;base64,T0s=').bytes), [79, 75]); +const markdownSource = '# Packed CommonJS handoff\\n\\nBuyer text'; +const projectedHtml = markdownToHtml(markdownSource); +assert.equal(projectedHtml, markdownToHtml(markdownSource)); +assert.match(markdownToPlainText(markdownSource), /Packed CommonJS handoff/u); +assert.match(markdownToPlainText(markdownSource), /Buyer text/u); + +process.stdout.write(JSON.stringify({ + serverRenderedNamedField: true, + converterRoundTrip: true, + markdownProjection: true, + rootEntry: require.resolve('${packageName}'), + autosaveEntry: require.resolve('${packageName}/autosave'), + converterEntry: require.resolve('${packageName}/converter'), + markdownEntry: require.resolve('${packageName}/markdown'), +})); +`, + 'utf8', + ); + + const commonJsConsumerResult = JSON.parse( + run(process.execPath, [commonJsConsumerPath], hostDirectory), + ); + + const installedPackageDirectory = join( + hostDirectory, + 'node_modules', + ...packageName.split('/'), + ); + const installedMetadata = JSON.parse( + readFileSync(join(installedPackageDirectory, 'package.json'), 'utf8'), + ); + + assert.equal(installedMetadata.name, packageName); + assert.equal(installedMetadata.version, packageVersion); + assert.equal( + isContained(join(hostDirectory, 'node_modules'), installedPackageDirectory), + true, + 'Installed package escaped the isolated host node_modules tree.', + ); + + const executableEntries = [ + ['ESM root', fileURLToPath(consumerResult.rootEntry)], + ['ESM autosave', fileURLToPath(consumerResult.autosaveEntry)], + ['ESM converter', fileURLToPath(consumerResult.converterEntry)], + ['ESM markdown', fileURLToPath(consumerResult.markdownEntry)], + ['CommonJS root', commonJsConsumerResult.rootEntry], + ['CommonJS autosave', commonJsConsumerResult.autosaveEntry], + ['CommonJS converter', commonJsConsumerResult.converterEntry], + ['CommonJS markdown', commonJsConsumerResult.markdownEntry], + ]; + for (const [label, entry] of executableEntries) { + assert.equal( + isContained(installedPackageDirectory, entry), + true, + `${label} import escaped the installed packed package.`, + ); + assert.equal( + relative(realpathSync(installedPackageDirectory), realpathSync(entry)).startsWith( + `dist${sep}`, + ), + true, + `${label} executable import did not resolve through packed dist/.`, + ); + } + + const publicAssetEntries = [ + ['stylesheet', consumerResult.styleEntry], + ['full font stylesheet', consumerResult.fullFontEntry], + ['Latin font stylesheet', consumerResult.latinFontEntry], + ]; + for (const [label, entry] of publicAssetEntries) { + assert.equal( + isContained(installedPackageDirectory, fileURLToPath(entry)), + true, + `${label} export escaped the installed packed package.`, + ); + } + const publicAssetEntriesContained = true; + + const sourceImportDetected = executableEntries.some(([, entry]) => + relative(realpathSync(installedPackageDirectory), realpathSync(entry)).startsWith( + `src${sep}`, + ), + ); + assert.equal( + sourceImportDetected, + false, + 'Packed consumer unexpectedly resolved an executable import through source files.', + ); + + const esmServerRenderedNamedField = consumerResult.serverRenderedNamedField === true; + const commonJsServerRenderedNamedField = + commonJsConsumerResult.serverRenderedNamedField === true; + + process.stdout.write( + `${JSON.stringify({ + packageName, + packageVersion, + installedFromTarball: true, + consumerInstallCompleted: true, + // Preserve the established verifier receipt while extending it with + // format-specific evidence below. + serverRenderedNamedField: esmServerRenderedNamedField, + esmServerRenderedNamedField, + commonJsServerRenderedNamedField, + autosaveObserverWired: consumerResult.autosaveObserverWired === true, + esmConverterRoundTrip: consumerResult.converterRoundTrip === true, + commonJsConverterRoundTrip: commonJsConsumerResult.converterRoundTrip === true, + esmMarkdownProjection: consumerResult.markdownProjection === true, + commonJsMarkdownProjection: commonJsConsumerResult.markdownProjection === true, + publicAssetEntriesContained, + executableEntriesContained: true, + sourceImportDetected, + })}\n`, + ); +} finally { + rmSync(temporaryRoot, { recursive: true, force: true }); +} diff --git a/examples/reference-host/verify-packed-office-journey.mjs b/examples/reference-host/verify-packed-office-journey.mjs new file mode 100644 index 000000000..917babbce --- /dev/null +++ b/examples/reference-host/verify-packed-office-journey.mjs @@ -0,0 +1,148 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { + existsSync, + mkdtempSync, + mkdirSync, + readFileSync, + readdirSync, + rmSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const referenceHostDirectory = dirname(fileURLToPath(import.meta.url)); +const repositoryRoot = resolve(referenceHostDirectory, '..', '..'); +const command = + 'node examples/reference-host/verify-packed-office-journey.mjs'; + +function writeJson(value) { + process.stdout.write(`${JSON.stringify(value)}\n`); +} + +function planReceipt() { + return { + command, + contractVersion: 1, + packageAuthority: 'exact-packed-tarball', + status: 'plan', + }; +} + +function run(commandName, args, options = {}) { + const result = spawnSync(commandName, args, { + cwd: options.cwd ?? repositoryRoot, + encoding: 'utf8', + env: options.env ?? process.env, + maxBuffer: 8 * 1024 * 1024, + stdio: ['ignore', 'pipe', 'pipe'], + timeout: options.timeout ?? 180_000, + }); + + if ( + result.error !== undefined || + result.signal !== null || + result.status !== 0 + ) { + throw new Error('Reference-host packed Office journey verification failed.'); + } + + return result.stdout; +} + +function verifyPackedOfficeJourney() { + const packageMetadata = JSON.parse( + readFileSync(resolve(repositoryRoot, 'package.json'), 'utf8'), + ); + const temporaryRoot = mkdtempSync(join(tmpdir(), 'inkspan-office-journey-')); + + try { + run('pnpm', ['build'], { timeout: 240_000 }); + + const packDirectory = join(temporaryRoot, 'pack'); + mkdirSync(packDirectory, { recursive: true }); + run('pnpm', ['pack', '--pack-destination', packDirectory]); + + const tarballs = readdirSync(packDirectory).filter((name) => + name.endsWith('.tgz'), + ); + assert.equal(tarballs.length, 1, 'Expected exactly one packed Inkspan tarball.'); + + const packageDirectory = join( + temporaryRoot, + 'host', + 'node_modules', + '@contextualwisdomlab', + 'cwl-editor', + ); + mkdirSync(packageDirectory, { recursive: true }); + run( + 'tar', + [ + '-xzf', + join(packDirectory, tarballs[0]), + '--strip-components=1', + '-C', + packageDirectory, + ], + { timeout: 60_000 }, + ); + + const packedMetadata = JSON.parse( + readFileSync(join(packageDirectory, 'package.json'), 'utf8'), + ); + assert.equal(packedMetadata.name, packageMetadata.name); + assert.equal(packedMetadata.version, packageMetadata.version); + + const packageEntry = join(packageDirectory, 'dist', 'cwl-editor.js'); + assert.equal( + existsSync(packageEntry), + true, + 'Packed Office journey is missing the public ESM entrypoint.', + ); + + run( + process.execPath, + [resolve(referenceHostDirectory, 'verify-office-handoff.mjs')], + { + env: { + ...process.env, + INKSPAN_BROWSER_PACKAGE_ENTRY: packageEntry, + }, + timeout: 180_000, + }, + ); + + writeJson({ + contractVersion: 1, + packageAuthority: 'exact-packed-tarball', + status: 'completed', + }); + } finally { + rmSync(temporaryRoot, { recursive: true, force: true }); + } +} + +function main(argv) { + if (argv.length === 1 && argv[0] === '--plan') { + writeJson(planReceipt()); + return; + } + if (argv.length === 1 && argv[0] === '--self-test') { + verifyPackedOfficeJourney(); + return; + } + throw new Error(`Usage: ${command} --plan | --self-test`); +} + +try { + main(process.argv.slice(2)); +} catch (error) { + const message = + error instanceof Error + ? error.message + : 'Reference-host packed Office journey verification failed.'; + process.stderr.write(`${message}\n`); + process.exitCode = 1; +} diff --git a/src/referenceHostApplicationHydration.test.tsx b/src/referenceHostApplicationHydration.test.tsx new file mode 100644 index 000000000..210cf6413 --- /dev/null +++ b/src/referenceHostApplicationHydration.test.tsx @@ -0,0 +1,73 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +const applicationPath = resolve( + process.cwd(), + 'examples/reference-host/reference-host-app.tsx', +); +const clientBoundaryPath = resolve( + process.cwd(), + 'examples/reference-host/reference-host-client.tsx', +); +const applicationSource = readFileSync(applicationPath, 'utf8'); +const clientBoundarySource = existsSync(clientBoundaryPath) + ? readFileSync(clientBoundaryPath, 'utf8') + : ''; +const nativeFormSource = readFileSync( + resolve(process.cwd(), 'examples/reference-host/native-form-host.tsx'), + 'utf8', +); + +describe('reference-host application hydration contract', () => { + it('keeps the deterministic server shell outside a narrow client hydration boundary', () => { + expect(applicationSource.startsWith("'use client';\n")).toBe(false); + expect(applicationSource).toContain( + "from './reference-host-client.js'", + ); + expect(clientBoundarySource.startsWith("'use client';\n")).toBe(true); + expect(applicationSource).toContain( + '
', + ); + expect(applicationSource).toContain(' ('); + expect(clientBoundarySource).toContain(' { + expect(applicationSource).toContain("controlMode = 'uncontrolled'"); + expect(applicationSource).toContain('controlMode={controlMode}'); + expect(clientBoundarySource).toContain('controlMode={controlMode}'); + }); + + it('preserves the public-package and host-authority boundary for the hydrated form', () => { + expect(nativeFormSource).toContain( + "from '@contextualwisdomlab/cwl-editor'", + ); + expect(nativeFormSource).toContain('formFieldName="message_body"'); + expect(applicationSource).not.toContain('/src/'); + expect(applicationSource).not.toContain('../../src'); + expect(applicationSource).not.toContain('fetch('); + expect(applicationSource).not.toContain('localStorage'); + expect(applicationSource).not.toContain('process.env'); + expect(clientBoundarySource).not.toContain('/src/'); + expect(clientBoundarySource).not.toContain('../../src'); + expect(clientBoundarySource).not.toContain('fetch('); + expect(clientBoundarySource).not.toContain('localStorage'); + expect(clientBoundarySource).not.toContain('process.env'); + }); +}); \ No newline at end of file diff --git a/src/referenceHostApplicationSsrJourney.test.ts b/src/referenceHostApplicationSsrJourney.test.ts new file mode 100644 index 000000000..44fabbb40 --- /dev/null +++ b/src/referenceHostApplicationSsrJourney.test.ts @@ -0,0 +1,47 @@ +import { execFile } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { promisify } from 'node:util'; + +import { describe, expect, it } from 'vitest'; + +const repositoryRoot = process.cwd(); +const verifierPath = resolve( + repositoryRoot, + 'examples/reference-host/verify-application-ssr.mjs', +); +const packageMetadata = JSON.parse( + readFileSync(resolve(repositoryRoot, 'package.json'), 'utf8'), +) as { name: string; version: string }; + +describe('reference-host application SSR acceptance', () => { + it( + 'server-renders the application shell against the exact packed package while deferring the client editor boundary', + async () => { + const { stdout: output } = await promisify(execFile)( + process.execPath, + [verifierPath, '--self-test'], + { + cwd: repositoryRoot, + encoding: 'utf8', + timeout: 180_000, + }, + ); + + const result = JSON.parse(output.trim()) as { + packageName?: unknown; + packageVersion?: unknown; + packageAuthority?: unknown; + applicationServerRendered?: unknown; + clientEditorDeferred?: unknown; + }; + + expect(result.packageName).toBe(packageMetadata.name); + expect(result.packageVersion).toBe(packageMetadata.version); + expect(result.packageAuthority).toBe('exact-packed-tarball'); + expect(result.applicationServerRendered).toBe(true); + expect(result.clientEditorDeferred).toBe(true); + }, + 180_000, + ); +}); diff --git a/src/referenceHostAuthorizedCollaboration.test.ts b/src/referenceHostAuthorizedCollaboration.test.ts new file mode 100644 index 000000000..bced5d446 --- /dev/null +++ b/src/referenceHostAuthorizedCollaboration.test.ts @@ -0,0 +1,164 @@ +import { execFileSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { pathToFileURL } from 'node:url'; +import { resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +const authorizationFixturePath = resolve( + process.cwd(), + 'examples/reference-host/host-authorized-collaboration.mjs', +); +const lifecycleFixturePath = resolve( + process.cwd(), + 'examples/reference-host/collaboration-provider-lifecycle.mjs', +); + +describe('reference-host authorized collaboration journey', () => { + it('ships a host-owned authorization gate outside the published Inkspan runtime', () => { + expect(existsSync(authorizationFixturePath)).toBe(true); + }); + + it('redacts host authorization failures before provider construction', () => { + if (!existsSync(authorizationFixturePath)) return; + const moduleUrl = JSON.stringify(pathToFileURL(authorizationFixturePath).href); + const script = ` + import { createHostAuthorizedProviderFactory } from ${moduleUrl}; + const privateCause = 'private buyer authorization reason'; + const events = []; + const providerFactory = createHostAuthorizedProviderFactory({ + authorize(context) { + events.push('authorize:' + context.actorId + ':' + context.roomId + ':' + context.generation); + throw new Error(privateCause); + }, + createProvider() { + events.push('provider:create'); + return {}; + }, + }); + let error = null; + try { + providerFactory({ + document: {}, + roomId: 'buyer-room', + actorId: 'buyer-actor', + generation: 1, + }); + } catch (failure) { + error = failure instanceof Error ? failure.message : 'unexpected error'; + } + process.stdout.write(JSON.stringify({ + error, + events, + leakedPrivateCause: typeof error === 'string' && error.includes(privateCause), + })); + `; + const output = execFileSync( + process.execPath, + ['--input-type=module', '--eval', script], + { encoding: 'utf8' }, + ); + + expect(JSON.parse(output)).toEqual({ + error: 'collaboration provider authorization failed.', + events: ['authorize:buyer-actor:buyer-room:1'], + leakedPrivateCause: false, + }); + }); + + it('requires an exact synchronous true decision before constructing a provider', () => { + if (!existsSync(authorizationFixturePath)) return; + const moduleUrl = JSON.stringify(pathToFileURL(authorizationFixturePath).href); + const script = ` + import { createHostAuthorizedProviderFactory } from ${moduleUrl}; + const events = []; + const providerFactory = createHostAuthorizedProviderFactory({ + authorize() { + events.push('authorize'); + return Promise.resolve(true); + }, + createProvider() { + events.push('provider:create'); + return {}; + }, + }); + let error = null; + try { + providerFactory({ document: {}, roomId: 'buyer-room', actorId: 'buyer-actor', generation: 1 }); + } catch (failure) { + error = failure instanceof Error ? failure.message : 'unexpected error'; + } + process.stdout.write(JSON.stringify({ error, events })); + `; + const output = execFileSync( + process.execPath, + ['--input-type=module', '--eval', script], + { encoding: 'utf8' }, + ); + + expect(JSON.parse(output)).toEqual({ + error: 'collaboration provider authorization failed.', + events: ['authorize'], + }); + }); + + it('re-authorizes the exact host room and actor before every provider generation', () => { + if (!existsSync(authorizationFixturePath) || !existsSync(lifecycleFixturePath)) return; + const authorizationUrl = JSON.stringify(pathToFileURL(authorizationFixturePath).href); + const lifecycleUrl = JSON.stringify(pathToFileURL(lifecycleFixturePath).href); + const script = ` + import { createHostAuthorizedProviderFactory } from ${authorizationUrl}; + import { createHostCollaborationLifecycle } from ${lifecycleUrl}; + const events = []; + const providerFactory = createHostAuthorizedProviderFactory({ + authorize(context) { + events.push('authorize:' + context.actorId + ':' + context.roomId + ':' + context.generation); + return true; + }, + createProvider(context) { + const generation = context.generation; + events.push('provider:create:' + generation); + return { + connect() { events.push('provider:connect:' + generation); }, + disconnect() { events.push('provider:disconnect:' + generation); }, + destroy() { events.push('provider:destroy:' + generation); }, + }; + }, + }); + const lifecycle = createHostCollaborationLifecycle({ + documentFactory() { + return { destroy() { events.push('document:destroy'); } }; + }, + providerFactory, + roomId: 'buyer-room', + actorId: 'buyer-actor', + }); + lifecycle.connect(); + lifecycle.reconnect(); + lifecycle.dispose(); + process.stdout.write(JSON.stringify({ events, snapshot: lifecycle.getSnapshot() })); + `; + const output = execFileSync( + process.execPath, + ['--input-type=module', '--eval', script], + { encoding: 'utf8' }, + ); + + expect(JSON.parse(output)).toEqual({ + events: [ + 'authorize:buyer-actor:buyer-room:1', + 'provider:create:1', + 'provider:connect:1', + 'provider:disconnect:1', + 'provider:destroy:1', + 'authorize:buyer-actor:buyer-room:2', + 'provider:create:2', + 'provider:connect:2', + 'provider:disconnect:2', + 'provider:destroy:2', + 'document:destroy', + ], + snapshot: { providerGeneration: 2, status: 'disposed' }, + }); + }); +}); diff --git a/src/referenceHostAutosaveSnapshotShape.test.ts b/src/referenceHostAutosaveSnapshotShape.test.ts new file mode 100644 index 000000000..f92843349 --- /dev/null +++ b/src/referenceHostAutosaveSnapshotShape.test.ts @@ -0,0 +1,97 @@ +import { execFileSync } from 'node:child_process'; +import { pathToFileURL } from 'node:url'; +import { resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +const fixturePath = resolve( + process.cwd(), + 'examples/reference-host/autosave-view-model.mjs', +); + +const baseSnapshotSource = ` + const validSnapshot = (state = 'idle', blockedReason = null) => ({ + state, + blockedReason, + activeStrongEntityTag: null, + pendingStrongEntityTag: null, + lastSavedStrongEntityTag: null, + }); +`; + +describe('reference-host autosave snapshot shape', () => { + it('rejects unknown authority-looking fields across enumerable, hidden, and symbol keys', () => { + const moduleUrl = JSON.stringify(pathToFileURL(fixturePath).href); + const script = ` + import { createAutosaveViewModel } from ${moduleUrl}; + ${baseSnapshotSource} + + function observeError(kind) { + const candidate = validSnapshot(); + if (kind === 'enumerable') { + candidate.authorization = 'owner'; + } else if (kind === 'hidden') { + Object.defineProperty(candidate, 'authorization', { + value: 'owner', + enumerable: false, + }); + } else { + candidate[Symbol('authorization')] = 'owner'; + } + try { + createAutosaveViewModel().observe(candidate); + return null; + } catch (error) { + return error instanceof Error ? error.message : 'unexpected error'; + } + } + + process.stdout.write(JSON.stringify({ + enumerable: observeError('enumerable'), + hidden: observeError('hidden'), + symbol: observeError('symbol'), + })); + `; + const output = execFileSync( + process.execPath, + ['--input-type=module', '--eval', script], + { encoding: 'utf8' }, + ); + + expect(JSON.parse(output)).toEqual({ + enumerable: 'autosave snapshot is invalid.', + hidden: 'autosave snapshot is invalid.', + symbol: 'autosave snapshot is invalid.', + }); + }); + + it('does not mutate retry presentation state when a malformed blocked snapshot is rejected', () => { + const moduleUrl = JSON.stringify(pathToFileURL(fixturePath).href); + const script = ` + import { createAutosaveViewModel } from ${moduleUrl}; + ${baseSnapshotSource} + + const viewModel = createAutosaveViewModel(); + const malformed = validSnapshot('blocked', 'conflict'); + malformed.authorization = 'owner'; + let malformedError = null; + try { + viewModel.observe(malformed); + } catch (error) { + malformedError = error instanceof Error ? error.message : 'unexpected error'; + } + const afterRejected = viewModel.observe(validSnapshot('saving')).viewState; + process.stdout.write(JSON.stringify({ afterRejected, malformedError })); + `; + const output = execFileSync( + process.execPath, + ['--input-type=module', '--eval', script], + { encoding: 'utf8' }, + ); + + expect(JSON.parse(output)).toEqual({ + afterRejected: 'saving', + malformedError: 'autosave snapshot is invalid.', + }); + }); +}); diff --git a/src/referenceHostAutosaveViewModel.test.ts b/src/referenceHostAutosaveViewModel.test.ts new file mode 100644 index 000000000..052263d13 --- /dev/null +++ b/src/referenceHostAutosaveViewModel.test.ts @@ -0,0 +1,197 @@ +import { execFileSync } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import { pathToFileURL } from 'node:url'; +import { resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +const fixturePath = resolve( + process.cwd(), + 'examples/reference-host/autosave-view-model.mjs', +); + +describe('reference-host autosave presentation contract', () => { + it('ships one host-owned autosave view-model fixture outside the published runtime', () => { + expect(existsSync(fixturePath)).toBe(true); + }); + + it('maps programmatic lifecycle transitions to localization keys without exposing validators', () => { + if (!existsSync(fixturePath)) return; + const source = readFileSync(fixturePath, 'utf8'); + + expect(source).not.toMatch(/(?:from|import\()\s*['"][^'"]*src\//u); + expect(source).not.toMatch(/\b(?:fetch|XMLHttpRequest|WebSocket)\b/u); + expect(source).toContain('REFERENCE_ONLY'); + expect(source).toContain('messageKey'); + expect(source).toContain('blockedReason'); + expect(source).toContain('observe'); + expect(source).not.toContain('recoveryPhase'); + expect(source).not.toContain('document body'); + }); + + it('derives clean, saving, queued, conflict, failed, retrying, recovered, closing, and closed states', () => { + if (!existsSync(fixturePath)) return; + const output = execFileSync(process.execPath, [fixturePath, '--self-test'], { + encoding: 'utf8', + }); + + expect(JSON.parse(output)).toEqual({ + clean: 'clean', + closed: 'closed', + closing: 'closing', + conflict: 'conflict', + failed: 'failed', + queued: 'queued', + recovered: 'recovered', + retrying: 'retrying', + saving: 'saving', + }); + }); + + it('does not claim recovery unless a retrying save was observed', () => { + if (!existsSync(fixturePath)) return; + const moduleUrl = JSON.stringify(pathToFileURL(fixturePath).href); + const script = ` + import { createAutosaveViewModel } from ${moduleUrl}; + const snapshot = (state, blockedReason = null) => ({ + state, + blockedReason, + activeStrongEntityTag: null, + pendingStrongEntityTag: null, + lastSavedStrongEntityTag: null, + }); + const viewModel = createAutosaveViewModel(); + const blocked = viewModel.observe(snapshot('blocked', 'conflict')).viewState; + const idleWithoutRetry = viewModel.observe(snapshot('idle')).viewState; + process.stdout.write(JSON.stringify({ blocked, idleWithoutRetry })); + `; + const output = execFileSync( + process.execPath, + ['--input-type=module', '--eval', script], + { encoding: 'utf8' }, + ); + + expect(JSON.parse(output)).toEqual({ + blocked: 'conflict', + idleWithoutRetry: 'clean', + }); + }); + + it('rejects empty non-null validator fields instead of treating them as lifecycle evidence', () => { + if (!existsSync(fixturePath)) return; + const output = execFileSync( + process.execPath, + [fixturePath, '--invalid-validator-self-test'], + { encoding: 'utf8' }, + ); + + expect(JSON.parse(output)).toEqual({ + activeError: 'activeStrongEntityTag is invalid.', + lastSavedError: 'lastSavedStrongEntityTag is invalid.', + pendingError: 'pendingStrongEntityTag is invalid.', + }); + }); + + it('bounds non-null validator fields before projecting autosave state', () => { + if (!existsSync(fixturePath)) return; + const moduleUrl = JSON.stringify(pathToFileURL(fixturePath).href); + const script = ` + import { createAutosaveViewModel } from ${moduleUrl}; + const oversized = 'x'.repeat(257); + const candidate = { + state: 'saving', + blockedReason: null, + activeStrongEntityTag: null, + pendingStrongEntityTag: null, + lastSavedStrongEntityTag: null, + }; + const errors = {}; + for (const field of [ + 'activeStrongEntityTag', + 'pendingStrongEntityTag', + 'lastSavedStrongEntityTag', + ]) { + try { + createAutosaveViewModel().observe({ ...candidate, [field]: oversized }); + errors[field] = null; + } catch (error) { + errors[field] = error instanceof Error ? error.message : 'unexpected error'; + } + } + process.stdout.write(JSON.stringify(errors)); + `; + const output = execFileSync( + process.execPath, + ['--input-type=module', '--eval', script], + { encoding: 'utf8' }, + ); + + expect(JSON.parse(output)).toEqual({ + activeStrongEntityTag: 'activeStrongEntityTag is invalid.', + pendingStrongEntityTag: 'pendingStrongEntityTag is invalid.', + lastSavedStrongEntityTag: 'lastSavedStrongEntityTag is invalid.', + }); + }); + + it('rejects weak and malformed values labeled as strong entity tags', () => { + if (!existsSync(fixturePath)) return; + const moduleUrl = JSON.stringify(pathToFileURL(fixturePath).href); + const script = ` + import { createAutosaveViewModel } from ${moduleUrl}; + const candidate = { + state: 'saving', + blockedReason: null, + activeStrongEntityTag: null, + pendingStrongEntityTag: null, + lastSavedStrongEntityTag: null, + }; + const candidates = [ + ['weak', 'W/"weak"'], + ['unquoted', 'opaque'], + ['embeddedQuote', '"bad"quote"'], + ['control', '"line\\nbreak"'], + ['nonOctet', '"😀"'], + ]; + const errors = {}; + for (const [name, value] of candidates) { + try { + createAutosaveViewModel().observe({ + ...candidate, + activeStrongEntityTag: value, + }); + errors[name] = null; + } catch (error) { + errors[name] = error instanceof Error ? error.message : 'unexpected error'; + } + } + process.stdout.write(JSON.stringify(errors)); + `; + const output = execFileSync( + process.execPath, + ['--input-type=module', '--eval', script], + { encoding: 'utf8' }, + ); + + expect(JSON.parse(output)).toEqual({ + weak: 'activeStrongEntityTag is invalid.', + unquoted: 'activeStrongEntityTag is invalid.', + embeddedQuote: 'activeStrongEntityTag is invalid.', + control: 'activeStrongEntityTag is invalid.', + nonOctet: 'activeStrongEntityTag is invalid.', + }); + }); + + it('rejects accessor-backed lifecycle snapshots without invoking them', () => { + if (!existsSync(fixturePath)) return; + const output = execFileSync( + process.execPath, + [fixturePath, '--hostile-accessor-self-test'], + { encoding: 'utf8' }, + ); + + expect(JSON.parse(output)).toEqual({ + error: 'autosave snapshot is invalid.', + getterCalls: 0, + }); + }); +}); diff --git a/src/referenceHostCollaborationAmbiguousConnect.test.ts b/src/referenceHostCollaborationAmbiguousConnect.test.ts new file mode 100644 index 000000000..2d285e22c --- /dev/null +++ b/src/referenceHostCollaborationAmbiguousConnect.test.ts @@ -0,0 +1,187 @@ +import { execFileSync } from 'node:child_process'; +import { pathToFileURL } from 'node:url'; +import { resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +const fixturePath = resolve( + process.cwd(), + 'examples/reference-host/collaboration-provider-lifecycle.mjs', +); + +describe('reference-host ambiguous collaboration connect contract', () => { + it('quarantines a provider after connect throws instead of retrying an indeterminate resource', () => { + const moduleUrl = JSON.stringify(pathToFileURL(fixturePath).href); + const script = ` + import { createHostCollaborationLifecycle } from ${moduleUrl}; + const events = []; + let generation = 0; + let firstProviderConnectAttempts = 0; + const lifecycle = createHostCollaborationLifecycle({ + documentFactory() { + return { + destroy() { events.push('document:destroy'); }, + }; + }, + providerFactory() { + generation += 1; + const current = generation; + events.push('provider:create:' + current); + return { + connect() { + events.push('provider:connect:' + current); + if (current === 1) { + firstProviderConnectAttempts += 1; + if (firstProviderConnectAttempts === 1) { + throw new Error('private ambiguous connect failure'); + } + } + }, + disconnect() { events.push('provider:disconnect:' + current); }, + destroy() { events.push('provider:destroy:' + current); }, + }; + }, + roomId: 'reference-room', + actorId: 'reference-actor', + }); + + let firstError = null; + try { + lifecycle.connect(); + } catch (error) { + firstError = error instanceof Error ? error.message : 'unexpected error'; + } + + let retryError = null; + let retryResult = null; + try { + retryResult = lifecycle.connect(); + } catch (error) { + retryError = error instanceof Error ? error.message : 'unexpected error'; + } + + const afterFailure = lifecycle.getSnapshot(); + const recovered = lifecycle.reconnect(); + const disposeResult = lifecycle.dispose(); + process.stdout.write(JSON.stringify({ + afterFailure, + disposeResult, + events, + firstError, + recovered, + retryError, + retryResult, + })); + `; + const output = execFileSync( + process.execPath, + ['--input-type=module', '--eval', script], + { encoding: 'utf8' }, + ); + + expect(JSON.parse(output)).toEqual({ + afterFailure: { providerGeneration: 1, status: 'disconnected' }, + disposeResult: true, + events: [ + 'provider:create:1', + 'provider:connect:1', + 'provider:disconnect:1', + 'provider:destroy:1', + 'provider:create:2', + 'provider:connect:2', + 'provider:disconnect:2', + 'provider:destroy:2', + 'document:destroy', + ], + firstError: 'collaboration lifecycle connection failed.', + recovered: { providerGeneration: 2, status: 'connected' }, + retryError: 'collaboration lifecycle connection failed.', + retryResult: null, + }); + }); + + it('quarantines promise-returning connect attempts without false success or unhandled rejection', () => { + const moduleUrl = JSON.stringify(pathToFileURL(fixturePath).href); + const script = ` + import { createHostCollaborationLifecycle } from ${moduleUrl}; + const events = []; + let generation = 0; + let unhandledRejections = 0; + process.on('unhandledRejection', () => { + unhandledRejections += 1; + }); + + const lifecycle = createHostCollaborationLifecycle({ + documentFactory() { + return { + destroy() { events.push('document:destroy'); }, + }; + }, + providerFactory() { + generation += 1; + const current = generation; + events.push('provider:create:' + current); + return { + connect() { + events.push('provider:connect:' + current); + if (current === 1) { + return Promise.reject(new Error('private asynchronous connect failure')); + } + }, + disconnect() { events.push('provider:disconnect:' + current); }, + destroy() { events.push('provider:destroy:' + current); }, + }; + }, + roomId: 'reference-room', + actorId: 'reference-actor', + }); + + let firstError = null; + let firstResult = null; + try { + firstResult = lifecycle.connect(); + } catch (error) { + firstError = error instanceof Error ? error.message : 'unexpected error'; + } + + await new Promise((resolve) => setImmediate(resolve)); + const afterFailure = lifecycle.getSnapshot(); + const recovered = lifecycle.reconnect(); + const disposeResult = lifecycle.dispose(); + process.stdout.write(JSON.stringify({ + afterFailure, + disposeResult, + events, + firstError, + firstResult, + recovered, + unhandledRejections, + })); + `; + const output = execFileSync( + process.execPath, + ['--input-type=module', '--eval', script], + { encoding: 'utf8' }, + ); + + expect(JSON.parse(output)).toEqual({ + afterFailure: { providerGeneration: 1, status: 'disconnected' }, + disposeResult: true, + events: [ + 'provider:create:1', + 'provider:connect:1', + 'provider:disconnect:1', + 'provider:destroy:1', + 'provider:create:2', + 'provider:connect:2', + 'provider:disconnect:2', + 'provider:destroy:2', + 'document:destroy', + ], + firstError: 'collaboration lifecycle connection failed.', + firstResult: null, + recovered: { providerGeneration: 2, status: 'connected' }, + unhandledRejections: 0, + }); + }); +}); diff --git a/src/referenceHostCollaborationAsyncTeardown.test.ts b/src/referenceHostCollaborationAsyncTeardown.test.ts new file mode 100644 index 000000000..97c381e90 --- /dev/null +++ b/src/referenceHostCollaborationAsyncTeardown.test.ts @@ -0,0 +1,300 @@ +import { execFileSync } from 'node:child_process'; +import { pathToFileURL } from 'node:url'; +import { resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +const fixturePath = resolve( + process.cwd(), + 'examples/reference-host/collaboration-provider-lifecycle.mjs', +); + +describe('reference-host asynchronous teardown contract', () => { + it('quarantines promise-returning provider destruction and retries cleanup without an unhandled rejection', () => { + const moduleUrl = JSON.stringify(pathToFileURL(fixturePath).href); + const script = ` + import { createHostCollaborationLifecycle } from ${moduleUrl}; + const events = []; + let providerDestroyAttempts = 0; + let unhandledRejections = 0; + process.on('unhandledRejection', () => { + unhandledRejections += 1; + }); + + const lifecycle = createHostCollaborationLifecycle({ + documentFactory() { + return { + destroy() { events.push('document:destroy'); }, + }; + }, + providerFactory() { + return { + connect() { events.push('provider:connect'); }, + disconnect() { events.push('provider:disconnect'); }, + destroy() { + providerDestroyAttempts += 1; + events.push('provider:destroy:' + providerDestroyAttempts); + if (providerDestroyAttempts === 1) { + return Promise.reject(new Error('private asynchronous provider destroy failure')); + } + }, + }; + }, + roomId: 'reference-room', + actorId: 'reference-actor', + }); + + lifecycle.connect(); + let firstError = null; + let firstResult = null; + try { + firstResult = lifecycle.dispose(); + } catch (error) { + firstError = error instanceof Error ? error.message : 'unexpected error'; + } + + await new Promise((resolve) => setImmediate(resolve)); + const afterFailure = lifecycle.getSnapshot(); + const retryResult = lifecycle.dispose(); + const afterRetry = lifecycle.getSnapshot(); + const idempotentResult = lifecycle.dispose(); + process.stdout.write(JSON.stringify({ + afterFailure, + afterRetry, + events, + firstError, + firstResult, + idempotentResult, + retryResult, + unhandledRejections, + })); + `; + const output = execFileSync( + process.execPath, + ['--input-type=module', '--eval', script], + { encoding: 'utf8' }, + ); + + expect(JSON.parse(output)).toEqual({ + afterFailure: { providerGeneration: 1, status: 'disconnected' }, + afterRetry: { providerGeneration: 1, status: 'disposed' }, + events: [ + 'provider:connect', + 'provider:disconnect', + 'provider:destroy:1', + 'document:destroy', + 'provider:destroy:2', + ], + firstError: 'collaboration lifecycle teardown failed.', + firstResult: null, + idempotentResult: false, + retryResult: true, + unhandledRejections: 0, + }); + }); + + it('retries promise-returning document destruction without repeating successful provider teardown', () => { + const moduleUrl = JSON.stringify(pathToFileURL(fixturePath).href); + const script = ` + import { createHostCollaborationLifecycle } from ${moduleUrl}; + const events = []; + let documentDestroyAttempts = 0; + let unhandledRejections = 0; + process.on('unhandledRejection', () => { + unhandledRejections += 1; + }); + + const lifecycle = createHostCollaborationLifecycle({ + documentFactory() { + return { + destroy() { + documentDestroyAttempts += 1; + events.push('document:destroy:' + documentDestroyAttempts); + if (documentDestroyAttempts === 1) { + return Promise.reject(new Error('private asynchronous document destroy failure')); + } + }, + }; + }, + providerFactory() { + return { + connect() { events.push('provider:connect'); }, + disconnect() { events.push('provider:disconnect'); }, + destroy() { events.push('provider:destroy'); }, + }; + }, + roomId: 'reference-room', + actorId: 'reference-actor', + }); + + lifecycle.connect(); + let firstError = null; + let firstResult = null; + try { + firstResult = lifecycle.dispose(); + } catch (error) { + firstError = error instanceof Error ? error.message : 'unexpected error'; + } + + await new Promise((resolve) => setImmediate(resolve)); + const afterFailure = lifecycle.getSnapshot(); + const retryResult = lifecycle.dispose(); + const afterRetry = lifecycle.getSnapshot(); + process.stdout.write(JSON.stringify({ + afterFailure, + afterRetry, + events, + firstError, + firstResult, + retryResult, + unhandledRejections, + })); + `; + const output = execFileSync( + process.execPath, + ['--input-type=module', '--eval', script], + { encoding: 'utf8' }, + ); + + expect(JSON.parse(output)).toEqual({ + afterFailure: { providerGeneration: 1, status: 'disconnected' }, + afterRetry: { providerGeneration: 1, status: 'disposed' }, + events: [ + 'provider:connect', + 'provider:disconnect', + 'provider:destroy', + 'document:destroy:1', + 'document:destroy:2', + ], + firstError: 'collaboration lifecycle teardown failed.', + firstResult: null, + retryResult: true, + unhandledRejections: 0, + }); + }); + + it('contains promise-returning document cleanup while unwinding initial provider failure', () => { + const moduleUrl = JSON.stringify(pathToFileURL(fixturePath).href); + const script = ` + import { createHostCollaborationLifecycle } from ${moduleUrl}; + const events = []; + let unhandledRejections = 0; + process.on('unhandledRejection', () => { + unhandledRejections += 1; + }); + + let initializationError = null; + try { + createHostCollaborationLifecycle({ + documentFactory() { + events.push('document:create'); + return { + destroy() { + events.push('document:destroy'); + return Promise.reject(new Error('private asynchronous initialization cleanup failure')); + }, + }; + }, + providerFactory() { + events.push('provider:create'); + throw new Error('private provider construction failure'); + }, + roomId: 'reference-room', + actorId: 'reference-actor', + }); + } catch (error) { + initializationError = error instanceof Error ? error.message : 'unexpected error'; + } + + await new Promise((resolve) => setImmediate(resolve)); + process.stdout.write(JSON.stringify({ + events, + initializationError, + unhandledRejections, + })); + `; + const output = execFileSync( + process.execPath, + ['--input-type=module', '--eval', script], + { encoding: 'utf8' }, + ); + + expect(JSON.parse(output)).toEqual({ + events: ['document:create', 'provider:create', 'document:destroy'], + initializationError: 'collaboration lifecycle initialization failed.', + unhandledRejections: 0, + }); + }); + + it('contains promise-returning provider disconnection before completing synchronous destruction', () => { + const moduleUrl = JSON.stringify(pathToFileURL(fixturePath).href); + const script = ` + import { createHostCollaborationLifecycle } from ${moduleUrl}; + const events = []; + let unhandledRejections = 0; + process.on('unhandledRejection', () => { + unhandledRejections += 1; + }); + + const lifecycle = createHostCollaborationLifecycle({ + documentFactory() { + return { + destroy() { events.push('document:destroy'); }, + }; + }, + providerFactory() { + return { + connect() { events.push('provider:connect'); }, + disconnect() { + events.push('provider:disconnect'); + return Promise.reject(new Error('private asynchronous provider disconnect failure')); + }, + destroy() { events.push('provider:destroy'); }, + }; + }, + roomId: 'reference-room', + actorId: 'reference-actor', + }); + + lifecycle.connect(); + let firstError = null; + let firstResult = null; + try { + firstResult = lifecycle.dispose(); + } catch (error) { + firstError = error instanceof Error ? error.message : 'unexpected error'; + } + + await new Promise((resolve) => setImmediate(resolve)); + const afterFailure = lifecycle.getSnapshot(); + const retryResult = lifecycle.dispose(); + process.stdout.write(JSON.stringify({ + afterFailure, + events, + firstError, + firstResult, + retryResult, + unhandledRejections, + })); + `; + const output = execFileSync( + process.execPath, + ['--input-type=module', '--eval', script], + { encoding: 'utf8' }, + ); + + expect(JSON.parse(output)).toEqual({ + afterFailure: { providerGeneration: 1, status: 'disposed' }, + events: [ + 'provider:connect', + 'provider:disconnect', + 'provider:destroy', + 'document:destroy', + ], + firstError: 'collaboration lifecycle teardown failed.', + firstResult: null, + retryResult: false, + unhandledRejections: 0, + }); + }); +}); diff --git a/src/referenceHostCollaborationFailureRedaction.test.ts b/src/referenceHostCollaborationFailureRedaction.test.ts new file mode 100644 index 000000000..0baf21012 --- /dev/null +++ b/src/referenceHostCollaborationFailureRedaction.test.ts @@ -0,0 +1,129 @@ +import { execFileSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { pathToFileURL } from 'node:url'; +import { resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +const fixturePath = resolve( + process.cwd(), + 'examples/reference-host/collaboration-provider-lifecycle.mjs', +); + +describe('reference-host collaboration callback failure redaction', () => { + it('redacts document factory failures at initialization', () => { + if (!existsSync(fixturePath)) return; + const moduleUrl = JSON.stringify(pathToFileURL(fixturePath).href); + const script = ` + import { createHostCollaborationLifecycle } from ${moduleUrl}; + const privateCause = 'private document factory cause'; + let error = null; + try { + createHostCollaborationLifecycle({ + documentFactory() { throw new Error(privateCause); }, + providerFactory() { + return { connect() {}, disconnect() {}, destroy() {} }; + }, + roomId: 'reference-room', + actorId: 'reference-actor', + }); + } catch (failure) { + error = failure instanceof Error ? failure.message : 'unexpected error'; + } + process.stdout.write(JSON.stringify({ + error, + leakedPrivateCause: typeof error === 'string' && error.includes(privateCause), + })); + `; + const output = execFileSync( + process.execPath, + ['--input-type=module', '--eval', script], + { encoding: 'utf8' }, + ); + + expect(JSON.parse(output)).toEqual({ + error: 'collaboration lifecycle initialization failed.', + leakedPrivateCause: false, + }); + }); + + it('redacts ambiguous provider connect failures and requires provider replacement', () => { + if (!existsSync(fixturePath)) return; + const moduleUrl = JSON.stringify(pathToFileURL(fixturePath).href); + const script = ` + import { createHostCollaborationLifecycle } from ${moduleUrl}; + const privateCause = 'private provider connect cause'; + const events = []; + let providerGeneration = 0; + const lifecycle = createHostCollaborationLifecycle({ + documentFactory() { + return { destroy() { events.push('document:destroy'); } }; + }, + providerFactory() { + providerGeneration += 1; + const generation = providerGeneration; + events.push('provider:create:' + generation); + return { + connect() { + events.push('provider:connect:' + generation); + if (generation === 1) throw new Error(privateCause); + }, + disconnect() { events.push('provider:disconnect:' + generation); }, + destroy() { events.push('provider:destroy:' + generation); }, + }; + }, + roomId: 'reference-room', + actorId: 'reference-actor', + }); + let error = null; + try { + lifecycle.connect(); + } catch (failure) { + error = failure instanceof Error ? failure.message : 'unexpected error'; + } + const afterFailure = lifecycle.getSnapshot(); + let retryError = null; + try { + lifecycle.connect(); + } catch (failure) { + retryError = failure instanceof Error ? failure.message : 'unexpected error'; + } + const recovered = lifecycle.reconnect(); + lifecycle.dispose(); + process.stdout.write(JSON.stringify({ + afterFailure, + error, + events, + leakedPrivateCause: + (typeof error === 'string' && error.includes(privateCause)) || + (typeof retryError === 'string' && retryError.includes(privateCause)), + recovered, + retryError, + })); + `; + const output = execFileSync( + process.execPath, + ['--input-type=module', '--eval', script], + { encoding: 'utf8' }, + ); + + expect(JSON.parse(output)).toEqual({ + afterFailure: { providerGeneration: 1, status: 'disconnected' }, + error: 'collaboration lifecycle connection failed.', + events: [ + 'provider:create:1', + 'provider:connect:1', + 'provider:disconnect:1', + 'provider:destroy:1', + 'provider:create:2', + 'provider:connect:2', + 'provider:disconnect:2', + 'provider:destroy:2', + 'document:destroy', + ], + leakedPrivateCause: false, + recovered: { providerGeneration: 2, status: 'connected' }, + retryError: 'collaboration lifecycle connection failed.', + }); + }); +}); diff --git a/src/referenceHostCollaborationLifecycle.test.ts b/src/referenceHostCollaborationLifecycle.test.ts new file mode 100644 index 000000000..83071c8c3 --- /dev/null +++ b/src/referenceHostCollaborationLifecycle.test.ts @@ -0,0 +1,398 @@ +import { execFileSync } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import { pathToFileURL } from 'node:url'; +import { resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +const fixturePath = resolve( + process.cwd(), + 'examples/reference-host/collaboration-provider-lifecycle.mjs', +); + +describe('reference-host collaboration lifecycle contract', () => { + it('ships one host-owned collaboration lifecycle fixture outside the published runtime', () => { + expect(existsSync(fixturePath)).toBe(true); + }); + + it('keeps provider creation and teardown host-owned and provider-neutral', () => { + if (!existsSync(fixturePath)) return; + const source = readFileSync(fixturePath, 'utf8'); + + expect(source).not.toMatch(/(?:from|import\()\s*['"][^'"]*src\//u); + expect(source).not.toMatch(/\b(?:fetch|XMLHttpRequest|WebSocket|process\.env)\b/u); + expect(source).toContain('REFERENCE_ONLY'); + expect(source).toContain('providerFactory'); + expect(source).toContain('reconnect'); + expect(source).toContain('dispose'); + }); + + it('reuses one real host-created Y.Doc across provider reconnects and tears it down exactly once', () => { + if (!existsSync(fixturePath)) return; + const output = execFileSync(process.execPath, [fixturePath, '--self-test'], { + encoding: 'utf8', + }); + + expect(JSON.parse(output)).toEqual({ + events: [ + 'document:create', + 'provider:create:1', + 'provider:connect:1', + 'provider:disconnect:1', + 'provider:destroy:1', + 'provider:create:2', + 'provider:connect:2', + 'provider:disconnect:2', + 'provider:destroy:2', + 'document:destroy', + ], + hostDocumentIsYjs: true, + providerGeneration: 2, + sameDocumentAcrossReconnect: true, + status: 'disposed', + yjsText: 'Buyer draft', + }); + }); + + it('retains a provider whose destroy failed so reconnect can retry cleanup before replacement', () => { + if (!existsSync(fixturePath)) return; + const moduleUrl = JSON.stringify(pathToFileURL(fixturePath).href); + const script = ` + import { createHostCollaborationLifecycle } from ${moduleUrl}; + const events = []; + let generation = 0; + let firstDestroyAttempts = 0; + const lifecycle = createHostCollaborationLifecycle({ + documentFactory() { + return { destroy() { events.push('document:destroy'); } }; + }, + providerFactory() { + generation += 1; + const current = generation; + events.push('provider:create:' + current); + return { + connect() { events.push('provider:connect:' + current); }, + disconnect() { events.push('provider:disconnect:' + current); }, + destroy() { + events.push('provider:destroy:' + current); + if (current === 1 && firstDestroyAttempts === 0) { + firstDestroyAttempts += 1; + throw new Error('private transient destroy failure'); + } + }, + }; + }, + roomId: 'reference-room', + actorId: 'reference-actor', + }); + lifecycle.connect(); + let firstError = null; + try { + lifecycle.reconnect(); + } catch (error) { + firstError = error instanceof Error ? error.message : 'unexpected error'; + } + lifecycle.reconnect(); + process.stdout.write(JSON.stringify({ + events, + firstError, + snapshot: lifecycle.getSnapshot(), + })); + `; + const output = execFileSync( + process.execPath, + ['--input-type=module', '--eval', script], + { encoding: 'utf8' }, + ); + + expect(JSON.parse(output)).toEqual({ + events: [ + 'provider:create:1', + 'provider:connect:1', + 'provider:disconnect:1', + 'provider:destroy:1', + 'provider:destroy:1', + 'provider:create:2', + 'provider:connect:2', + ], + firstError: 'collaboration lifecycle teardown failed.', + snapshot: { providerGeneration: 2, status: 'connected' }, + }); + }); + + it('redacts reconnect provider construction failures and remains recoverable', () => { + if (!existsSync(fixturePath)) return; + const moduleUrl = JSON.stringify(pathToFileURL(fixturePath).href); + const script = ` + import { createHostCollaborationLifecycle } from ${moduleUrl}; + const privateCause = 'private reconnect provider cause'; + const events = []; + let generation = 0; + const lifecycle = createHostCollaborationLifecycle({ + documentFactory() { + return { destroy() { events.push('document:destroy'); } }; + }, + providerFactory() { + generation += 1; + const current = generation; + events.push('provider:create:' + current); + if (current === 2) throw new Error(privateCause); + return { + connect() { events.push('provider:connect:' + current); }, + disconnect() { events.push('provider:disconnect:' + current); }, + destroy() { events.push('provider:destroy:' + current); }, + }; + }, + roomId: 'reference-room', + actorId: 'reference-actor', + }); + lifecycle.connect(); + let reconnectError = null; + try { + lifecycle.reconnect(); + } catch (error) { + reconnectError = error instanceof Error ? error.message : 'unexpected error'; + } + const afterFailure = lifecycle.getSnapshot(); + const recovered = lifecycle.reconnect(); + lifecycle.dispose(); + process.stdout.write(JSON.stringify({ + afterFailure, + events, + leakedPrivateCause: + typeof reconnectError === 'string' && reconnectError.includes(privateCause), + reconnectError, + recovered, + })); + `; + const output = execFileSync( + process.execPath, + ['--input-type=module', '--eval', script], + { encoding: 'utf8' }, + ); + + expect(JSON.parse(output)).toEqual({ + afterFailure: { providerGeneration: 2, status: 'disconnected' }, + events: [ + 'provider:create:1', + 'provider:connect:1', + 'provider:disconnect:1', + 'provider:destroy:1', + 'provider:create:2', + 'provider:create:3', + 'provider:connect:3', + 'provider:disconnect:3', + 'provider:destroy:3', + 'document:destroy', + ], + leakedPrivateCause: false, + reconnectError: 'collaboration lifecycle reconnect failed.', + recovered: { providerGeneration: 3, status: 'connected' }, + }); + }); + + it('rejects accessor-backed lifecycle options and resource methods without invoking them', () => { + if (!existsSync(fixturePath)) return; + const output = execFileSync( + process.execPath, + [fixturePath, '--hostile-accessor-self-test'], + { encoding: 'utf8' }, + ); + + expect(JSON.parse(output)).toEqual({ + documentError: 'documentFactory returned an invalid document.', + documentGetterCalls: 0, + optionsError: 'collaboration options are invalid.', + optionsGetterCalls: 0, + providerError: 'providerFactory returned an invalid provider.', + providerGetterCalls: 0, + }); + }); + + it('unwinds the acquired host document when initial provider construction fails', () => { + if (!existsSync(fixturePath)) return; + const output = execFileSync( + process.execPath, + [fixturePath, '--initialization-failure-self-test'], + { encoding: 'utf8' }, + ); + + expect(JSON.parse(output)).toEqual({ + error: 'collaboration lifecycle initialization failed.', + events: ['document:create', 'provider:create', 'document:destroy'], + leakedPrivateCause: false, + }); + }); + + it('attempts provider and document cleanup after teardown failure without leaking private causes', () => { + if (!existsSync(fixturePath)) return; + const output = execFileSync( + process.execPath, + [fixturePath, '--cleanup-failure-self-test'], + { encoding: 'utf8' }, + ); + + expect(JSON.parse(output)).toEqual({ + error: 'collaboration lifecycle teardown failed.', + events: [ + 'provider:connect', + 'provider:disconnect', + 'provider:destroy', + 'document:destroy', + ], + leakedPrivateCause: false, + status: 'disposed', + }); + }); + + it('retries incomplete provider destruction without destroying the host document twice', () => { + if (!existsSync(fixturePath)) return; + const moduleUrl = JSON.stringify(pathToFileURL(fixturePath).href); + const script = ` + import { createHostCollaborationLifecycle } from ${moduleUrl}; + const events = []; + let providerDestroyAttempts = 0; + const lifecycle = createHostCollaborationLifecycle({ + documentFactory() { + return { + destroy() { events.push('document:destroy'); }, + }; + }, + providerFactory() { + return { + connect() { events.push('provider:connect'); }, + disconnect() { events.push('provider:disconnect'); }, + destroy() { + providerDestroyAttempts += 1; + events.push('provider:destroy:' + providerDestroyAttempts); + if (providerDestroyAttempts === 1) { + throw new Error('private transient provider destroy failure'); + } + }, + }; + }, + roomId: 'reference-room', + actorId: 'reference-actor', + }); + lifecycle.connect(); + let firstError = null; + try { + lifecycle.dispose(); + } catch (error) { + firstError = error instanceof Error ? error.message : 'unexpected error'; + } + const afterFailure = lifecycle.getSnapshot(); + const retryResult = lifecycle.dispose(); + const afterRetry = lifecycle.getSnapshot(); + const idempotentResult = lifecycle.dispose(); + process.stdout.write(JSON.stringify({ + afterFailure, + afterRetry, + events, + firstError, + idempotentResult, + retryResult, + })); + `; + const output = execFileSync( + process.execPath, + ['--input-type=module', '--eval', script], + { encoding: 'utf8' }, + ); + + expect(JSON.parse(output)).toEqual({ + afterFailure: { providerGeneration: 1, status: 'disconnected' }, + afterRetry: { providerGeneration: 1, status: 'disposed' }, + events: [ + 'provider:connect', + 'provider:disconnect', + 'provider:destroy:1', + 'document:destroy', + 'provider:destroy:2', + ], + firstError: 'collaboration lifecycle teardown failed.', + idempotentResult: false, + retryResult: true, + }); + }); + + it('allows only cleanup retries after disposal has started', () => { + if (!existsSync(fixturePath)) return; + const moduleUrl = JSON.stringify(pathToFileURL(fixturePath).href); + const script = ` + import { createHostCollaborationLifecycle } from ${moduleUrl}; + const events = []; + const lifecycle = createHostCollaborationLifecycle({ + documentFactory() { + return { + destroy() { events.push('document:destroy'); }, + }; + }, + providerFactory() { + return { + connect() { events.push('provider:connect'); }, + disconnect() { events.push('provider:disconnect'); }, + destroy() { + events.push('provider:destroy'); + throw new Error('private persistent provider destroy failure'); + }, + }; + }, + roomId: 'reference-room', + actorId: 'reference-actor', + }); + lifecycle.connect(); + let disposeError = null; + try { + lifecycle.dispose(); + } catch (error) { + disposeError = error instanceof Error ? error.message : 'unexpected error'; + } + const eventsAfterDispose = [...events]; + let connectError = null; + try { + lifecycle.connect(); + } catch (error) { + connectError = error instanceof Error ? error.message : 'unexpected error'; + } + let reconnectError = null; + try { + lifecycle.reconnect(); + } catch (error) { + reconnectError = error instanceof Error ? error.message : 'unexpected error'; + } + process.stdout.write(JSON.stringify({ + connectError, + disposeError, + events, + eventsAfterDispose, + reconnectError, + snapshot: lifecycle.getSnapshot(), + })); + `; + const output = execFileSync( + process.execPath, + ['--input-type=module', '--eval', script], + { encoding: 'utf8' }, + ); + + expect(JSON.parse(output)).toEqual({ + connectError: 'collaboration lifecycle teardown failed.', + disposeError: 'collaboration lifecycle teardown failed.', + events: [ + 'provider:connect', + 'provider:disconnect', + 'provider:destroy', + 'document:destroy', + ], + eventsAfterDispose: [ + 'provider:connect', + 'provider:disconnect', + 'provider:destroy', + 'document:destroy', + ], + reconnectError: 'collaboration lifecycle teardown failed.', + snapshot: { providerGeneration: 1, status: 'disconnected' }, + }); + }); +}); diff --git a/src/referenceHostCollaborationOptionShape.test.ts b/src/referenceHostCollaborationOptionShape.test.ts new file mode 100644 index 000000000..245e6a950 --- /dev/null +++ b/src/referenceHostCollaborationOptionShape.test.ts @@ -0,0 +1,78 @@ +import { execFileSync } from 'node:child_process'; +import { pathToFileURL } from 'node:url'; +import { resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +const fixturePath = resolve( + process.cwd(), + 'examples/reference-host/collaboration-provider-lifecycle.mjs', +); + +describe('reference-host collaboration option shape', () => { + it('rejects unknown authority-looking option fields before invoking host factories', () => { + const moduleUrl = JSON.stringify(pathToFileURL(fixturePath).href); + const script = ` + import { createHostCollaborationLifecycle } from ${moduleUrl}; + + function run(kind) { + let documentFactoryCalls = 0; + let providerFactoryCalls = 0; + const options = { + documentFactory() { + documentFactoryCalls += 1; + return { destroy() {} }; + }, + providerFactory() { + providerFactoryCalls += 1; + return { connect() {}, disconnect() {}, destroy() {} }; + }, + roomId: 'reference-room', + actorId: 'reference-actor', + }; + + if (kind === 'enumerable') { + options.authorization = 'owner'; + } else if (kind === 'hidden') { + Object.defineProperty(options, 'authorization', { + value: 'owner', + enumerable: false, + }); + } else { + options[Symbol('authorization')] = 'owner'; + } + + let error = null; + try { + const lifecycle = createHostCollaborationLifecycle(options); + lifecycle.dispose(); + } catch (failure) { + error = failure instanceof Error ? failure.message : 'unexpected error'; + } + return { documentFactoryCalls, error, providerFactoryCalls }; + } + + process.stdout.write(JSON.stringify({ + enumerable: run('enumerable'), + hidden: run('hidden'), + symbol: run('symbol'), + })); + `; + const output = execFileSync( + process.execPath, + ['--input-type=module', '--eval', script], + { encoding: 'utf8' }, + ); + + const rejected = { + documentFactoryCalls: 0, + error: 'collaboration options are invalid.', + providerFactoryCalls: 0, + }; + expect(JSON.parse(output)).toEqual({ + enumerable: rejected, + hidden: rejected, + symbol: rejected, + }); + }); +}); diff --git a/src/referenceHostCollaborationPrototypeTraversal.test.ts b/src/referenceHostCollaborationPrototypeTraversal.test.ts new file mode 100644 index 000000000..515651ab0 --- /dev/null +++ b/src/referenceHostCollaborationPrototypeTraversal.test.ts @@ -0,0 +1,58 @@ +import { execFileSync } from 'node:child_process'; +import { pathToFileURL } from 'node:url'; +import { resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +const fixturePath = resolve( + process.cwd(), + 'examples/reference-host/collaboration-provider-lifecycle.mjs', +); + +describe('reference-host collaboration prototype traversal', () => { + it('bounds hostile resource prototype traversal before caller-controlled work can continue indefinitely', () => { + const fixtureUrl = JSON.stringify(pathToFileURL(fixturePath).href); + const script = ` + import { createHostCollaborationLifecycle } from ${fixtureUrl}; + const privateCause = 'private prototype traversal cause'; + let prototypeReads = 0; + let hostileDocument; + hostileDocument = new Proxy({}, { + getPrototypeOf() { + prototypeReads += 1; + if (prototypeReads > 64) throw new Error(privateCause); + return hostileDocument; + }, + }); + let error = null; + try { + createHostCollaborationLifecycle({ + documentFactory() { return hostileDocument; }, + providerFactory() { + return { connect() {}, disconnect() {}, destroy() {} }; + }, + roomId: 'reference-room', + actorId: 'reference-actor', + }); + } catch (failure) { + error = failure instanceof Error ? failure.message : 'unexpected error'; + } + process.stdout.write(JSON.stringify({ + error, + leakedPrivateCause: typeof error === 'string' && error.includes(privateCause), + prototypeReads, + })); + `; + const output = execFileSync( + process.execPath, + ['--input-type=module', '--eval', script], + { encoding: 'utf8' }, + ); + + expect(JSON.parse(output)).toEqual({ + error: 'documentFactory returned an invalid document.', + leakedPrivateCause: false, + prototypeReads: 64, + }); + }); +}); diff --git a/src/referenceHostDelayedProposal.test.ts b/src/referenceHostDelayedProposal.test.ts new file mode 100644 index 000000000..19aafeec1 --- /dev/null +++ b/src/referenceHostDelayedProposal.test.ts @@ -0,0 +1,91 @@ +import { execFileSync } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +const fixturePath = resolve( + process.cwd(), + 'examples/reference-host/delayed-proposal.mjs', +); + +describe('reference-host delayed proposal contract', () => { + it('ships one deterministic local proposal fixture outside the published runtime', () => { + expect(existsSync(fixturePath)).toBe(true); + }); + + it('keeps proposal generation provider-free and network-free', () => { + if (!existsSync(fixturePath)) return; + const source = readFileSync(fixturePath, 'utf8'); + + expect(source).not.toMatch(/(?:from|import\()\s*['"][^'"]*src\//u); + expect(source).not.toMatch(/\b(?:fetch|XMLHttpRequest|WebSocket)\b/u); + expect(source).toContain('REFERENCE_ONLY'); + expect(source).toContain('expectedRevision'); + }); + + it('conflicts stale delayed proposals instead of overwriting newer content', () => { + if (!existsSync(fixturePath)) return; + const output = execFileSync(process.execPath, [fixturePath, '--self-test'], { + encoding: 'utf8', + }); + + expect(JSON.parse(output)).toEqual({ + acceptedDocument: 'Accepted proposal', + acceptedStatus: 'applied', + staleDocument: 'User typed newer text', + staleStatus: 'conflict', + }); + }); + + it('permits an empty replacement without weakening non-empty revision identity', () => { + if (!existsSync(fixturePath)) return; + const output = execFileSync( + process.execPath, + [fixturePath, '--empty-proposal-self-test'], + { encoding: 'utf8' }, + ); + + expect(JSON.parse(output)).toEqual({ + appliedDocument: '', + appliedStatus: 'applied', + emptyRevisionError: 'expectedRevision is invalid.', + proposalReplacement: '', + }); + }); + + it('rejects accessor-backed untrusted proposal inputs without invoking them', () => { + if (!existsSync(fixturePath)) return; + const output = execFileSync( + process.execPath, + [fixturePath, '--hostile-accessor-self-test'], + { encoding: 'utf8' }, + ); + + expect(JSON.parse(output)).toEqual({ + applicationError: 'proposal application is invalid.', + applicationGetterCalls: 0, + creationError: 'proposal creation is invalid.', + creationGetterCalls: 0, + proposalError: 'proposal application is invalid.', + proposalGetterCalls: 0, + }); + }); + + it('fails closed on unknown proposal and application fields before host mutation', () => { + if (!existsSync(fixturePath)) return; + const output = execFileSync( + process.execPath, + [fixturePath, '--unknown-field-self-test'], + { encoding: 'utf8' }, + ); + + expect(JSON.parse(output)).toEqual({ + applicationApplyCalls: 0, + applicationError: 'proposal application is invalid.', + creationError: 'proposal creation is invalid.', + proposalApplyCalls: 0, + proposalError: 'proposal application is invalid.', + }); + }); +}); diff --git a/src/referenceHostDelayedProposalApplicationFailure.test.ts b/src/referenceHostDelayedProposalApplicationFailure.test.ts new file mode 100644 index 000000000..9a5246fe7 --- /dev/null +++ b/src/referenceHostDelayedProposalApplicationFailure.test.ts @@ -0,0 +1,96 @@ +import { execFileSync } from 'node:child_process'; +import { pathToFileURL } from 'node:url'; +import { resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +const fixturePath = resolve( + process.cwd(), + 'examples/reference-host/delayed-proposal.mjs', +); + +describe('reference-host delayed proposal application failure', () => { + it('redacts host apply failures instead of leaking private causes through the proposal boundary', () => { + const fixtureUrl = JSON.stringify(pathToFileURL(fixturePath).href); + const script = ` + import { applyDelayedProposal, createDelayedProposal } from ${fixtureUrl}; + const privateCause = 'private host apply cause'; + const proposal = await createDelayedProposal({ + expectedRevision: 'revision-v1', + replacement: 'Accepted proposal', + }); + let error = null; + try { + applyDelayedProposal({ + proposal, + currentRevision: 'revision-v1', + apply() { + throw new Error(privateCause); + }, + }); + } catch (failure) { + error = failure instanceof Error ? failure.message : 'unexpected error'; + } + process.stdout.write(JSON.stringify({ + error, + leakedPrivateCause: typeof error === 'string' && error.includes(privateCause), + })); + `; + const output = execFileSync( + process.execPath, + ['--input-type=module', '--eval', script], + { encoding: 'utf8' }, + ); + + expect(JSON.parse(output)).toEqual({ + error: 'proposal application failed.', + leakedPrivateCause: false, + }); + }); + + it('does not report applied when a host apply thenable rejects asynchronously', () => { + const fixtureUrl = JSON.stringify(pathToFileURL(fixturePath).href); + const script = ` + import { applyDelayedProposal, createDelayedProposal } from ${fixtureUrl}; + const privateCause = 'private async host apply cause'; + const proposal = await createDelayedProposal({ + expectedRevision: 'revision-v1', + replacement: 'Accepted proposal', + }); + let error = null; + let status = null; + try { + const result = await applyDelayedProposal({ + proposal, + currentRevision: 'revision-v1', + apply() { + return { + then(_resolve, reject) { + reject(new Error(privateCause)); + }, + }; + }, + }); + status = result.status; + } catch (failure) { + error = failure instanceof Error ? failure.message : 'unexpected error'; + } + process.stdout.write(JSON.stringify({ + error, + leakedPrivateCause: typeof error === 'string' && error.includes(privateCause), + status, + })); + `; + const output = execFileSync( + process.execPath, + ['--input-type=module', '--eval', script], + { encoding: 'utf8' }, + ); + + expect(JSON.parse(output)).toEqual({ + error: 'proposal application failed.', + leakedPrivateCause: false, + status: null, + }); + }); +}); diff --git a/src/referenceHostFrameworkFreePackedBoundary.test.ts b/src/referenceHostFrameworkFreePackedBoundary.test.ts new file mode 100644 index 000000000..da4f25969 --- /dev/null +++ b/src/referenceHostFrameworkFreePackedBoundary.test.ts @@ -0,0 +1,168 @@ +import { execFile } from 'node:child_process'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + renameSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { promisify } from 'node:util'; + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +const repositoryRoot = process.cwd(); +const packageMetadata = JSON.parse( + readFileSync(resolve(repositoryRoot, 'package.json'), 'utf8'), +) as { name: string; version: string }; + +const temporaryRoot = mkdtempSync( + join(tmpdir(), 'inkspan-reference-host-framework-free-'), +); +const extractionDirectory = join(temporaryRoot, 'extracted'); +const consumerDirectory = join(temporaryRoot, 'consumer'); +const packageDirectory = join( + consumerDirectory, + 'node_modules', + ...packageMetadata.name.split('/'), +); + +async function run(command: string, argumentsList: string[], cwd = repositoryRoot) { + const { stdout } = await promisify(execFile)(command, argumentsList, { + cwd, + encoding: 'utf8', + timeout: 180_000, + }); + return stdout; +} + +beforeAll( + async () => { + await run('pnpm', ['build']); + mkdirSync(extractionDirectory, { recursive: true }); + mkdirSync(dirname(packageDirectory), { recursive: true }); + + const packResult = JSON.parse( + await run('npm', [ + 'pack', + '--json', + '--ignore-scripts', + '--pack-destination', + temporaryRoot, + ]), + ) as Array<{ filename: string; name: string; version: string }>; + + expect(packResult).toHaveLength(1); + expect(packResult[0]?.name).toBe(packageMetadata.name); + expect(packResult[0]?.version).toBe(packageMetadata.version); + + const tarballPath = join(temporaryRoot, packResult[0]!.filename); + expect(existsSync(tarballPath)).toBe(true); + await run('tar', ['-xzf', tarballPath, '-C', extractionDirectory]); + renameSync(join(extractionDirectory, 'package'), packageDirectory); + writeFileSync( + join(consumerDirectory, 'package.json'), + '{"name":"inkspan-reference-host-framework-free-consumer","private":true,"type":"module"}\n', + 'utf8', + ); + }, + 180_000, +); + +afterAll(() => { + rmSync(temporaryRoot, { recursive: true, force: true }); +}); + +describe('reference-host framework-free packed package boundary', () => { + it('contains no installed framework or editor dependency beside the exact packed Inkspan artifact', () => { + expect(readdirSync(join(consumerDirectory, 'node_modules'))).toEqual([ + '@contextualwisdomlab', + ]); + expect( + readdirSync(join(consumerDirectory, 'node_modules', '@contextualwisdomlab')), + ).toEqual(['cwl-editor']); + }); + + it('executes the packed autosave, converter, and Markdown ESM subpaths without React, TipTap, Yjs, browser, network, credential, or model dependencies', async () => { + const consumerPath = join(consumerDirectory, 'consumer.mjs'); + writeFileSync( + consumerPath, + `import assert from 'node:assert/strict'; +Object.defineProperty(globalThis, 'document', { + configurable: true, + get() { throw new Error('browser document authority is forbidden'); }, +}); +Object.defineProperty(globalThis, 'window', { + configurable: true, + get() { throw new Error('browser window authority is forbidden'); }, +}); +globalThis.fetch = () => { throw new Error('network authority is forbidden'); }; + +const autosave = await import('${packageMetadata.name}/autosave'); +const converter = await import('${packageMetadata.name}/converter'); +const markdown = await import('${packageMetadata.name}/markdown'); + +assert.equal(typeof autosave.createDocumentAutosaveQueue, 'function'); +assert.deepEqual( + Array.from(converter.dataUriToBytes('data:text/plain;base64,SGk=').bytes), + [72, 105], +); +assert.equal(markdown.markdownToPlainText('# Buyer boundary'), 'Buyer boundary'); +assert.equal( + markdown.markdownToHtml('**Buyer boundary**'), + markdown.markdownToHtml('**Buyer boundary**'), +); + +for (const specifier of ['autosave', 'converter', 'markdown']) { + const resolved = import.meta.resolve(\`${packageMetadata.name}/\${specifier}\`); + assert.match(resolved, /\\/dist\\/cwl-(?:autosave|converter|markdown)\\.js$/u); +} +`, + 'utf8', + ); + + await expect(run(process.execPath, [consumerPath], consumerDirectory)).resolves.toBe(''); + }); + + it('executes the same packed CommonJS subpaths without installing framework dependencies', async () => { + const consumerPath = join(consumerDirectory, 'consumer.cjs'); + writeFileSync( + consumerPath, + `const assert = require('node:assert/strict'); +Object.defineProperty(globalThis, 'document', { + configurable: true, + get() { throw new Error('browser document authority is forbidden'); }, +}); +Object.defineProperty(globalThis, 'window', { + configurable: true, + get() { throw new Error('browser window authority is forbidden'); }, +}); +globalThis.fetch = () => { throw new Error('network authority is forbidden'); }; + +const autosave = require('${packageMetadata.name}/autosave'); +const converter = require('${packageMetadata.name}/converter'); +const markdown = require('${packageMetadata.name}/markdown'); + +assert.equal(typeof autosave.createDocumentAutosaveQueue, 'function'); +assert.deepEqual( + Array.from(converter.dataUriToBytes('data:text/plain;base64,T0s=').bytes), + [79, 75], +); +assert.equal(markdown.markdownToPlainText('# Buyer boundary'), 'Buyer boundary'); +for (const specifier of ['autosave', 'converter', 'markdown']) { + assert.match( + require.resolve(\`${packageMetadata.name}/\${specifier}\`), + /\\/dist\\/cwl-(?:autosave|converter|markdown)\\.cjs$/u, + ); +} +`, + 'utf8', + ); + + await expect(run(process.execPath, [consumerPath], consumerDirectory)).resolves.toBe(''); + }); +}); diff --git a/src/referenceHostHydrationGate.test.tsx b/src/referenceHostHydrationGate.test.tsx new file mode 100644 index 000000000..4d396311c --- /dev/null +++ b/src/referenceHostHydrationGate.test.tsx @@ -0,0 +1,51 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { cleanup, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { renderToString } from 'react-dom/server'; +import { ReferenceHostHydrationGate } from '../examples/reference-host/hydration-gate.js'; + +const hydrationGateSource = readFileSync( + resolve(process.cwd(), 'examples/reference-host/hydration-gate.tsx'), + 'utf8', +); + +afterEach(cleanup); + +describe('reference-host hydration gate', () => { + it('declares the hydration boundary as a client component for App Router style hosts', () => { + expect(hydrationGateSource.startsWith("'use client';\n")).toBe(true); + }); + + it('keeps the browser editor out of server markup and mounts it only after client hydration', async () => { + const renderEditor = vi.fn(() => ( +
Hydrated editor
+ )); + + const serverHtml = renderToString( + , + ); + + expect(renderEditor).not.toHaveBeenCalled(); + expect(serverHtml).toContain('aria-busy="true"'); + expect(serverHtml).toContain('Loading editor'); + expect(serverHtml).not.toContain('data-reference-editor="ready"'); + + render( + , + ); + + await waitFor(() => { + expect(renderEditor).toHaveBeenCalledTimes(1); + expect(screen.getByText('Hydrated editor')).toBeInTheDocument(); + }); + expect(screen.queryByText('Loading editor')).not.toBeInTheDocument(); + }); +}); diff --git a/src/referenceHostNativeFormAdmission.test.ts b/src/referenceHostNativeFormAdmission.test.ts new file mode 100644 index 000000000..1fab32bcd --- /dev/null +++ b/src/referenceHostNativeFormAdmission.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + createSingleFlightSubmission, + shouldBlockReferenceHostFormMutation, +} from '../examples/reference-host/single-flight-submission.js'; + +describe('reference-host native-form synchronous admission', () => { + it('blocks a same-turn reset after durable submission admission before presentation commits', async () => { + let releaseAuthorizedSubmit: (() => void) | undefined; + const authorizedSubmit = new Promise((resolve) => { + releaseAuthorizedSubmit = resolve; + }); + let savingPresentationCommitted = false; + const submit = createSingleFlightSubmission( + async () => authorizedSubmit, + (state) => { + if (state === 'saving') { + queueMicrotask(() => { + savingPresentationCommitted = true; + }); + } + }, + ); + + expect( + shouldBlockReferenceHostFormMutation(false, submit.isInFlight), + ).toBe(false); + + const pendingSubmission = submit('# Durable write'); + + expect(submit.isInFlight()).toBe(true); + expect(savingPresentationCommitted).toBe(false); + + const preventDefault = vi.fn(); + if (shouldBlockReferenceHostFormMutation(false, submit.isInFlight)) { + preventDefault(); + } + + expect(preventDefault).toHaveBeenCalledOnce(); + expect(savingPresentationCommitted).toBe(false); + + await Promise.resolve(); + expect(savingPresentationCommitted).toBe(true); + + releaseAuthorizedSubmit?.(); + await expect(pendingSubmission).resolves.toBe(true); + expect(submit.isInFlight()).toBe(false); + }); + + it('also blocks host writes while read-only without consulting presentation state', () => { + expect(shouldBlockReferenceHostFormMutation(true, () => false)).toBe(true); + }); +}); diff --git a/src/referenceHostNativeFormJourney.test.ts b/src/referenceHostNativeFormJourney.test.ts new file mode 100644 index 000000000..0a5f4ff08 --- /dev/null +++ b/src/referenceHostNativeFormJourney.test.ts @@ -0,0 +1,130 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const nativeFormHostSource = readFileSync( + resolve(process.cwd(), 'examples/reference-host/native-form-host.tsx'), + 'utf8', +); + +describe('reference-host native form journey', () => { + it('uses the published editor package and delegates serialization to Inkspan native form integration', () => { + expect(nativeFormHostSource).toContain( + "from '@contextualwisdomlab/cwl-editor'", + ); + expect(nativeFormHostSource).toContain('formFieldName="message_body"'); + expect(nativeFormHostSource).toContain('formResetValue="# Draft"'); + expect(nativeFormHostSource).toContain('new FormData(event.currentTarget)'); + expect(nativeFormHostSource).toContain('type="submit"'); + expect(nativeFormHostSource).toContain('type="reset"'); + + expect(nativeFormHostSource).not.toMatch(/]+type=["']hidden["']/i); + expect(nativeFormHostSource).not.toContain('/src/'); + expect(nativeFormHostSource).not.toContain('../../src'); + }); + + it('keeps host authorization and durable persistence explicitly outside the component submit callback', () => { + expect(nativeFormHostSource).toContain('onAuthorizedSubmit'); + expect(nativeFormHostSource).toContain('createSingleFlightSubmission'); + expect(nativeFormHostSource).toContain( + 'onAuthorizedSubmitRef.current(messageBody)', + ); + expect(nativeFormHostSource).toContain( + 'await submitAuthorized(messageBodyEntry)', + ); + expect(nativeFormHostSource).toContain( + "disabled={readOnly || submissionState === 'saving'}", + ); + expect(nativeFormHostSource).not.toContain('fetch('); + expect(nativeFormHostSource).not.toContain('localStorage'); + }); + + it('binds submit/reset admission to the synchronous durable gate rather than deferred presentation state', () => { + expect( + nativeFormHostSource.match( + /shouldBlockReferenceHostFormMutation\(\s*readOnly,\s*submitAuthorized\.isInFlight,\s*\)/gu, + ), + ).toHaveLength(2); + expect(nativeFormHostSource).not.toContain( + "if (readOnly || submissionState === 'saving') {", + ); + }); + + it('blocks form reset while durable submission is in flight and marks a later reset unsaved', () => { + expect(nativeFormHostSource).toMatch( + /onInput=\{handleNativeInput\}\s+onSubmit=\{handleSubmit\}\s+onReset=\{handleReset\}/u, + ); + expect(nativeFormHostSource).toContain( + 'shouldBlockReferenceHostFormMutation', + ); + expect(nativeFormHostSource).toContain('submitAuthorized.isInFlight,'); + expect(nativeFormHostSource).toContain('event.preventDefault();'); + expect(nativeFormHostSource).toContain("setSubmissionState('idle');"); + expect(nativeFormHostSource).toMatch( + /type="reset"\s+disabled=\{readOnly \|\| submissionState === 'saving'\}/u, + ); + }); + + it('invalidates stale persistence presentation through independent editor and native-input mutation signals', () => { + expect(nativeFormHostSource).toContain('const documentGenerationRef = useRef(0);'); + expect(nativeFormHostSource).toContain('function markDocumentDirty() {'); + expect(nativeFormHostSource).toContain( + 'documentGenerationRef.current += 1;', + ); + expect(nativeFormHostSource).toContain( + 'const submittedGeneration = documentGenerationRef.current;', + ); + expect(nativeFormHostSource).toContain( + 'if (documentGenerationRef.current !== submittedGeneration) {', + ); + expect(nativeFormHostSource).toContain( + "setSubmissionState((state) => (state === 'saving' ? state : 'idle'));", + ); + expect(nativeFormHostSource).toContain('onChange={handleDocumentChange}'); + expect(nativeFormHostSource).toContain('function handleNativeInput() {'); + expect(nativeFormHostSource).toContain('onInput={handleNativeInput}'); + expect( + nativeFormHostSource.match(/markDocumentDirty\(\);/gu), + ).toHaveLength(2); + }); + + it('makes host write permission explicit and fail-closes native form writes while read-only', () => { + expect(nativeFormHostSource).toContain('readOnly?: boolean;'); + expect(nativeFormHostSource).toContain('readOnly = false'); + expect(nativeFormHostSource).toContain('editable={!readOnly}'); + expect(nativeFormHostSource).toContain('formFieldDisabled={readOnly}'); + expect(nativeFormHostSource).toContain( + 'shouldBlockReferenceHostFormMutation', + ); + expect(nativeFormHostSource).toMatch( + /type="submit"\s+disabled=\{readOnly \|\| submissionState === 'saving'\}/u, + ); + expect(nativeFormHostSource).toMatch( + /type="reset"\s+disabled=\{readOnly \|\| submissionState === 'saving'\}/u, + ); + }); + + it('demonstrates both uncontrolled and host-controlled editor composition without moving persistence authority into Inkspan', () => { + expect(nativeFormHostSource).toContain( + "controlMode?: 'controlled' | 'uncontrolled';", + ); + expect(nativeFormHostSource).toContain("controlMode = 'uncontrolled'"); + expect(nativeFormHostSource).toContain( + 'data-reference-host-control-mode={controlMode}', + ); + expect(nativeFormHostSource).toContain( + "const [controlledValue, setControlledValue] = useState('# Draft');", + ); + expect(nativeFormHostSource).toContain( + "value={controlMode === 'controlled' ? controlledValue : undefined}", + ); + expect(nativeFormHostSource).toContain('onChange={handleDocumentChange}'); + expect(nativeFormHostSource).toContain( + "defaultValue={controlMode === 'uncontrolled' ? '# Draft' : undefined}", + ); + expect(nativeFormHostSource).toContain( + "if (controlMode === 'controlled') {", + ); + expect(nativeFormHostSource).toContain("setControlledValue('# Draft');"); + }); +}); diff --git a/src/referenceHostOfficeExecutionContract.test.ts b/src/referenceHostOfficeExecutionContract.test.ts new file mode 100644 index 000000000..44f9b6a02 --- /dev/null +++ b/src/referenceHostOfficeExecutionContract.test.ts @@ -0,0 +1,65 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +function repositoryFile(path: string): string { + return readFileSync(resolve(process.cwd(), path), 'utf8'); +} + +describe('reference-host Office execution acceptance', () => { + it('executes the self-contained exact-packed Office journey and documents the package-authority split', () => { + const verifierPath = resolve( + process.cwd(), + 'examples/reference-host/verify-office-handoff.mjs', + ); + const packedVerifierPath = resolve( + process.cwd(), + 'examples/reference-host/verify-packed-office-journey.mjs', + ); + expect(existsSync(verifierPath)).toBe(true); + expect(existsSync(packedVerifierPath)).toBe(true); + if (!existsSync(verifierPath) || !existsSync(packedVerifierPath)) return; + + const verifier = readFileSync(verifierPath, 'utf8'); + const packedVerifier = readFileSync(packedVerifierPath, 'utf8'); + const readme = repositoryFile('examples/reference-host/README.md'); + + expect(verifier).toContain('INKSPAN_BROWSER_PACKAGE_ENTRY'); + expect(verifier).toContain('office-handoff.mjs'); + expect(verifier).toContain('createReferenceDocxRequest'); + expect(verifier).not.toContain('createDocxHandoff'); + expect(verifier).toContain("'-m', 'inkspan_office.cli'"); + expect(verifier).toContain("'office/.venv'"); + expect(verifier).not.toContain("|| 'python'"); + expect(verifier).toContain('document.core_properties.title'); + expect(verifier).toContain('Buyer-ready body.'); + expect(verifier).toContain('mkdtemp'); + expect(verifier).toContain('rm(temporaryDirectory'); + + expect(packedVerifier).toContain("packageAuthority: 'exact-packed-tarball'"); + expect(packedVerifier).toContain("run('pnpm', ['build']"); + expect(packedVerifier).toContain( + "run('pnpm', ['pack', '--pack-destination', packDirectory])", + ); + expect(packedVerifier).toContain("'verify-office-handoff.mjs'"); + expect(packedVerifier).toContain('INKSPAN_BROWSER_PACKAGE_ENTRY: packageEntry'); + expect(packedVerifier).toContain("argv[0] === '--self-test'"); + expect(packedVerifier).toContain('verifyPackedOfficeJourney();'); + + expect(readme).toContain('`verify-office-handoff.mjs`'); + expect(readme).toContain('consumes an already extracted exact package entry'); + expect(readme).toContain( + 'renders the request through the local Inkspan Office CLI', + ); + expect(readme).toContain('does not build, pack, or choose the artifact itself'); + expect(readme).toContain('`verify-packed-office-journey.mjs`'); + expect(readme).toContain('self-contained package-authority wrapper'); + expect(readme).not.toContain( + 'Complete Office-renderer execution and validation also remain pending', + ); + expect(readme).not.toContain( + 'complete converter/Office execution and validation beyond the bounded request helper', + ); + }); +}); diff --git a/src/referenceHostOfficeHandoff.test.ts b/src/referenceHostOfficeHandoff.test.ts new file mode 100644 index 000000000..c6e5409cb --- /dev/null +++ b/src/referenceHostOfficeHandoff.test.ts @@ -0,0 +1,43 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +function repositoryFile(path: string): string { + return readFileSync(resolve(process.cwd(), path), 'utf8'); +} + +describe('reference-host Office handoff', () => { + it('maps bounded editor Markdown through the public React-free projection before creating a DOCX request', () => { + const source = repositoryFile('examples/reference-host/office-handoff.mjs'); + + expect(source).toContain( + "import { markdownToPlainText } from '@contextualwisdomlab/cwl-editor/markdown';", + ); + expect(source).toContain('export function createReferenceDocxRequest'); + expect(source).toContain('markdownToPlainText(markdown)'); + expect(source).toContain("format: 'docx'"); + expect(source).toContain("type: 'paragraph'"); + }); + + it('preserves deterministic plain-text block boundaries as separate DOCX paragraphs', () => { + const source = repositoryFile('examples/reference-host/office-handoff.mjs'); + + expect(source).toContain("const paragraphs = text.split('\\n\\n');"); + expect(source).toContain( + "paragraphs.map((paragraphText) => Object.freeze({ type: 'paragraph', text: paragraphText }))", + ); + expect(source).not.toContain('blocks: Object.freeze([paragraph])'); + }); + + it('keeps Office rendering, authorization, storage, and transport outside the reference helper', () => { + const source = repositoryFile('examples/reference-host/office-handoff.mjs'); + + expect(source).not.toContain('fetch('); + expect(source).not.toContain('localStorage'); + expect(source).not.toContain('process.env'); + expect(source).not.toContain('inkspan_office'); + expect(source).not.toContain('child_process'); + expect(source).not.toContain('writeFile'); + }); +}); diff --git a/src/referenceHostOneCommandJourney.test.ts b/src/referenceHostOneCommandJourney.test.ts new file mode 100644 index 000000000..d59ef433a --- /dev/null +++ b/src/referenceHostOneCommandJourney.test.ts @@ -0,0 +1,141 @@ +import { execFileSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +const repositoryRoot = process.cwd(); +const verifierPath = resolve( + repositoryRoot, + 'examples/reference-host/verify-current-reference-journey.mjs', +); +const applicationSsrVerifierPath = resolve( + repositoryRoot, + 'examples/reference-host/verify-application-ssr.mjs', +); +const browserVerifierPath = resolve( + repositoryRoot, + 'examples/reference-host/verify-browser-journey.mjs', +); +const packedOfficeVerifierPath = resolve( + repositoryRoot, + 'examples/reference-host/verify-packed-office-journey.mjs', +); + +describe('reference-host one-command journey contract', () => { + it('exposes one deterministic command for the currently implemented buyer journey', () => { + expect(existsSync(verifierPath)).toBe(true); + + const output = execFileSync(process.execPath, [verifierPath, '--plan'], { + cwd: repositoryRoot, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 10_000, + }); + + expect(JSON.parse(output.trim())).toEqual({ + command: + 'node examples/reference-host/verify-current-reference-journey.mjs', + contractVersion: 1, + status: 'plan', + steps: [ + { + args: ['--self-test'], + path: 'examples/reference-host/synthetic-document-repository.mjs', + }, + { + args: ['--self-test'], + path: 'examples/reference-host/delayed-proposal.mjs', + }, + { + args: ['--self-test'], + path: 'examples/reference-host/autosave-view-model.mjs', + }, + { + args: ['--self-test'], + path: 'examples/reference-host/collaboration-provider-lifecycle.mjs', + }, + { + args: ['--self-test'], + path: 'examples/reference-host/host-authorized-collaboration.mjs', + }, + { + args: [], + path: 'examples/reference-host/verify-packed-artifact.mjs', + }, + { + args: ['--self-test'], + path: 'examples/reference-host/verify-application-ssr.mjs', + }, + { + args: ['--self-test'], + path: 'examples/reference-host/verify-packed-office-journey.mjs', + }, + { + args: ['--self-test'], + path: 'examples/reference-host/verify-browser-journey.mjs', + }, + ], + }); + }); + + it('keeps exact-packed application SSR in the one-command buyer journey', () => { + expect(existsSync(applicationSsrVerifierPath)).toBe(true); + }); + + it('binds the scoped Office step to a self-contained exact packed tarball', () => { + expect(existsSync(packedOfficeVerifierPath)).toBe(true); + + const output = execFileSync( + process.execPath, + [packedOfficeVerifierPath, '--plan'], + { + cwd: repositoryRoot, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 10_000, + }, + ); + + expect(JSON.parse(output.trim())).toEqual({ + command: + 'node examples/reference-host/verify-packed-office-journey.mjs', + contractVersion: 1, + packageAuthority: 'exact-packed-tarball', + status: 'plan', + }); + }); + + it('binds the scoped browser step to an exact packed tarball and all supported engines', () => { + expect(existsSync(browserVerifierPath)).toBe(true); + + const output = execFileSync( + process.execPath, + [browserVerifierPath, '--plan'], + { + cwd: repositoryRoot, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 10_000, + }, + ); + + expect(JSON.parse(output.trim())).toEqual({ + command: 'node examples/reference-host/verify-browser-journey.mjs', + contractVersion: 1, + packageAuthority: 'exact-packed-tarball', + projects: ['chromium', 'firefox', 'webkit'], + specs: [ + 'reference-host-collaboration.browser.spec.ts', + 'reference-host-proposal.browser.spec.ts', + 'reference-host-recovery.browser.spec.ts', + 'reference-host-dirty-state.browser.spec.ts', + 'reference-host-forced-colors.print.browser.spec.ts', + 'reference-host-hydration.browser.spec.ts', + 'reference-host-readonly.browser.spec.ts', + 'reference-host.print.browser.spec.ts', + ], + status: 'plan', + }); + }); +}); diff --git a/src/referenceHostPackagingBoundary.test.ts b/src/referenceHostPackagingBoundary.test.ts new file mode 100644 index 000000000..e706cc6cc --- /dev/null +++ b/src/referenceHostPackagingBoundary.test.ts @@ -0,0 +1,89 @@ +import { readdirSync, readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +const referenceHostDirectory = resolve(process.cwd(), 'examples/reference-host'); + +function repositoryFile(path: string): string { + return readFileSync(resolve(process.cwd(), path), 'utf8'); +} + +function referenceHostExecutableFiles(): string[] { + return readdirSync(referenceHostDirectory).filter((path) => + /\.(?:[cm]?[jt]s|[jt]sx)$/u.test(path), + ); +} + +function referenceHostRuntimeFiles(): string[] { + return referenceHostExecutableFiles().filter( + (path) => !path.startsWith('verify-'), + ); +} + +describe('reference-host package authority boundary', () => { + it('keeps every reference-host file outside the npm publish inventory', () => { + const packageMetadata = JSON.parse(repositoryFile('package.json')) as { + files: string[]; + }; + + expect(packageMetadata.files.some((path) => path.startsWith('examples'))).toBe( + false, + ); + }); + + it('rejects source-relative and workspace-alias imports in executable reference files', () => { + const executableFiles = referenceHostExecutableFiles(); + + expect(executableFiles).toEqual( + expect.arrayContaining([ + 'hydration-gate.tsx', + 'native-form-host.tsx', + 'single-flight-submission.ts', + ]), + ); + expect(executableFiles.length).toBeGreaterThan(0); + for (const file of executableFiles) { + const source = readFileSync(resolve(referenceHostDirectory, file), 'utf8'); + expect(source).not.toMatch(/(?:from|import\()\s*['"][^'"]*src\//u); + expect(source).not.toContain('workspace:'); + } + }); + + it('rejects runtime environment-variable authority in reference-host runtime files', () => { + const runtimeFiles = referenceHostRuntimeFiles(); + + expect(runtimeFiles).toEqual( + expect.arrayContaining([ + 'browser-host.tsx', + 'reference-host-app.tsx', + 'native-form-host.tsx', + ]), + ); + expect(runtimeFiles.length).toBeGreaterThan(0); + for (const file of runtimeFiles) { + const source = readFileSync(resolve(referenceHostDirectory, file), 'utf8'); + expect(source).not.toMatch(/\bprocess\s*\.\s*env\b/u); + expect(source).not.toMatch(/\bimport\s*\.\s*meta\s*\.\s*env\b/u); + } + }); + + it('keeps the buyer-facing inventory synchronized without claiming complete app acceptance', () => { + const readme = readFileSync(resolve(referenceHostDirectory, 'README.md'), 'utf8'); + const executableFiles = referenceHostExecutableFiles(); + + for (const file of executableFiles) { + expect(readme).toContain(`\`${file}\``); + } + expect(readme).toContain('`presentation-full.css`'); + expect(readme).toContain('`presentation-latin.css`'); + expect(readme).toContain( + 'exact packed autosave observer wiring into the host lifecycle projection', + ); + expect(readme).not.toContain('packed-package wiring of the autosave observer'); + expect(readme).toContain('complete reference-host application'); + expect(readme).toContain( + 'do **not** yet satisfy #377\'s complete packed-artifact framework-application acceptance.', + ); + }); +}); \ No newline at end of file diff --git a/src/referenceHostPackedArtifact.test.ts b/src/referenceHostPackedArtifact.test.ts new file mode 100644 index 000000000..146a086c7 --- /dev/null +++ b/src/referenceHostPackedArtifact.test.ts @@ -0,0 +1,53 @@ +import { execFile } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { promisify } from 'node:util'; + +import { describe, expect, it } from 'vitest'; + +const repositoryRoot = process.cwd(); +const verifierPath = resolve( + repositoryRoot, + 'examples/reference-host/verify-packed-artifact.mjs', +); +const packageMetadata = JSON.parse( + readFileSync(resolve(repositoryRoot, 'package.json'), 'utf8'), +) as { name: string; version: string }; + +describe('reference-host packed artifact acceptance', () => { + it( + 'builds, packs, installs, and SSR-imports the exact tarball in an isolated consumer', + async () => { + const { stdout: output } = await promisify(execFile)(process.execPath, [verifierPath], { + cwd: repositoryRoot, + encoding: 'utf8', + timeout: 180_000, + }); + + const result = JSON.parse(output.trim()) as { + packageName?: unknown; + packageVersion?: unknown; + installedFromTarball?: unknown; + consumerInstallCompleted?: unknown; + serverRenderedNamedField?: unknown; + autosaveObserverWired?: unknown; + esmMarkdownProjection?: unknown; + commonJsMarkdownProjection?: unknown; + publicAssetEntriesContained?: unknown; + sourceImportDetected?: unknown; + }; + + expect(result.packageName).toBe(packageMetadata.name); + expect(result.packageVersion).toBe(packageMetadata.version); + expect(result.installedFromTarball).toBe(true); + expect(result.consumerInstallCompleted).toBe(true); + expect(result.serverRenderedNamedField).toBe(true); + expect(result.autosaveObserverWired).toBe(true); + expect(result.esmMarkdownProjection).toBe(true); + expect(result.commonJsMarkdownProjection).toBe(true); + expect(result.publicAssetEntriesContained).toBe(true); + expect(result.sourceImportDetected).toBe(false); + }, + 180_000, + ); +}); diff --git a/src/referenceHostPackedBrowserBinding.test.ts b/src/referenceHostPackedBrowserBinding.test.ts new file mode 100644 index 000000000..9b2c542ce --- /dev/null +++ b/src/referenceHostPackedBrowserBinding.test.ts @@ -0,0 +1,87 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +function repositoryFile(path: string): string { + return readFileSync(resolve(process.cwd(), path), 'utf8'); +} + +describe('reference-host packed browser binding', () => { + it('routes the buyer host public package imports through the exact packed release artifact', () => { + const viteConfig = repositoryFile('tests/browser/vite.config.ts'); + const nativeFormHost = repositoryFile( + 'examples/reference-host/native-form-host.tsx', + ); + const browserHost = repositoryFile( + 'examples/reference-host/browser-host.tsx', + ); + const presentationFull = repositoryFile( + 'examples/reference-host/presentation-full.css', + ); + + expect(nativeFormHost).toContain( + "from '@contextualwisdomlab/cwl-editor'", + ); + expect(browserHost).toContain("import './presentation-full.css'"); + expect(presentationFull).toContain( + "@import '@contextualwisdomlab/cwl-editor/styles.css'", + ); + expect(presentationFull).toContain( + "@import '@contextualwisdomlab/cwl-editor/fonts.css'", + ); + expect(nativeFormHost).not.toContain('inkspan-browser-under-test'); + expect(browserHost).not.toContain('inkspan-browser-under-test'); + + expect(viteConfig).toContain('INKSPAN_BROWSER_PACKAGE_ENTRY'); + expect(viteConfig).toContain( + "find: '@contextualwisdomlab/cwl-editor/styles.css'", + ); + expect(viteConfig).toContain( + "find: '@contextualwisdomlab/cwl-editor/fonts.css'", + ); + expect(viteConfig).toContain( + "find: '@contextualwisdomlab/cwl-editor/fonts-latin.css'", + ); + expect(viteConfig).toContain( + "find: '@contextualwisdomlab/cwl-editor'", + ); + expect(viteConfig).toContain( + "resolve(packedPackageRoot, 'dist/cwl-editor.css')", + ); + expect(viteConfig).toContain( + "resolve(packedPackageRoot, 'src/fonts/fonts.css')", + ); + expect(viteConfig).toContain( + "resolve(packedPackageRoot, 'src/fonts/fonts-latin.css')", + ); + expect(viteConfig).toContain('replacement: packageEntry'); + }); + + it('pre-optimizes every browser application entry before parallel Playwright workers can invalidate shared Vite dependency chunks', () => { + const viteConfig = repositoryFile('tests/browser/vite.config.ts'); + + expect(viteConfig).toContain('optimizeDeps: {'); + expect(viteConfig).toContain("'tests/browser/harness.html'"); + expect(viteConfig).toContain( + "'examples/reference-host/browser-host.html'", + ); + }); + + it('installs packed runtime dependencies and shares consumer-owned React and Yjs instances with the host', () => { + const verifier = repositoryFile('examples/reference-host/verify-browser-journey.mjs'); + const viteConfig = repositoryFile('tests/browser/vite.config.ts'); + expect(verifier).toContain("['install', '--prefer-offline', '--ignore-scripts', '--no-frozen-lockfile']"); + expect(verifier).toContain('file:${tarballPath}'); + expect(verifier).toContain('installedDependencyClosure: true'); + expect(verifier).toContain("'--reporter=json'"); + expect(verifier).toContain("['unexpected', 'skipped', 'flaky']"); + expect(verifier).toContain('tests: browserReport.stats'); + expect(viteConfig).toContain("['react', 'react-dom', 'yjs']"); + expect(viteConfig).toContain("find: '@contextualwisdomlab/cwl-editor/collaboration'"); + expect(viteConfig).toContain("resolve(packedPackageRoot, 'dist/cwl-collaboration.js')"); + expect(viteConfig).toContain('packageRequire.resolve(`${peerName}/package.json`)'); + expect(viteConfig).toContain('strict: true'); + expect(viteConfig).toContain('allow: [repositoryRoot, ...(packedPackageRoot ? [packedPackageRoot] : [])]'); + }); +}); diff --git a/src/referenceHostPresentationAssets.test.ts b/src/referenceHostPresentationAssets.test.ts new file mode 100644 index 000000000..801d859d8 --- /dev/null +++ b/src/referenceHostPresentationAssets.test.ts @@ -0,0 +1,47 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +function repositoryFile(path: string): string { + return readFileSync(resolve(process.cwd(), path), 'utf8'); +} + +describe('reference-host public presentation assets', () => { + it('wires editor styles plus the complete multilingual font option through public package subpaths', () => { + const source = repositoryFile('examples/reference-host/presentation-full.css'); + + expect(source).toBe( + "@import '@contextualwisdomlab/cwl-editor/styles.css';\n" + + "@import '@contextualwisdomlab/cwl-editor/fonts.css';\n", + ); + }); + + it('wires editor styles plus the smaller Latin font option through public package subpaths', () => { + const source = repositoryFile('examples/reference-host/presentation-latin.css'); + + expect(source).toBe( + "@import '@contextualwisdomlab/cwl-editor/styles.css';\n" + + "@import '@contextualwisdomlab/cwl-editor/fonts-latin.css';\n", + ); + }); + + it('runs the real browser host through the complete multilingual presentation entrypoint', () => { + const source = repositoryFile('examples/reference-host/browser-host.tsx'); + + expect(source).toContain("import './presentation-full.css';"); + expect(source).not.toContain( + "import '@contextualwisdomlab/cwl-editor/styles.css';", + ); + }); + + it('keeps every referenced presentation entrypoint in the published package export map', () => { + const packageMetadata = JSON.parse(repositoryFile('package.json')) as { + exports: Record; + }; + + expect(packageMetadata.exports).toHaveProperty('./styles.css'); + expect(packageMetadata.exports).toHaveProperty('./fonts.css'); + expect(packageMetadata.exports).toHaveProperty('./fonts-latin.css'); + }); +}); \ No newline at end of file diff --git a/src/referenceHostReflectionContainment.test.ts b/src/referenceHostReflectionContainment.test.ts new file mode 100644 index 000000000..54686ad6f --- /dev/null +++ b/src/referenceHostReflectionContainment.test.ts @@ -0,0 +1,146 @@ +import { execFileSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +const OFFICE_MARKDOWN_IMPORT = + "import { markdownToPlainText } from '@contextualwisdomlab/cwl-editor/markdown';"; + +function fixtureModuleUrl(fixture: string): string { + const path = resolve(process.cwd(), fixture); + if (fixture !== 'examples/reference-host/office-handoff.mjs') { + return pathToFileURL(path).href; + } + + const source = readFileSync(path, 'utf8'); + const isolatedSource = source.replace( + OFFICE_MARKDOWN_IMPORT, + 'const markdownToPlainText = (markdown) => markdown;', + ); + if (isolatedSource === source) { + throw new Error('Office handoff public Markdown import contract changed.'); + } + return `data:text/javascript;base64,${Buffer.from(isolatedSource, 'utf8').toString('base64')}`; +} + +function observeHostileReflectionFailure( + fixture: string, + sourceTrap: string, + call: string, +): string { + const fixtureUrl = fixtureModuleUrl(fixture); + const script = ` + const module = await import(${JSON.stringify(fixtureUrl)}); + const privateSentinel = 'private-reflection-sentinel'; + const hostileError = new Proxy({}, { + getPrototypeOf() { + throw privateSentinel; + }, + }); + const hostileSource = new Proxy({}, ${sourceTrap}); + let observed = 'no-error'; + try { + ${call} + } catch (error) { + if (typeof error === 'object' && error !== null) { + const descriptor = Object.getOwnPropertyDescriptor(error, 'message'); + observed = descriptor && Object.prototype.hasOwnProperty.call(descriptor, 'value') + ? descriptor.value + : 'object-error'; + } else { + observed = String(error); + } + } + process.stdout.write(JSON.stringify({ observed })); + `; + const output = execFileSync( + process.execPath, + ['--input-type=module', '--eval', script], + { encoding: 'utf8' }, + ); + return (JSON.parse(output) as { observed: string }).observed; +} + +describe('reference-host hostile reflection containment', () => { + it('redacts hostile meta-object failures across every reference boundary', () => { + const prototypeTrap = `{ + getPrototypeOf() { + throw hostileError; + }, + }`; + const descriptorTrap = `{ + getOwnPropertyDescriptor() { + throw hostileError; + }, + }`; + + expect( + observeHostileReflectionFailure( + 'examples/reference-host/synthetic-document-repository.mjs', + prototypeTrap, + 'module.createSyntheticDocumentRepository(hostileSource);', + ), + ).toBe('Reference persistence invalid_options.'); + expect( + observeHostileReflectionFailure( + 'examples/reference-host/delayed-proposal.mjs', + prototypeTrap, + 'await module.createDelayedProposal(hostileSource);', + ), + ).toBe('proposal creation is invalid.'); + expect( + observeHostileReflectionFailure( + 'examples/reference-host/office-handoff.mjs', + `{ + get(_target, property) { + if (property === 'title') throw privateSentinel; + return undefined; + }, + }`, + 'module.createReferenceDocxRequest(hostileSource);', + ), + ).toBe('Office handoff input is invalid.'); + expect( + observeHostileReflectionFailure( + 'examples/reference-host/autosave-view-model.mjs', + descriptorTrap, + 'module.createAutosaveViewModel().observe(hostileSource);', + ), + ).toBe('autosave snapshot is invalid.'); + expect( + observeHostileReflectionFailure( + 'examples/reference-host/collaboration-provider-lifecycle.mjs', + descriptorTrap, + 'module.createHostCollaborationLifecycle(hostileSource);', + ), + ).toBe('collaboration options are invalid.'); + expect( + observeHostileReflectionFailure( + 'examples/reference-host/collaboration-provider-lifecycle.mjs', + descriptorTrap, + `module.createHostCollaborationLifecycle({ + documentFactory() { return hostileSource; }, + providerFactory() { + return { connect() {}, disconnect() {}, destroy() {} }; + }, + roomId: 'reference-room', + actorId: 'reference-actor', + });`, + ), + ).toBe('documentFactory returned an invalid document.'); + expect( + observeHostileReflectionFailure( + 'examples/reference-host/collaboration-provider-lifecycle.mjs', + descriptorTrap, + `module.createHostCollaborationLifecycle({ + documentFactory() { return { destroy() {} }; }, + providerFactory() { throw hostileError; }, + roomId: 'reference-room', + actorId: 'reference-actor', + });`, + ), + ).toBe('collaboration lifecycle initialization failed.'); + }); +}); diff --git a/src/referenceHostSingleFlightSubmission.test.ts b/src/referenceHostSingleFlightSubmission.test.ts new file mode 100644 index 000000000..0f40d5c99 --- /dev/null +++ b/src/referenceHostSingleFlightSubmission.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createSingleFlightSubmission } from '../examples/reference-host/single-flight-submission.js'; + +describe('reference-host authorized submission single-flight boundary', () => { + it('admits at most one durable host submission at a time and permits a later submission after settlement', async () => { + let resolveFirst: (() => void) | undefined; + const firstResult = new Promise((resolve) => { + resolveFirst = resolve; + }); + const onAuthorizedSubmit = vi + .fn<(messageBody: string) => Promise>() + .mockImplementationOnce(async () => firstResult) + .mockResolvedValue(undefined); + const states: string[] = []; + const submit = createSingleFlightSubmission(onAuthorizedSubmit, (state) => { + states.push(state); + }); + + const first = submit('# First'); + + // Admission is synchronous even when a React-style state observer has not + // committed a render yet. Native submit/reset handlers must be able to + // consult this gate directly rather than infer it from presentation state. + expect(submit.isInFlight()).toBe(true); + + const overlapping = submit('# Overlapping'); + + expect(onAuthorizedSubmit).toHaveBeenCalledTimes(1); + expect(onAuthorizedSubmit).toHaveBeenLastCalledWith('# First'); + await expect(overlapping).resolves.toBe(false); + expect(submit.isInFlight()).toBe(true); + expect(states).toEqual(['saving']); + + resolveFirst?.(); + await expect(first).resolves.toBe(true); + expect(submit.isInFlight()).toBe(false); + expect(states).toEqual(['saving', 'saved']); + + await expect(submit('# Later')).resolves.toBe(true); + expect(submit.isInFlight()).toBe(false); + expect(onAuthorizedSubmit).toHaveBeenCalledTimes(2); + expect(onAuthorizedSubmit).toHaveBeenLastCalledWith('# Later'); + expect(states).toEqual(['saving', 'saved', 'saving', 'saved']); + }); + + it('releases the gate after a failed host submission without exposing the failure value', async () => { + const privateFailure = new Error('private durable-store detail'); + const onAuthorizedSubmit = vi + .fn<(messageBody: string) => Promise>() + .mockRejectedValueOnce(privateFailure) + .mockResolvedValue(undefined); + const states: string[] = []; + const submit = createSingleFlightSubmission(onAuthorizedSubmit, (state) => { + states.push(state); + }); + + await expect(submit('# Failing')).resolves.toBe(false); + expect(submit.isInFlight()).toBe(false); + await expect(submit('# Retry')).resolves.toBe(true); + + expect(onAuthorizedSubmit).toHaveBeenCalledTimes(2); + expect(states).toEqual(['saving', 'failed', 'saving', 'saved']); + }); + + it('contains a saving-state observer failure without blocking durable host submission or later retries', async () => { + const privateObserverFailure = new Error('private presentation detail'); + const onAuthorizedSubmit = vi + .fn<(messageBody: string) => Promise>() + .mockResolvedValue(undefined); + let savingNotifications = 0; + const submit = createSingleFlightSubmission(onAuthorizedSubmit, (state) => { + if (state === 'saving' && savingNotifications++ === 0) { + throw privateObserverFailure; + } + }); + + await expect(submit('# First')).resolves.toBe(true); + expect(submit.isInFlight()).toBe(false); + await expect(submit('# Retry')).resolves.toBe(true); + expect(onAuthorizedSubmit).toHaveBeenCalledTimes(2); + }); + + it('does not reclassify successful durable persistence when the saved-state observer fails', async () => { + const privateObserverFailure = new Error('private presentation detail'); + const onAuthorizedSubmit = vi + .fn<(messageBody: string) => Promise>() + .mockResolvedValue(undefined); + const states: string[] = []; + const submit = createSingleFlightSubmission(onAuthorizedSubmit, (state) => { + states.push(state); + if (state === 'saved') { + throw privateObserverFailure; + } + }); + + await expect(submit('# Persisted')).resolves.toBe(true); + expect(submit.isInFlight()).toBe(false); + expect(onAuthorizedSubmit).toHaveBeenCalledOnce(); + expect(states).toEqual(['saving', 'saved']); + }); +}); diff --git a/src/referenceHostSyntheticRepository.test.ts b/src/referenceHostSyntheticRepository.test.ts new file mode 100644 index 000000000..60b642f0e --- /dev/null +++ b/src/referenceHostSyntheticRepository.test.ts @@ -0,0 +1,207 @@ +import { execFileSync } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +const fixturePath = resolve( + process.cwd(), + 'examples/reference-host/synthetic-document-repository.mjs', +); +const guidePath = resolve(process.cwd(), 'examples/reference-host/README.md'); + +describe('reference-host synthetic durable repository contract', () => { + it('ships one executable reference-only repository fixture outside the published runtime', () => { + expect(existsSync(fixturePath)).toBe(true); + }); + + it('keeps the host persistence fixture source-independent and network-free', () => { + if (!existsSync(fixturePath)) return; + const source = readFileSync(fixturePath, 'utf8'); + + expect(source).not.toMatch(/(?:from|import\()\s*['"][^'"]*src\//u); + expect(source).not.toMatch(/\b(?:fetch|XMLHttpRequest|WebSocket)\b/u); + expect(source).toContain('REFERENCE_ONLY'); + expect(source).toContain('If-Match'); + }); + + it('proves failure-safe retry, restore, fork isolation, and stale-write conflict semantics', () => { + if (!existsSync(fixturePath)) return; + const output = execFileSync(process.execPath, [fixturePath, '--self-test'], { + encoding: 'utf8', + }); + + expect(JSON.parse(output)).toEqual({ + afterAmbiguousValidator: '"v1"', + afterFailureValidator: '"v2"', + conflictCurrentValidator: '"v2"', + forkDocument: 'Buyer draft v1', + forkFinalDocument: 'Fork-only edit', + forkInitialValidator: '"f1-v1"', + forkSavedValidator: '"f1-v2"', + initialValidator: '"v1"', + restoredValidator: '"v4"', + retrySavedValidator: '"v3"', + savedValidator: '"v2"', + sourceDocumentAfterFork: 'Buyer draft v1', + sourceValidatorAfterFork: '"v4"', + }); + }); + + it('gives an immediate fork validator authority distinct from its unchanged source', () => { + if (!existsSync(fixturePath)) return; + const fixtureUrl = pathToFileURL(fixturePath).href; + const output = execFileSync( + process.execPath, + [ + '--input-type=module', + '--eval', + `import { createSyntheticDocumentRepository } from ${JSON.stringify(fixtureUrl)}; +const source = createSyntheticDocumentRepository({ + documentId: 'buyer-document', + initialDocument: 'Buyer draft v1', +}); +const sourceInitial = source.read('buyer-document'); +const forked = source.fork({ + documentId: 'buyer-document', + forkDocumentId: 'buyer-document-fork', + ifMatch: sourceInitial.validator, +}); +const forkInitial = forked.repository.read('buyer-document-fork'); +process.stdout.write(JSON.stringify({ + forkValidator: forkInitial.validator, + sourceValidator: sourceInitial.validator, +}));`, + ], + { encoding: 'utf8' }, + ); + const evidence = JSON.parse(output) as { + forkValidator: string; + sourceValidator: string; + }; + + expect(evidence.forkValidator).not.toBe(evidence.sourceValidator); + }); + + it('keeps deeply nested fork-issued validators acceptable to their own save boundary', () => { + if (!existsSync(fixturePath)) return; + const fixtureUrl = pathToFileURL(fixturePath).href; + const output = execFileSync( + process.execPath, + [ + '--input-type=module', + '--eval', + `import { createSyntheticDocumentRepository } from ${JSON.stringify(fixtureUrl)}; +let repository = createSyntheticDocumentRepository({ + documentId: 'buyer-document-0', + initialDocument: 'Buyer draft v1', +}); +let documentId = 'buyer-document-0'; +for (let depth = 1; depth <= 100; depth += 1) { + const current = repository.read(documentId); + const forkDocumentId = \`buyer-document-\${depth}\`; + const forked = repository.fork({ + documentId, + forkDocumentId, + ifMatch: current.validator, + }); + repository = forked.repository; + documentId = forkDocumentId; +} +const leaf = repository.read(documentId); +const saved = repository.save({ + documentId, + document: 'Deep fork edit', + ifMatch: leaf.validator, +}); +process.stdout.write(JSON.stringify({ + initialValidatorLength: leaf.validator.length, + savedStatus: saved.status, + savedValidatorLength: saved.validator.length, +}));`, + ], + { encoding: 'utf8' }, + ); + const evidence = JSON.parse(output) as { + initialValidatorLength: number; + savedStatus: string; + savedValidatorLength: number; + }; + + expect(evidence.savedStatus).toBe('saved'); + expect(evidence.initialValidatorLength).toBeLessThanOrEqual(256); + expect(evidence.savedValidatorLength).toBeLessThanOrEqual(256); + }); + + it('accepts an empty document body while keeping document identifiers non-empty', () => { + if (!existsSync(fixturePath)) return; + const output = execFileSync( + process.execPath, + [fixturePath, '--empty-document-self-test'], + { encoding: 'utf8' }, + ); + + expect(JSON.parse(output)).toEqual({ + clearedDocument: '', + clearedValidator: '"v2"', + emptyDocumentIdError: 'invalid_document_id', + initialEmptyDocument: '', + }); + }); + + it('fails closed without invoking caller-owned option, save, or fork-request accessors', () => { + if (!existsSync(fixturePath)) return; + const output = execFileSync( + process.execPath, + [fixturePath, '--hostile-accessor-self-test'], + { encoding: 'utf8' }, + ); + + expect(JSON.parse(output)).toEqual({ + forkErrorCode: 'invalid_fork_request', + forkGetterCalls: 0, + optionErrorCode: 'invalid_options', + optionGetterCalls: 0, + requestErrorCode: 'invalid_request', + requestGetterCalls: 0, + }); + }); + + it('fails closed on unknown option, save, and fork fields before durable state changes', () => { + if (!existsSync(fixturePath)) return; + const output = execFileSync( + process.execPath, + [fixturePath, '--unknown-field-self-test'], + { encoding: 'utf8' }, + ); + + expect(JSON.parse(output)).toEqual({ + forkErrorCode: 'invalid_fork_request', + optionErrorCode: 'invalid_options', + saveErrorCode: 'invalid_request', + savedDocument: 'Buyer draft v1', + savedValidator: '"v1"', + }); + }); + + it('keeps the buyer guide code-current for retry, ambiguity reconciliation, restore, and independent fork semantics', () => { + const guide = readFileSync(guidePath, 'utf8'); + + expect(guide).toContain( + 'A confirmed failure can be retried with the unchanged current validator.', + ); + expect(guide).toContain( + '`ambiguous_failure` models a pre-commit failure, while `ambiguous_commit_failure` commits durable state but returns the same ambiguous error without a replacement validator.', + ); + expect(guide).toContain( + 'After either ambiguous outcome, re-read durable state before retrying instead of advancing or blindly reusing the caller\'s last known validator.', + ); + expect(guide).toContain( + 'A restore is a normal confirmed save against the current validator and advances it only after success.', + ); + expect(guide).toContain( + 'A fork requires the current validator, copies the current document into an independent reference repository, and starts that fork at a fresh validator.', + ); + }); +}); diff --git a/src/referenceHostSyntheticRepositoryAmbiguousCommit.test.ts b/src/referenceHostSyntheticRepositoryAmbiguousCommit.test.ts new file mode 100644 index 000000000..c84e07abe --- /dev/null +++ b/src/referenceHostSyntheticRepositoryAmbiguousCommit.test.ts @@ -0,0 +1,65 @@ +import { execFileSync } from 'node:child_process'; +import { pathToFileURL } from 'node:url'; +import { resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +const fixturePath = resolve( + process.cwd(), + 'examples/reference-host/synthetic-document-repository.mjs', +); + +describe('reference-host ambiguous persistence reconciliation', () => { + it('models a transport-ambiguous write as possibly committed and forces a durable read before retry', () => { + const fixtureUrl = JSON.stringify(pathToFileURL(fixturePath).href); + const script = ` + import { + ReferencePersistenceError, + createSyntheticDocumentRepository, + } from ${fixtureUrl}; + const repository = createSyntheticDocumentRepository({ + documentId: 'buyer-document', + initialDocument: 'Buyer draft v1', + }); + const initial = repository.read('buyer-document'); + let ambiguousError = null; + try { + repository.save({ + documentId: 'buyer-document', + document: 'Possibly committed draft', + ifMatch: initial.validator, + outcome: 'ambiguous_commit_failure', + }); + } catch (error) { + ambiguousError = + error instanceof ReferencePersistenceError ? error.code : 'unexpected_error'; + } + const reconciled = repository.read('buyer-document'); + const staleRetry = repository.save({ + documentId: 'buyer-document', + document: 'Blind retry must not overwrite', + ifMatch: initial.validator, + }); + process.stdout.write(JSON.stringify({ + ambiguousError, + initialValidator: initial.validator, + reconciledDocument: reconciled.document, + reconciledValidator: reconciled.validator, + staleRetry, + })); + `; + const output = execFileSync( + process.execPath, + ['--input-type=module', '--eval', script], + { encoding: 'utf8' }, + ); + + expect(JSON.parse(output)).toEqual({ + ambiguousError: 'ambiguous_failure', + initialValidator: '"v1"', + reconciledDocument: 'Possibly committed draft', + reconciledValidator: '"v2"', + staleRetry: { status: 'conflict', currentValidator: '"v2"' }, + }); + }); +}); diff --git a/tests/browser/playwright.config.ts b/tests/browser/playwright.config.ts index d7d4c2a20..85c2a8552 100644 --- a/tests/browser/playwright.config.ts +++ b/tests/browser/playwright.config.ts @@ -2,7 +2,7 @@ import { defineConfig, devices } from '@playwright/test'; const HARNESS_ORIGIN = 'http://127.0.0.1:4173'; const HARNESS_URL = `${HARNESS_ORIGIN}/tests/browser/harness.html`; -const ENGINE_BROWSER_SPECS = /(?:clipboard|focus|print)\.browser\.spec\.ts/u; +const ENGINE_BROWSER_SPECS = /\.browser\.spec\.ts$/u; export default defineConfig({ testDir: './specs', diff --git a/tests/browser/specs/reference-host-collaboration.browser.spec.ts b/tests/browser/specs/reference-host-collaboration.browser.spec.ts new file mode 100644 index 000000000..139ab5edf --- /dev/null +++ b/tests/browser/specs/reference-host-collaboration.browser.spec.ts @@ -0,0 +1,133 @@ +import { expect, test, type Page } from '@playwright/test'; + +const collaborationUrl = '/examples/reference-host/browser-host.html?journey=collaboration'; + +async function openCollaboration(page: Page, suffix = '') { + const errors: string[] = []; + page.on('pageerror', (error) => errors.push(error.message)); + page.on('console', (message) => { if (message.type() === 'error') errors.push(message.text()); }); + await page.route('**/*', async (route) => { + if (new URL(route.request().url()).origin === 'http://127.0.0.1:4173') await route.continue(); + else { errors.push('Unexpected external request'); await route.abort(); } + }); + await page.goto(`${collaborationUrl}${suffix}`); + await expect(page.getByRole('heading', { name: 'Try a local collaboration session' })).toBeVisible(); + return errors; +} + +async function replaceDraft(page: Page, text: string) { + const textbox = page.getByRole('textbox', { name: 'Your draft', exact: true }); + await textbox.focus(); + await page.keyboard.press('ControlOrMeta+A'); + await page.keyboard.insertText(text); + await expect(textbox).toHaveText(text); +} + +async function events(page: Page) { + return page.evaluate(() => (window as typeof window & { referenceHostCollaborationEvents: string[] }).referenceHostCollaborationEvents); +} + +test('synchronizes local views, replaces the connection and confirms final teardown', async ({ page }) => { + const errors = await openCollaboration(page); + await page.getByRole('button', { name: 'Start local session', exact: true }).click(); + await replaceDraft(page, 'My local shared draft'); + await expect(page.getByRole('textbox', { name: 'Other local view', exact: true })).toHaveText('My local shared draft'); + await page.getByRole('button', { name: 'Reconnect', exact: true }).click(); + await expect(page.locator('output')).toHaveText('Local views connected. Nothing is saved or sent to a server.'); + await expect(page.getByRole('textbox', { name: 'Your draft', exact: true })).toHaveText('My local shared draft'); + await replaceDraft(page, 'Still shared after reconnect'); + await expect(page.getByRole('textbox', { name: 'Other local view', exact: true })).toHaveText('Still shared after reconnect'); + expect((await events(page)).filter((event) => event.startsWith('authorize:'))).toEqual(['authorize:1', 'authorize:2']); + expect((await events(page)).filter((event) => event.startsWith('provider:destroy:'))).toEqual(['provider:destroy:1']); + page.once('dialog', (dialog) => dialog.dismiss()); + await page.getByRole('button', { name: 'Close local session', exact: true }).click(); + await expect(page.getByRole('textbox', { name: 'Your draft', exact: true })).toHaveText('Still shared after reconnect'); + page.once('dialog', (dialog) => dialog.accept()); + await page.getByRole('button', { name: 'Close local session', exact: true }).click(); + await expect(page.getByRole('textbox')).toHaveCount(0); + expect((await events(page)).filter((event) => event.startsWith('document:destroy:')).sort()).toEqual(['document:destroy:local', 'document:destroy:peer']); + await expect(page.getByRole('button', { name: 'Start local session', exact: true })).toBeFocused(); + await page.keyboard.press('Enter'); + await expect(page.getByRole('textbox', { name: 'Your draft', exact: true })).toBeEmpty(); + expect(errors).toEqual([]); +}); + +test('replaces an indeterminate connection without duplicate admission or stale listeners', async ({ page }) => { + const errors = await openCollaboration(page); + await page.getByLabel('Fail the next connection').check(); + await page.getByRole('button', { name: 'Start local session', exact: true }).evaluate((button: HTMLButtonElement) => { button.click(); button.click(); }); + await expect(page.locator('output')).toHaveText('The connection could not be confirmed. Reconnect to try again; your local draft is still here.'); + await expect(page.locator('body')).not.toContainText('Disconnected'); + await expect(page.locator('body')).not.toContainText('private local connection fixture cause'); + expect((await events(page)).filter((event) => event.startsWith('provider:create:'))).toEqual(['provider:create:1']); + await replaceDraft(page, 'Draft after uncertain connection'); + await page.getByRole('button', { name: 'Reconnect', exact: true }).evaluate((button: HTMLButtonElement) => { button.click(); button.click(); }); + await expect(page.locator('output')).toHaveText('Local views connected. Nothing is saved or sent to a server.'); + await expect(page.getByRole('textbox', { name: 'Other local view', exact: true })).toHaveText('Draft after uncertain connection'); + const recorded = await events(page); + expect(recorded.indexOf('provider:destroy:1')).toBeLessThan(recorded.indexOf('provider:create:2')); + expect(recorded.filter((event) => event.startsWith('provider:create:'))).toEqual(['provider:create:1', 'provider:create:2']); + await replaceDraft(page, 'Only the new connection receives this'); + expect((await events(page)).slice(recorded.length).filter((event) => event.startsWith('provider:forward:'))).not.toContain('provider:forward:1'); + await expect(page.getByRole('textbox', { name: 'Other local view', exact: true })).toHaveText('Only the new connection receives this'); + expect(errors).toEqual([]); +}); + +test('rechecks admission on replacement and preserves disconnected edits for authorized retry', async ({ page }) => { + const errors = await openCollaboration(page); + await page.getByRole('button', { name: 'Start local session', exact: true }).click(); + await replaceDraft(page, 'Last connected draft'); + await page.getByLabel('Allow the next local connection').uncheck(); + await page.getByRole('button', { name: 'Reconnect', exact: true }).click(); + await expect(page.locator('output')).toContainText('The local connection is not allowed.'); + await replaceDraft(page, 'Kept while disconnected'); + await expect(page.getByRole('textbox', { name: 'Other local view', exact: true })).toHaveText('Last connected draft'); + await page.getByLabel('Allow the next local connection').check(); + await page.getByRole('button', { name: 'Reconnect', exact: true }).click(); + await expect(page.getByRole('textbox', { name: 'Other local view', exact: true })).toHaveText('Kept while disconnected'); + expect((await events(page)).filter((event) => event.startsWith('authorize:'))).toEqual(['authorize:1', 'authorize:2', 'authorize:3']); + expect((await events(page)).filter((event) => event.startsWith('provider:create:'))).toEqual(['provider:create:1', 'provider:create:3']); + expect(errors).toEqual([]); +}); + +test('keeps read-only local views usable at 320px, in forced colors and print', async ({ page }, testInfo) => { + await page.setViewportSize({ width: 320, height: 900 }); + await page.emulateMedia({ forcedColors: 'active' }); + const errors = await openCollaboration(page, '&readOnly=1'); + await page.getByRole('button', { name: 'Start local session', exact: true }).click(); + await expect(page.getByRole('textbox', { name: 'Your draft', exact: true })).toHaveAttribute('contenteditable', 'false'); + await expect(page.getByRole('textbox', { name: 'Other local view', exact: true })).toHaveAttribute('contenteditable', 'false'); + expect(await page.evaluate(() => matchMedia('(forced-colors: active)').matches)).toBe(true); + expect(await page.evaluate(() => Math.max(document.documentElement.scrollWidth, document.body.scrollWidth))).toBeLessThanOrEqual(320); + await page.screenshot({ path: testInfo.outputPath('collaboration-320.png'), fullPage: true }); + await page.emulateMedia({ media: 'print' }); + await expect(page.getByRole('button', { name: 'Reconnect', exact: true })).toBeHidden(); + await expect(page.getByRole('textbox', { name: 'Your draft', exact: true })).toBeVisible(); + expect(errors).toEqual([]); +}); + +test('tears down the actual host documents and connection when the application unmounts', async ({ page }) => { + const errors = await openCollaboration(page); + await page.getByRole('button', { name: 'Start local session', exact: true }).click(); + await replaceDraft(page, 'Draft before leaving'); + await page.evaluate(() => (window as typeof window & { referenceHostUnmount: () => void }).referenceHostUnmount()); + await expect(page.getByRole('textbox')).toHaveCount(0); + const recorded = await events(page); + expect(recorded.filter((event) => event.startsWith('document:destroy:')).sort()).toEqual(['document:destroy:local', 'document:destroy:peer']); + expect(recorded.filter((event) => event.startsWith('provider:destroy:'))).toEqual(['provider:destroy:1']); + expect(errors).toEqual([]); +}); + +test('denies provider construction before starting and permits a later authorized session', async ({ page }) => { + const errors = await openCollaboration(page); + await page.getByLabel('Allow the next local connection').uncheck(); + await page.getByRole('button', { name: 'Start local session', exact: true }).click(); + await expect(page.locator('output')).toHaveText('The local connection is not allowed. Enable the demo permission and try again.'); + await expect(page.getByRole('textbox')).toHaveCount(0); + expect((await events(page)).filter((event) => event.startsWith('provider:create:'))).toEqual([]); + await page.getByLabel('Allow the next local connection').check(); + await page.getByRole('button', { name: 'Start local session', exact: true }).click(); + await replaceDraft(page, 'Allowed local draft'); + await expect(page.getByRole('textbox', { name: 'Other local view', exact: true })).toHaveText('Allowed local draft'); + expect(errors).toEqual([]); +}); diff --git a/tests/browser/specs/reference-host-dirty-state.browser.spec.ts b/tests/browser/specs/reference-host-dirty-state.browser.spec.ts new file mode 100644 index 000000000..5aa9b4c86 --- /dev/null +++ b/tests/browser/specs/reference-host-dirty-state.browser.spec.ts @@ -0,0 +1,128 @@ +import { expect, test, type Page } from '@playwright/test'; + +const REFERENCE_HOST_URL = + 'http://127.0.0.1:4173/examples/reference-host/browser-host.html'; + +function isReferenceHostRequest(requestUrl: string): boolean { + const url = new URL(requestUrl); + return ( + url.protocol === 'http:' && + url.hostname === '127.0.0.1' && + url.port === '4173' + ); +} + +async function failUnexpectedNetwork(page: Page) { + const rejectedRequests: string[] = []; + const pageErrors: string[] = []; + + page.on('pageerror', (error) => { + pageErrors.push(error.message); + }); + await page.route('**/*', async (route) => { + const requestUrl = route.request().url(); + if (isReferenceHostRequest(requestUrl)) { + await route.continue(); + return; + } + rejectedRequests.push(requestUrl); + await route.abort('blockedbyclient'); + }); + + return { rejectedRequests, pageErrors }; +} + +test('invalidates a completed save claim after the buyer edits the packed editor again', async ({ + page, +}) => { + const { rejectedRequests, pageErrors } = await failUnexpectedNetwork(page); + const response = await page.goto(REFERENCE_HOST_URL); + expect(response?.ok()).toBe(true); + + const editor = page.getByRole('textbox'); + const field = page.locator( + '[data-inkspan-form-field][name="message_body"]', + ); + await expect(editor).toBeVisible(); + await expect(field).toHaveValue('# Draft'); + + await page.getByRole('button', { name: 'Save document' }).click(); + await expect(page.getByText('Saved', { exact: true })).toBeVisible(); + + await editor.selectText(); + await page.keyboard.type('Edited after save'); + + await expect(field).not.toHaveValue('# Draft'); + await expect(page.getByText('Saved', { exact: true })).toHaveCount(0); + await expect(page.getByText('Not saved yet', { exact: true })).toBeVisible(); + expect(rejectedRequests).toEqual([]); + expect(pageErrors).toEqual([]); +}); + +test('does not claim a newer document is saved when the submitted version settles later', async ({ + page, +}) => { + const { rejectedRequests, pageErrors } = await failUnexpectedNetwork(page); + const response = await page.goto(`${REFERENCE_HOST_URL}?deferSubmission=1`); + expect(response?.ok()).toBe(true); + + const editor = page.getByRole('textbox'); + const saveButton = page.getByRole('button', { name: 'Save document' }); + await expect(editor).toBeVisible(); + + await editor.selectText(); + await page.keyboard.type('Submitted version'); + await saveButton.click(); + await expect(page.getByText('Saving…', { exact: true })).toBeVisible(); + await expect(saveButton).toBeDisabled(); + + await editor.selectText(); + await page.keyboard.type('Newer unsaved version'); + await page.evaluate(() => window.referenceHostResolveSubmission?.()); + + await expect(page.getByText('Saving…', { exact: true })).toHaveCount(0); + await expect(page.getByText('Saved', { exact: true })).toHaveCount(0); + await expect(page.getByText('Not saved yet', { exact: true })).toBeVisible(); + expect(await page.evaluate(() => window.referenceHostSubmissions)).toEqual([ + '# Submitted version', + ]); + expect(rejectedRequests).toEqual([]); + expect(pageErrors).toEqual([]); +}); + +test('blocks a same-turn native reset after durable submission admission before React presentation commits', async ({ + page, +}) => { + const { rejectedRequests, pageErrors } = await failUnexpectedNetwork(page); + const response = await page.goto(`${REFERENCE_HOST_URL}?deferSubmission=1`); + expect(response?.ok()).toBe(true); + + const editor = page.getByRole('textbox'); + const field = page.locator( + '[data-inkspan-form-field][name="message_body"]', + ); + await expect(editor).toBeVisible(); + + await editor.selectText(); + await page.keyboard.type('Durable gate payload'); + await expect(field).toHaveValue('# Durable gate payload'); + + await page.evaluate(() => { + const form = document.querySelector('form'); + if (!(form instanceof HTMLFormElement)) { + throw new Error('Reference host form is missing.'); + } + form.requestSubmit(); + form.reset(); + }); + + await expect(field).toHaveValue('# Durable gate payload'); + expect(await page.evaluate(() => window.referenceHostSubmissions)).toEqual([ + '# Durable gate payload', + ]); + + await page.evaluate(() => window.referenceHostResolveSubmission?.()); + await expect(page.getByText('Saved', { exact: true })).toBeVisible(); + expect(rejectedRequests).toEqual([]); + expect(pageErrors).toEqual([]); +}); diff --git a/tests/browser/specs/reference-host-forced-colors.print.browser.spec.ts b/tests/browser/specs/reference-host-forced-colors.print.browser.spec.ts new file mode 100644 index 000000000..b8a5f9fa0 --- /dev/null +++ b/tests/browser/specs/reference-host-forced-colors.print.browser.spec.ts @@ -0,0 +1,81 @@ +import { expect, test } from '@playwright/test'; + +const REFERENCE_HOST_URL = + 'http://127.0.0.1:4173/examples/reference-host/browser-host.html'; + +function isReferenceHostRequest(requestUrl: string): boolean { + const url = new URL(requestUrl); + return ( + url.protocol === 'http:' && + url.hostname === '127.0.0.1' && + url.port === '4173' + ); +} + +test.describe.configure({ mode: 'serial' }); + +test('keeps the real buyer host operable in forced-colors mode without runtime network', async ({ + page, +}) => { + const rejectedRequests: string[] = []; + const pageErrors: string[] = []; + + page.on('pageerror', (error) => { + pageErrors.push(error.message); + }); + await page.route('**/*', async (route) => { + const requestUrl = route.request().url(); + if (isReferenceHostRequest(requestUrl)) { + await route.continue(); + return; + } + rejectedRequests.push(requestUrl); + await route.abort('blockedbyclient'); + }); + + await page.emulateMedia({ forcedColors: 'active' }); + const response = await page.goto(REFERENCE_HOST_URL); + expect(response?.ok()).toBe(true); + + await expect( + page.getByRole('heading', { name: 'Inkspan reference host' }), + ).toBeVisible(); + await expect(page.getByRole('textbox')).toBeVisible(); + await expect(page.getByRole('button', { name: 'Save document' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Reset draft' })).toBeVisible(); + await expect( + page.locator('[data-inkspan-form-field][name="message_body"]'), + ).toHaveValue('# Draft'); + await expect(page.getByText('Loading buyer editor')).toHaveCount(0); + + const toolbarButton = page.locator('.cwl-tb-btn').first(); + await expect(toolbarButton).toBeVisible(); + await toolbarButton.focus(); + + const forcedColorsEvidence = await page.evaluate(() => { + const editor = document.querySelector('.cwl-editor'); + const toolbarControl = document.querySelector('.cwl-tb-btn'); + if (!editor || !toolbarControl) { + throw new Error('reference host forced-colors surface is incomplete'); + } + const editorStyle = getComputedStyle(editor); + const toolbarStyle = getComputedStyle(toolbarControl); + return { + forcedColorsMatches: matchMedia('(forced-colors: active)').matches, + editorColor: editorStyle.color, + editorBackgroundColor: editorStyle.backgroundColor, + outlineStyle: toolbarStyle.outlineStyle, + outlineWidth: toolbarStyle.outlineWidth, + outlineColor: toolbarStyle.outlineColor, + }; + }); + + expect(forcedColorsEvidence.forcedColorsMatches).toBe(true); + expect(forcedColorsEvidence.editorColor).not.toBe('rgba(0, 0, 0, 0)'); + expect(forcedColorsEvidence.editorBackgroundColor).not.toBe('rgba(0, 0, 0, 0)'); + expect(forcedColorsEvidence.outlineStyle).not.toBe('none'); + expect(forcedColorsEvidence.outlineWidth).not.toBe('0px'); + expect(forcedColorsEvidence.outlineColor).not.toBe('rgba(0, 0, 0, 0)'); + expect(rejectedRequests).toEqual([]); + expect(pageErrors).toEqual([]); +}); diff --git a/tests/browser/specs/reference-host-hydration.browser.spec.ts b/tests/browser/specs/reference-host-hydration.browser.spec.ts new file mode 100644 index 000000000..664b3ab3e --- /dev/null +++ b/tests/browser/specs/reference-host-hydration.browser.spec.ts @@ -0,0 +1,127 @@ +import { expect, test, type Page } from '@playwright/test'; + +const REFERENCE_HOST_URL = + 'http://127.0.0.1:4173/examples/reference-host/browser-host.html'; + +function isReferenceHostRequest(requestUrl: string): boolean { + const url = new URL(requestUrl); + return ( + url.protocol === 'http:' && + url.hostname === '127.0.0.1' && + url.port === '4173' + ); +} + +async function observeReferenceHost(page: Page) { + const rejectedRequests: string[] = []; + const pageErrors: string[] = []; + const consoleErrors: string[] = []; + + page.on('pageerror', (error) => { + pageErrors.push(error.message); + }); + page.on('console', (message) => { + if (message.type() === 'error') { + consoleErrors.push(message.text()); + } + }); + await page.route('**/*', async (route) => { + const requestUrl = route.request().url(); + if (isReferenceHostRequest(requestUrl)) { + await route.continue(); + return; + } + rejectedRequests.push(requestUrl); + await route.abort('blockedbyclient'); + }); + + return { rejectedRequests, pageErrors, consoleErrors }; +} + +test('preserves an accessible pre-hydration shell and hydrates the packed reference host without mismatch or external network', async ({ + browser, +}) => { + const staticContext = await browser.newContext({ javaScriptEnabled: false }); + const staticPage = await staticContext.newPage(); + const staticEvidence = await observeReferenceHost(staticPage); + + const staticResponse = await staticPage.goto(REFERENCE_HOST_URL); + expect(staticResponse?.ok()).toBe(true); + await expect( + staticPage.getByRole('heading', { name: 'Inkspan reference host' }), + ).toBeVisible(); + await expect( + staticPage.getByText('Loading buyer editor', { exact: true }), + ).toBeVisible(); + await expect(staticPage.locator('[aria-busy="true"]')).toHaveText( + 'Loading buyer editor', + ); + await expect(staticPage.getByRole('textbox')).toHaveCount(0); + expect(staticEvidence.rejectedRequests).toEqual([]); + expect(staticEvidence.pageErrors).toEqual([]); + expect(staticEvidence.consoleErrors).toEqual([]); + await staticContext.close(); + + const hydratedContext = await browser.newContext(); + const hydratedPage = await hydratedContext.newPage(); + const hydratedEvidence = await observeReferenceHost(hydratedPage); + + const hydratedResponse = await hydratedPage.goto(REFERENCE_HOST_URL); + expect(hydratedResponse?.ok()).toBe(true); + await expect( + hydratedPage.getByRole('heading', { name: 'Inkspan reference host' }), + ).toBeVisible(); + await expect(hydratedPage.getByRole('textbox')).toBeVisible(); + await expect( + hydratedPage.getByRole('button', { name: 'Save document' }), + ).toBeVisible(); + await expect( + hydratedPage.getByText('Loading buyer editor', { exact: true }), + ).toHaveCount(0); + await expect(hydratedPage.locator('[aria-busy="true"]')).toHaveCount(0); + await expect( + hydratedPage.locator('[data-inkspan-form-field][name="message_body"]'), + ).toHaveValue('# Draft'); + expect(hydratedEvidence.rejectedRequests).toEqual([]); + expect(hydratedEvidence.pageErrors).toEqual([]); + expect(hydratedEvidence.consoleErrors).toEqual([]); + await hydratedContext.close(); +}); + +test('exercises the controlled public editor composition in the packed reference host without changing host authority', async ({ + browser, +}) => { + const context = await browser.newContext(); + const page = await context.newPage(); + const evidence = await observeReferenceHost(page); + + const response = await page.goto(`${REFERENCE_HOST_URL}?controlMode=controlled`); + expect(response?.ok()).toBe(true); + + const form = page.locator('form[data-reference-host-control-mode="controlled"]'); + await expect(form).toBeVisible(); + const textbox = page.getByRole('textbox'); + await expect(textbox).toBeVisible(); + await expect(textbox).toHaveText('Draft'); + await textbox.fill('Controlled buyer draft'); + await expect( + page.locator('[data-inkspan-form-field][name="message_body"]'), + ).toHaveValue('# Controlled buyer draft'); + + await page.getByRole('button', { name: 'Save document' }).click(); + await expect(page.getByText('Saved', { exact: true })).toBeVisible(); + expect(await page.evaluate(() => window.referenceHostSubmissions)).toEqual([ + '# Controlled buyer draft', + ]); + + await page.getByRole('button', { name: 'Reset draft' }).click(); + await expect(textbox).toHaveText('Draft'); + await expect( + page.locator('[data-inkspan-form-field][name="message_body"]'), + ).toHaveValue('# Draft'); + await expect(page.getByText('Not saved yet', { exact: true })).toBeVisible(); + expect(evidence.rejectedRequests).toEqual([]); + expect(evidence.pageErrors).toEqual([]); + expect(evidence.consoleErrors).toEqual([]); + await context.close(); +}); diff --git a/tests/browser/specs/reference-host-proposal.browser.spec.ts b/tests/browser/specs/reference-host-proposal.browser.spec.ts new file mode 100644 index 000000000..ad6e63984 --- /dev/null +++ b/tests/browser/specs/reference-host-proposal.browser.spec.ts @@ -0,0 +1,159 @@ +import { expect, test, type Page } from '@playwright/test'; + +const proposalUrl = '/examples/reference-host/browser-host.html?journey=proposal'; +const suggestionText = 'An example suggestion for this draft.'; + +async function openProposal(page: Page, suffix = '') { + const errors: string[] = []; + page.on('pageerror', (error) => errors.push(error.message)); + page.on('console', (message) => { if (message.type() === 'error') errors.push(message.text()); }); + await page.route('**/*', async (route) => { + if (new URL(route.request().url()).origin === 'http://127.0.0.1:4173') await route.continue(); + else { errors.push('Unexpected external request'); await route.abort(); } + }); + await page.goto(`${proposalUrl}${suffix}`); + await expect(page.getByRole('heading', { name: 'Review a suggested change' })).toBeVisible(); + await expect(page.getByRole('textbox', { name: 'Draft' })).toHaveText('Draft'); + return errors; +} + +async function replaceDraft(page: Page, text: string) { + const textbox = page.getByRole('textbox', { name: 'Draft' }); + await textbox.focus(); + await page.keyboard.press('ControlOrMeta+A'); + await page.keyboard.insertText(text); + await expect(textbox).toHaveText(text); +} + +test('reviews a local suggestion and applies it only after confirmation', async ({ page }) => { + const errors = await openProposal(page); + await page.getByRole('button', { name: 'Prepare example suggestion' }).click(); + await expect(page.getByRole('status')).toHaveText('Suggestion ready. Review it before applying.'); + await expect(page.getByRole('blockquote')).toHaveText(suggestionText); + page.once('dialog', (dialog) => dialog.dismiss()); + await page.getByRole('button', { name: 'Apply suggestion', exact: true }).click(); + await expect(page.getByRole('textbox', { name: 'Draft' })).toHaveText('Draft'); + page.once('dialog', (dialog) => dialog.accept()); + await page.getByRole('button', { name: 'Apply suggestion', exact: true }).click(); + await expect(page.getByRole('status')).toHaveText('Suggestion applied. Nothing has been saved.'); + await expect(page.getByRole('textbox', { name: 'Draft' })).toHaveText(suggestionText); + await expect(page.getByRole('textbox', { name: 'Draft' })).toBeFocused(); + expect(errors).toEqual([]); +}); + +test('rejects a suggestion prepared before newer local edits', async ({ page }) => { + const errors = await openProposal(page); + await page.getByRole('button', { name: 'Prepare example suggestion' }).click(); + await expect(page.getByRole('status')).toHaveText('Suggestion ready. Review it before applying.'); + await replaceDraft(page, 'My newer draft must stay'); + page.once('dialog', (dialog) => dialog.accept()); + await page.getByRole('button', { name: 'Apply suggestion', exact: true }).click(); + await expect(page.getByRole('status')).toHaveText('Your draft changed. Prepare a new suggestion.'); + await expect(page.getByRole('textbox', { name: 'Draft' })).toHaveText('My newer draft must stay'); + expect(errors).toEqual([]); +}); + +test('captures the original draft before delayed preparation and admits only one preparation', async ({ page }) => { + await page.addInitScript(() => { + const digest = crypto.subtle.digest.bind(crypto.subtle); + const observed = window as typeof window & { releasePreparation?: () => void; preparationCaptures: number }; + observed.preparationCaptures = 0; + crypto.subtle.digest = async (algorithm, data) => { + const result = await digest(algorithm, data); + observed.preparationCaptures += 1; + if (observed.preparationCaptures === 1) { + await new Promise((resolve) => { observed.releasePreparation = resolve; }); + } + return result; + }; + }); + const errors = await openProposal(page); + await page.getByRole('button', { name: 'Prepare example suggestion' }).evaluate((button: HTMLButtonElement) => { button.click(); button.click(); }); + await expect.poll(() => page.evaluate(() => typeof (window as typeof window & { releasePreparation?: () => void }).releasePreparation)).toBe('function'); + await expect(page.getByRole('status')).toHaveText('Preparing a local suggestion…'); + expect(await page.evaluate(() => (window as typeof window & { preparationCaptures: number }).preparationCaptures)).toBe(1); + await replaceDraft(page, 'New text during suggestion preparation'); + await page.evaluate(() => (window as typeof window & { releasePreparation?: () => void }).releasePreparation?.()); + await expect(page.getByRole('status')).toHaveText('Suggestion ready. Review it before applying.'); + page.once('dialog', (dialog) => dialog.accept()); + await page.getByRole('button', { name: 'Apply suggestion', exact: true }).click(); + await expect(page.getByRole('status')).toHaveText('Your draft changed. Prepare a new suggestion.'); + await expect(page.getByRole('textbox', { name: 'Draft' })).toHaveText('New text during suggestion preparation'); + expect(errors).toEqual([]); +}); + +test('preserves edits during asynchronous application and admits only one apply', async ({ page }) => { + await page.addInitScript(() => { + const digest = crypto.subtle.digest.bind(crypto.subtle); + const observed = window as typeof window & { releaseSuggestionDigest?: () => void; proposalConfirmations: number }; + observed.proposalConfirmations = 0; + let delayed = false; + crypto.subtle.digest = async (algorithm, data) => { + const result = await digest(algorithm, data); + if (!delayed && new TextDecoder().decode(data).includes('An example suggestion for this draft.')) { + delayed = true; + await new Promise((resolve) => { observed.releaseSuggestionDigest = resolve; }); + } + return result; + }; + window.confirm = () => { observed.proposalConfirmations += 1; return true; }; + }); + const errors = await openProposal(page); + await page.getByRole('button', { name: 'Prepare example suggestion' }).click(); + await expect(page.getByRole('status')).toHaveText('Suggestion ready. Review it before applying.'); + await page.getByRole('button', { name: 'Apply suggestion', exact: true }).evaluate((button: HTMLButtonElement) => { + button.click(); + button.click(); + Array.from(document.querySelectorAll('button')).find((candidate) => candidate.textContent === 'Discard suggestion')?.click(); + }); + await expect.poll(() => page.evaluate(() => typeof (window as typeof window & { releaseSuggestionDigest?: () => void }).releaseSuggestionDigest)).toBe('function'); + expect(await page.evaluate(() => (window as typeof window & { proposalConfirmations: number }).proposalConfirmations)).toBe(1); + await expect(page.getByRole('button', { name: 'Discard suggestion' })).toBeDisabled(); + await replaceDraft(page, 'New text while the suggestion is being checked'); + await page.evaluate(() => (window as typeof window & { releaseSuggestionDigest?: () => void }).releaseSuggestionDigest?.()); + await expect(page.getByRole('status')).toHaveText('Your draft changed. Prepare a new suggestion.'); + await expect(page.getByRole('textbox', { name: 'Draft' })).toHaveText('New text while the suggestion is being checked'); + await page.getByRole('button', { name: 'Prepare example suggestion' }).click(); + await expect(page.getByRole('status')).toHaveText('Suggestion ready. Review it before applying.'); + await page.getByRole('button', { name: 'Apply suggestion', exact: true }).click(); + await expect(page.getByRole('status')).toHaveText('Suggestion applied. Nothing has been saved.'); + await expect(page.getByRole('textbox', { name: 'Draft' })).toHaveText(suggestionText); + expect(errors).toEqual([]); +}); + +test('supports keyboard discard and narrow forced-color review while read-only prevents preparation', async ({ page }) => { + await page.setViewportSize({ width: 320, height: 780 }); + await page.emulateMedia({ forcedColors: 'active' }); + const errors = await openProposal(page); + await page.getByRole('button', { name: 'Prepare example suggestion' }).click(); + await expect(page.getByRole('status')).toHaveText('Suggestion ready. Review it before applying.'); + await page.getByRole('button', { name: 'Discard suggestion' }).focus(); + expect(await page.evaluate(() => matchMedia('(forced-colors: active)').matches)).toBe(true); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); + await page.screenshot({ path: test.info().outputPath('proposal-review-320.png'), fullPage: true }); + await page.keyboard.press('Enter'); + await expect(page.getByRole('status')).toHaveText('Edit your draft or prepare an example suggestion.'); + await expect(page.getByRole('textbox', { name: 'Draft' })).toHaveText('Draft'); + await expect(page.getByRole('textbox', { name: 'Draft' })).toBeFocused(); + await page.emulateMedia({ media: 'print' }); + await expect(page.getByRole('button', { name: 'Prepare example suggestion' })).not.toBeVisible(); + await expect(page.getByRole('textbox', { name: 'Draft' })).toBeVisible(); + await page.emulateMedia({ media: 'screen' }); + await page.goto(`${proposalUrl}&readOnly=1`); + await expect(page.getByRole('textbox', { name: 'Draft' })).toHaveAttribute('contenteditable', 'false'); + await expect(page.getByRole('button', { name: 'Prepare example suggestion' })).toBeDisabled(); + expect(errors).toEqual([]); +}); + +test('contains a failed revision capture without exposing its private cause or changing the draft', async ({ page }) => { + await page.addInitScript(() => { + crypto.subtle.digest = async () => { throw new Error('Private digest failure detail'); }; + }); + const errors = await openProposal(page); + await page.getByRole('button', { name: 'Prepare example suggestion' }).click(); + await expect(page.getByRole('status')).toHaveText('The suggestion could not be used. Your draft is still here.'); + await expect(page.getByRole('textbox', { name: 'Draft' })).toHaveText('Draft'); + await expect(page.getByText('Private digest failure detail')).toHaveCount(0); + await expect(page.getByRole('button', { name: 'Prepare example suggestion' })).toBeEnabled(); + expect(errors).toEqual([]); +}); diff --git a/tests/browser/specs/reference-host-readonly.browser.spec.ts b/tests/browser/specs/reference-host-readonly.browser.spec.ts new file mode 100644 index 000000000..6b36d98b9 --- /dev/null +++ b/tests/browser/specs/reference-host-readonly.browser.spec.ts @@ -0,0 +1,68 @@ +import { expect, test, type Page } from '@playwright/test'; + +const REFERENCE_HOST_URL = + 'http://127.0.0.1:4173/examples/reference-host/browser-host.html'; + +function isReferenceHostRequest(requestUrl: string): boolean { + const url = new URL(requestUrl); + return ( + url.protocol === 'http:' && + url.hostname === '127.0.0.1' && + url.port === '4173' + ); +} + +async function observeReferenceHost(page: Page) { + const rejectedRequests: string[] = []; + const pageErrors: string[] = []; + + page.on('pageerror', (error) => { + pageErrors.push(error.message); + }); + await page.route('**/*', async (route) => { + const requestUrl = route.request().url(); + if (isReferenceHostRequest(requestUrl)) { + await route.continue(); + return; + } + rejectedRequests.push(requestUrl); + await route.abort('blockedbyclient'); + }); + + return { rejectedRequests, pageErrors }; +} + +test('keeps the read-only reference host inert at the native-form write boundary', async ({ + page, +}) => { + const evidence = await observeReferenceHost(page); + const response = await page.goto(`${REFERENCE_HOST_URL}?readOnly=1`); + expect(response?.ok()).toBe(true); + + const textbox = page.getByRole('textbox'); + const field = page.locator( + '[data-inkspan-form-field][name="message_body"]', + ); + const saveButton = page.getByRole('button', { name: 'Save document' }); + const resetButton = page.getByRole('button', { name: 'Reset draft' }); + const form = page.locator('form'); + + await expect(textbox).toBeVisible(); + await expect(textbox).toHaveAttribute('contenteditable', 'false'); + await expect(textbox).toHaveText('Draft'); + await expect(field).toBeDisabled(); + await expect(field).toHaveValue('# Draft'); + await expect(saveButton).toBeDisabled(); + await expect(resetButton).toBeDisabled(); + + await form.evaluate((element) => { + element.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); + element.dispatchEvent(new Event('reset', { bubbles: true, cancelable: true })); + }); + + await expect(textbox).toHaveText('Draft'); + await expect(field).toHaveValue('# Draft'); + expect(await page.evaluate(() => window.referenceHostSubmissions)).toEqual([]); + expect(evidence.rejectedRequests).toEqual([]); + expect(evidence.pageErrors).toEqual([]); +}); diff --git a/tests/browser/specs/reference-host-recovery.browser.spec.ts b/tests/browser/specs/reference-host-recovery.browser.spec.ts new file mode 100644 index 000000000..b0947931a --- /dev/null +++ b/tests/browser/specs/reference-host-recovery.browser.spec.ts @@ -0,0 +1,405 @@ +import { expect, test, type Page } from '@playwright/test'; + +const recoveryUrl = '/examples/reference-host/browser-host.html?journey=recovery'; + +async function openRecovery(page: Page, suffix = '', initialText = 'Draft') { + const errors: string[] = []; + page.on('pageerror', (error) => errors.push(error.message)); + page.on('console', (message) => { + if (message.type() === 'error') errors.push(message.text()); + }); + await page.route('**/*', async (route) => { + const requestUrl = new URL(route.request().url()); + if (requestUrl.origin === 'http://127.0.0.1:4173') await route.continue(); + else { + errors.push('Unexpected external request'); + await route.abort(); + } + }); + await page.goto(`${recoveryUrl}${suffix}`); + await expect(page.getByRole('heading', { name: 'Save and recover a draft' })).toBeVisible(); + await expect(page.getByRole('textbox')).toHaveText(initialText); + expect((await savedDocuments(page)).originalValidator).toBe('"v1"'); + return errors; +} + +async function savedDocuments(page: Page) { + return page.evaluate(() => window.referenceHostSavedDocuments()); +} + +async function replaceDraft(page: Page, text: string) { + const textbox = page.getByRole('textbox'); + await textbox.focus(); + await page.keyboard.press('ControlOrMeta+A'); + await page.keyboard.insertText(text); + await expect(textbox).toHaveText(text); +} + +for (const forcedColors of ['none', 'active'] as const) { + test(`keeps host control styles outside the editor toolbar with forced colors ${forcedColors}`, async ({ page }) => { + await page.setViewportSize({ width: 320, height: 800 }); + await page.emulateMedia({ forcedColors }); + const errors = await openRecovery(page); + const styles = await page.getByRole('button', { name: 'Delete row', exact: true }).evaluate((button) => { + const properties = ['fontSize', 'fontWeight', 'lineHeight', 'paddingTop', 'paddingBottom', 'height', 'minBlockSize'] as const; + const readStyles = () => { + const computed = getComputedStyle(button); + return Object.fromEntries(properties.map((property) => [property, computed[property]])); + }; + const host = button.closest('.reference-recovery')!; + const insideHost = readStyles(); + host.classList.remove('reference-recovery'); + try { + return { insideHost, withoutHost: readStyles() }; + } finally { + host.classList.add('reference-recovery'); + } + }); + await page.screenshot({ path: test.info().outputPath('toolbar-style-boundary-320.png'), fullPage: true }); + expect(styles.insideHost).toEqual(styles.withoutHost); + const selectBounds = await page.getByLabel('Next save in this demo').boundingBox(); + expect(selectBounds!.height).toBeGreaterThanOrEqual(44); + expect(errors).toEqual([]); + }); +} + +test('saves the newest queued edit and never calls an older submitted draft current', async ({ page }) => { + const errors = await openRecovery(page); + await page.getByLabel('Next save in this demo').selectOption('deferred'); + await replaceDraft(page, 'First submitted draft'); + await expect(page.getByRole('status')).toHaveText('Saving changes…'); + await replaceDraft(page, 'Newest draft'); + await expect(page.getByRole('status')).toHaveText('Saving; newer changes are waiting.'); + await page.getByRole('button', { name: 'Finish pending save' }).click(); + await expect(page.getByRole('status')).toHaveText('All changes saved in this demo.'); + expect((await savedDocuments(page)).original).toContain('Newest draft'); + expect(errors).toEqual([]); +}); + +for (const outcome of ['failure', 'ambiguous_failure', 'ambiguous_commit_failure']) { + test(`rereads after ${outcome} without losing the local draft or duplicating a confirmed save`, async ({ page }) => { + const errors = await openRecovery(page); + await page.getByLabel('Next save in this demo').selectOption(outcome); + await replaceDraft(page, 'My recoverable draft'); + await expect(page.getByRole('status')).toHaveText('Save not confirmed. Your draft is still here.'); + await expect(page.getByRole('textbox')).toHaveText('My recoverable draft'); + await page.getByRole('button', { name: 'Check saved copy and retry' }).click(); + await expect(page.getByRole('status')).toHaveText('Draft recovered and saved in this demo.'); + const documents = await savedDocuments(page); + expect(documents.original).toContain('My recoverable draft'); + expect(documents.originalValidator).toBe('"v2"'); + expect(errors).toEqual([]); + }); +} + +test('keeps both drafts on conflict and continues autosaving only the separate copy', async ({ page }) => { + const errors = await openRecovery(page); + await page.getByLabel('Next save in this demo').selectOption('conflict'); + await replaceDraft(page, 'My conflicting draft'); + await expect(page.getByRole('status')).toHaveText('Another version was saved. Your draft is still here.'); + await page.screenshot({ path: test.info().outputPath('recovery-conflict-desktop.png'), fullPage: true }); + const original = (await savedDocuments(page)).original; + expect(original).toContain('Draft saved elsewhere.'); + await replaceDraft(page, 'My newest conflicting draft'); + const copyButton = page.getByRole('button', { name: 'Save my draft as a separate copy' }); + await expect(copyButton).toBeEnabled(); + await copyButton.evaluate((button: HTMLButtonElement) => { button.click(); button.click(); }); + await expect(page.getByRole('status')).toHaveText('Separate copy saved. The original was not changed.'); + await expect(page.getByRole('textbox')).toHaveText('My newest conflicting draft'); + expect((await savedDocuments(page)).original).toBe(original); + expect((await savedDocuments(page)).copies[0]).toContain('My newest conflicting draft'); + expect((await savedDocuments(page)).copies).toHaveLength(1); + await replaceDraft(page, 'Continue in my copy'); + await expect(page.getByRole('status')).toHaveText('All changes saved in this demo.'); + expect((await savedDocuments(page)).original).toBe(original); + expect((await savedDocuments(page)).copies[0]).toContain('Continue in my copy'); + expect(errors).toEqual([]); +}); + +test('keeps recovery usable at 320px with keyboard and forced colors; read-only never writes', async ({ page }) => { + await page.setViewportSize({ width: 320, height: 780 }); + await page.emulateMedia({ forcedColors: 'active' }); + const errors = await openRecovery(page); + expect(await page.evaluate(() => matchMedia('(forced-colors: active)').matches)).toBe(true); + await page.getByLabel('Next save in this demo').selectOption('failure'); + await replaceDraft(page, 'Keyboard recovery'); + const retryButton = page.getByRole('button', { name: 'Check saved copy and retry' }); + await expect(retryButton).toBeEnabled(); + await retryButton.focus(); + await page.screenshot({ path: test.info().outputPath('recovery-retry-320-forced-colors.png'), fullPage: true }); + await page.keyboard.press('Enter'); + await expect(page.getByRole('status')).toHaveText('Draft recovered and saved in this demo.'); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); + await page.screenshot({ path: test.info().outputPath('recovery-320-forced-colors.png'), fullPage: true }); + await page.emulateMedia({ media: 'print', forcedColors: 'none' }); + await expect(page.getByRole('textbox')).toHaveText('Keyboard recovery'); + await expect(page.getByLabel('Next save in this demo')).not.toBeVisible(); + await page.screenshot({ path: test.info().outputPath('recovery-print.png'), fullPage: true }); + await page.emulateMedia({ media: 'screen' }); + await page.goto(`${recoveryUrl}&readOnly=1`); + await expect(page.getByRole('textbox')).toHaveAttribute('contenteditable', 'false'); + await expect(page.getByLabel('Next save in this demo')).toBeDisabled(); + await expect(page.getByRole('button', { name: 'Use saved version', exact: true })).toHaveCount(0); + expect((await savedDocuments(page)).originalValidator).toBe('"v1"'); + expect(errors).toEqual([]); +}); + +test('recovers newer local edits after a lost confirmation and admits a same-turn retry only once', async ({ page }) => { + const errors = await openRecovery(page); + await page.getByLabel('Next save in this demo').selectOption('ambiguous_commit_failure'); + await replaceDraft(page, 'Committed without confirmation'); + await expect(page.getByRole('status')).toContainText('Save not confirmed'); + await replaceDraft(page, 'Newer local edit'); + const retryButton = page.getByRole('button', { name: 'Check saved copy and retry' }); + await expect(retryButton).toBeEnabled(); + await retryButton.evaluate((button: HTMLButtonElement) => { button.click(); button.click(); }); + await expect(page.getByRole('status')).toHaveText('Draft recovered and saved in this demo.'); + expect((await savedDocuments(page)).original).toContain('Newer local edit'); + expect((await savedDocuments(page)).originalValidator).toBe('"v3"'); + expect(errors).toEqual([]); +}); + +test('does not overwrite a competing save that arrives after the recovery reread', async ({ page }) => { + const errors = await openRecovery(page); + await page.getByLabel('Next save in this demo').selectOption('failure'); + await replaceDraft(page, 'Keep my version'); + await expect(page.getByRole('status')).toContainText('Save not confirmed'); + await page.getByLabel('Next save in this demo').selectOption('conflict'); + await page.getByRole('button', { name: 'Check saved copy and retry' }).click(); + await expect(page.getByRole('status')).toHaveText('Another version was saved. Your draft is still here.'); + await expect(page.getByRole('textbox')).toHaveText('Keep my version'); + expect((await savedDocuments(page)).original).toContain('Draft saved elsewhere.'); + expect((await savedDocuments(page)).originalValidator).toBe('"v2"'); + expect(errors).toEqual([]); +}); + +test('ignores an older digest that settles after a newer draft has saved', async ({ page }) => { + await page.addInitScript(() => { + const digest = crypto.subtle.digest.bind(crypto.subtle); + const pending = window as typeof window & { releaseOldDigest?: () => void; oldDigestFinished?: boolean }; + crypto.subtle.digest = async (algorithm, data) => { + const result = await digest(algorithm, data); + if (new TextDecoder().decode(data).includes('Older slow draft')) { + await new Promise((resolve) => { pending.releaseOldDigest = resolve; }); + pending.oldDigestFinished = true; + } + return result; + }; + }); + const errors = await openRecovery(page); + await replaceDraft(page, 'Older slow draft'); + await expect.poll(() => page.evaluate(() => typeof (window as Window & { releaseOldDigest?: () => void }).releaseOldDigest)).toBe('function'); + await replaceDraft(page, 'Newer fast draft'); + await expect(page.getByRole('textbox')).toHaveText('Newer fast draft'); + await expect(page.getByRole('status')).toHaveText('All changes saved in this demo.'); + await page.evaluate(() => (window as Window & { releaseOldDigest?: () => void }).releaseOldDigest?.()); + await expect.poll(() => page.evaluate(() => (window as Window & { oldDigestFinished?: boolean }).oldDigestFinished)).toBe(true); + expect((await savedDocuments(page)).original).toContain('Newer fast draft'); + expect((await savedDocuments(page)).originalValidator).toBe('"v2"'); + await expect(page.getByRole('textbox')).toHaveText('Newer fast draft'); + expect(errors).toEqual([]); +}); + +test('does not report an oversized unsaved draft as saved when an older request finishes', async ({ page }) => { + const errors = await openRecovery(page); + await page.getByLabel('Next save in this demo').selectOption('deferred'); + await replaceDraft(page, 'Earlier accepted draft'); + await expect(page.getByRole('status')).toHaveText('Saving changes…'); + await replaceDraft(page, 'x'.repeat(65_537)); + const failedPreparation = 'Changes could not be prepared. Your draft is still here; shorten it and try again.'; + await expect(page.getByRole('status')).toHaveText(failedPreparation); + await page.getByRole('button', { name: 'Finish pending save' }).click(); + await expect.poll(async () => (await savedDocuments(page)).original).toContain('Earlier accepted draft'); + await expect(page.getByRole('status')).toHaveText(failedPreparation); + await expect(page.getByRole('textbox')).toHaveText('x'.repeat(65_537)); + await replaceDraft(page, 'Shortened recoverable draft'); + await expect(page.getByRole('status')).toHaveText('All changes saved in this demo.'); + expect((await savedDocuments(page)).original).toContain('Shortened recoverable draft'); + expect(errors).toEqual([]); +}); + +test('opens the stored rich document before enabling edits without rewriting it', async ({ page }) => { + const errors = await openRecovery(page, '&savedDraft=1', 'Saved headingPreviously saved draft'); + await expect(page.getByRole('textbox').getByRole('heading', { name: 'Saved heading', level: 2 })).toBeVisible(); + await expect(page.getByRole('textbox').locator('strong')).toHaveText('Previously saved draft'); + await expect(page.getByRole('status')).toHaveText('All changes saved in this demo.'); + expect((await savedDocuments(page)).originalValidator).toBe('"v1"'); + expect((await savedDocuments(page)).original).toContain('\n'); + expect(errors).toEqual([]); +}); + +test('keeps an unreadable stored draft untouched and does not enable replacement editing', async ({ page }) => { + const errors = await openRecovery(page, '&savedDraft=invalid', ''); + await expect(page.getByRole('status')).toHaveText('The saved draft could not be opened. Nothing was changed.'); + await expect(page.getByRole('textbox')).toHaveAttribute('contenteditable', 'false'); + await expect(page.getByLabel('Next save in this demo')).toBeDisabled(); + expect((await savedDocuments(page)).original).toBe('Invalid stored draft'); + expect((await savedDocuments(page)).originalValidator).toBe('"v1"'); + expect(errors).toEqual([]); +}); + +test('uses the saved version only after confirmation and resumes saving against its current version', async ({ page }) => { + const errors = await openRecovery(page); + await page.getByLabel('Next save in this demo').selectOption('conflict'); + await replaceDraft(page, 'My unsaved draft'); + await expect(page.getByRole('status')).toContainText('Another version was saved'); + const savedBefore = await savedDocuments(page); + const restoreButton = page.getByRole('button', { name: 'Use saved version', exact: true }); + await expect(restoreButton).toBeEnabled(); + page.once('dialog', async (dialog) => { + expect(dialog.type()).toBe('confirm'); + expect(dialog.message()).toContain('replace your unsaved changes'); + await dialog.dismiss(); + }); + await restoreButton.click(); + await expect(page.getByRole('textbox')).toHaveText('My unsaved draft'); + expect(await savedDocuments(page)).toEqual(savedBefore); + page.once('dialog', (dialog) => dialog.accept()); + await restoreButton.focus(); + await page.keyboard.press('Enter'); + await expect(page.getByRole('status')).toHaveText('Saved version opened. You can continue editing.'); + await expect(page.getByRole('textbox')).toHaveText('Draft saved elsewhere.'); + await expect(page.getByRole('textbox')).toBeFocused(); + expect(await savedDocuments(page)).toEqual(savedBefore); + await replaceDraft(page, 'Edit after restoring saved version'); + await expect(page.getByRole('status')).toHaveText('All changes saved in this demo.'); + expect((await savedDocuments(page)).original).toContain('Edit after restoring saved version'); + expect((await savedDocuments(page)).originalValidator).toBe('"v3"'); + expect(errors).toEqual([]); +}); + +test('restores rich saved content without rewriting storage and remains usable at 320px with forced colors', async ({ page }) => { + await page.setViewportSize({ width: 320, height: 780 }); + await page.emulateMedia({ forcedColors: 'active' }); + const errors = await openRecovery(page, '&savedDraft=1', 'Saved headingPreviously saved draft'); + const savedBefore = await savedDocuments(page); + await page.getByLabel('Next save in this demo').selectOption('failure'); + await replaceDraft(page, 'Unsaved replacement'); + const restoreButton = page.getByRole('button', { name: 'Use saved version', exact: true }); + await expect(restoreButton).toBeEnabled(); + await restoreButton.focus(); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); + await page.screenshot({ path: test.info().outputPath('restore-320-forced-colors.png'), fullPage: true }); + // A reentrant click must not open a second confirmation before React renders. + await page.evaluate(() => { + const observed = window as typeof window & { restoreConfirmationCount: number }; + observed.restoreConfirmationCount = 0; + window.confirm = () => { + observed.restoreConfirmationCount += 1; + if (observed.restoreConfirmationCount === 1) { + Array.from(document.querySelectorAll('button')).find((button) => button.textContent === 'Use saved version')?.click(); + } + return true; + }; + }); + await page.keyboard.press('Enter'); + expect(await page.evaluate(() => (window as typeof window & { restoreConfirmationCount: number }).restoreConfirmationCount)).toBe(1); + await expect(page.getByRole('status')).toHaveText('Saved version opened. You can continue editing.'); + await expect(page.getByRole('textbox').getByRole('heading', { name: 'Saved heading', level: 2 })).toBeVisible(); + await expect(page.getByRole('textbox').locator('strong')).toHaveText('Previously saved draft'); + expect(await savedDocuments(page)).toEqual(savedBefore); + expect(errors).toEqual([]); +}); + +test('refuses a saved version that changes during confirmation and rereads on the next attempt', async ({ page }) => { + const errors = await openRecovery(page); + await page.getByLabel('Next save in this demo').selectOption('failure'); + await replaceDraft(page, 'Keep this local draft'); + const restoreButton = page.getByRole('button', { name: 'Use saved version', exact: true }); + await expect(restoreButton).toBeEnabled(); + await page.evaluate(() => { + const originalConfirm = window.confirm; + window.confirm = () => { + window.confirm = originalConfirm; + window.referenceHostSaveElsewhere(window.referenceHostSavedDocuments().original.replace('Draft', 'New saved content')); + return true; + }; + }); + await restoreButton.click(); + await expect(page.getByRole('status')).toHaveText('The draft or saved version changed. Nothing was replaced; try again.'); + await expect(page.getByRole('textbox')).toHaveText('Keep this local draft'); + const savedAfter = await savedDocuments(page); + expect(savedAfter.original).toContain('New saved content'); + page.once('dialog', (dialog) => dialog.accept()); + await restoreButton.click(); + await expect(page.getByRole('textbox')).toHaveText('New saved content'); + expect(await savedDocuments(page)).toEqual(savedAfter); + expect(errors).toEqual([]); +}); + +test('preserves a local edit made during confirmation', async ({ page }) => { + const errors = await openRecovery(page); + await page.getByLabel('Next save in this demo').selectOption('failure'); + await replaceDraft(page, 'Keep this local draft'); + const restoreButton = page.getByRole('button', { name: 'Use saved version', exact: true }); + await expect(restoreButton).toBeEnabled(); + const savedBefore = await savedDocuments(page); + await page.evaluate(() => { + const originalConfirm = window.confirm; + window.confirm = () => { + window.confirm = originalConfirm; + const textbox = document.querySelector('[contenteditable="true"]'); + if (!textbox) throw new Error('Editable draft is unavailable'); + textbox.focus(); + const selection = window.getSelection(); + const range = document.createRange(); + range.selectNodeContents(textbox); + selection?.removeAllRanges(); + selection?.addRange(range); + document.execCommand('insertText', false, 'New local edit during confirmation'); + return true; + }; + }); + await restoreButton.click(); + await expect(page.getByRole('status')).toHaveText('The draft or saved version changed. Nothing was replaced; try again.'); + await expect(page.getByRole('textbox')).toHaveText('New local edit during confirmation'); + expect(await savedDocuments(page)).toEqual(savedBefore); + expect(errors).toEqual([]); +}); + +for (const invalidKind of ['json', 'schema']) { + test(`preserves the draft when the saved version is rejected: ${invalidKind}`, async ({ page }) => { + const errors = await openRecovery(page); + await page.getByLabel('Next save in this demo').selectOption('failure'); + await replaceDraft(page, 'Keep my recoverable draft'); + const restoreButton = page.getByRole('button', { name: 'Use saved version', exact: true }); + await expect(restoreButton).toBeEnabled(); + const originalSaved = (await savedDocuments(page)).original; + const invalidSaved = invalidKind === 'json' ? 'Invalid saved content' : JSON.stringify({ + ...JSON.parse(originalSaved), documentJson: { type: 'doc', content: [{ type: 'unknown-node' }] }, + }); + await page.evaluate((value) => window.referenceHostSaveElsewhere(value), invalidSaved); + page.once('dialog', (dialog) => dialog.accept()); + await restoreButton.click(); + await expect(page.getByRole('status')).toHaveText('The saved version could not be opened. Your draft is still here.'); + await expect(page.getByRole('textbox')).toHaveText('Keep my recoverable draft'); + expect((await savedDocuments(page)).original).toBe(invalidSaved); + await page.evaluate((value) => window.referenceHostSaveElsewhere(value), originalSaved); + page.once('dialog', (dialog) => dialog.accept()); + await restoreButton.click(); + await expect(page.getByRole('textbox')).toHaveText('Draft'); + expect((await savedDocuments(page)).originalValidator).toBe('"v3"'); + expect(errors).toEqual([]); + }); +} + +test('restores the active separate copy without reading or overwriting the original', async ({ page }) => { + const errors = await openRecovery(page); + await page.getByLabel('Next save in this demo').selectOption('conflict'); + await replaceDraft(page, 'Saved in my copy'); + await page.getByRole('button', { name: 'Save my draft as a separate copy' }).click(); + await expect(page.getByRole('status')).toContainText('Separate copy saved'); + const savedBefore = await savedDocuments(page); + await page.getByLabel('Next save in this demo').selectOption('failure'); + await replaceDraft(page, 'Unsaved edit to my copy'); + page.once('dialog', (dialog) => dialog.accept()); + await page.getByRole('button', { name: 'Use saved version', exact: true }).click(); + await expect(page.getByRole('textbox')).toHaveText('Saved in my copy'); + expect(await savedDocuments(page)).toEqual(savedBefore); + await replaceDraft(page, 'Continue editing my restored copy'); + await expect(page.getByRole('status')).toHaveText('All changes saved in this demo.'); + expect((await savedDocuments(page)).copies).toHaveLength(1); + expect((await savedDocuments(page)).copies[0]).toContain('Continue editing my restored copy'); + expect((await savedDocuments(page)).original).toBe(savedBefore.original); + expect(errors).toEqual([]); +}); diff --git a/tests/browser/specs/reference-host.print.browser.spec.ts b/tests/browser/specs/reference-host.print.browser.spec.ts new file mode 100644 index 000000000..53902406f --- /dev/null +++ b/tests/browser/specs/reference-host.print.browser.spec.ts @@ -0,0 +1,299 @@ +import { expect, test } from '@playwright/test'; + +const REFERENCE_HOST_URL = + 'http://127.0.0.1:4173/examples/reference-host/browser-host.html'; + +function isReferenceHostRequest(requestUrl: string): boolean { + const url = new URL(requestUrl); + return url.protocol === 'http:' && url.hostname === '127.0.0.1' && url.port === '4173'; +} + +test.describe.configure({ mode: 'serial' }); + +test('hydrates the real native-form reference host without external runtime requests', async ({ + page, +}) => { + const rejectedRequests: string[] = []; + const pageErrors: string[] = []; + const consoleErrors: string[] = []; + + page.on('pageerror', (error) => { + pageErrors.push(error.message); + }); + page.on('console', (message) => { + if (message.type() === 'error') { + consoleErrors.push(message.text()); + } + }); + await page.route('**/*', async (route) => { + const requestUrl = route.request().url(); + if (isReferenceHostRequest(requestUrl)) { + await route.continue(); + return; + } + rejectedRequests.push(requestUrl); + await route.abort('blockedbyclient'); + }); + + const response = await page.goto(REFERENCE_HOST_URL); + expect(response?.ok()).toBe(true); + + await expect( + page.getByRole('heading', { name: 'Inkspan reference host' }), + ).toBeVisible(); + await expect( + page.locator('[data-inkspan-form-field][name="message_body"]'), + ).toHaveValue('# Draft'); + await expect(page.getByText('Loading buyer editor')).toHaveCount(0); + + await page.getByRole('button', { name: 'Save document' }).click(); + await expect + .poll(() => + page.evaluate(() => { + const hostWindow = window as typeof window & { + referenceHostSubmissions?: string[]; + }; + return hostWindow.referenceHostSubmissions ?? []; + }), + ) + .toEqual(['# Draft']); + + expect(rejectedRequests).toEqual([]); + expect(pageErrors).toEqual([]); + expect( + consoleErrors.filter((message) => + /hydration|did not match|server html/iu.test(message), + ), + ).toEqual([]); +}); + +test('restores the packed native-form draft without creating a host submission', async ({ + page, +}) => { + const rejectedRequests: string[] = []; + const pageErrors: string[] = []; + + page.on('pageerror', (error) => { + pageErrors.push(error.message); + }); + await page.route('**/*', async (route) => { + const requestUrl = route.request().url(); + if (isReferenceHostRequest(requestUrl)) { + await route.continue(); + return; + } + rejectedRequests.push(requestUrl); + await route.abort('blockedbyclient'); + }); + + const response = await page.goto(REFERENCE_HOST_URL); + expect(response?.ok()).toBe(true); + + const editor = page.getByRole('textbox'); + const field = page.locator( + '[data-inkspan-form-field][name="message_body"]', + ); + await expect(editor).toBeVisible(); + await expect(field).toHaveValue('# Draft'); + + await editor.selectText(); + await page.keyboard.type('Buyer changed draft'); + await expect(field).not.toHaveValue('# Draft'); + + await page.getByRole('button', { name: 'Reset draft' }).click(); + await expect(field).toHaveValue('# Draft'); + await expect(editor).toContainText('Draft'); + await expect(page.getByText('Not saved yet', { exact: true })).toBeVisible(); + await expect + .poll(() => + page.evaluate(() => { + const hostWindow = window as typeof window & { + referenceHostSubmissions?: string[]; + }; + return hostWindow.referenceHostSubmissions ?? []; + }), + ) + .toEqual([]); + + expect(rejectedRequests).toEqual([]); + expect(pageErrors).toEqual([]); +}); + +test('keeps the buyer host readable while read-only mode fail-closes native writes', async ({ + page, +}) => { + const rejectedRequests: string[] = []; + const pageErrors: string[] = []; + const consoleErrors: string[] = []; + + page.on('pageerror', (error) => { + pageErrors.push(error.message); + }); + page.on('console', (message) => { + if (message.type() === 'error') { + consoleErrors.push(message.text()); + } + }); + await page.route('**/*', async (route) => { + const requestUrl = route.request().url(); + if (isReferenceHostRequest(requestUrl)) { + await route.continue(); + return; + } + rejectedRequests.push(requestUrl); + await route.abort('blockedbyclient'); + }); + + const response = await page.goto(`${REFERENCE_HOST_URL}?readOnly=1`); + expect(response?.ok()).toBe(true); + + await expect( + page.getByRole('heading', { name: 'Inkspan reference host' }), + ).toBeVisible(); + await expect(page.getByRole('textbox')).toHaveAttribute('aria-readonly', 'true'); + await expect(page.getByRole('textbox')).toContainText('Draft'); + await expect( + page.locator('[data-inkspan-form-field][name="message_body"]'), + ).toBeDisabled(); + await expect( + page.locator('[data-inkspan-form-field][name="message_body"]'), + ).toHaveValue('# Draft'); + await expect(page.getByRole('button', { name: 'Save document' })).toBeDisabled(); + await expect(page.getByRole('button', { name: 'Reset draft' })).toBeDisabled(); + await expect(page.getByText('Loading buyer editor')).toHaveCount(0); + + await expect + .poll(() => + page.evaluate(() => { + const hostWindow = window as typeof window & { + referenceHostSubmissions?: string[]; + }; + return hostWindow.referenceHostSubmissions ?? []; + }), + ) + .toEqual([]); + + expect(rejectedRequests).toEqual([]); + expect(pageErrors).toEqual([]); + expect( + consoleErrors.filter((message) => + /hydration|did not match|server html/iu.test(message), + ), + ).toEqual([]); +}); + +test('keeps the buyer host usable without horizontal overflow at a narrow viewport', async ({ + page, +}) => { + const rejectedRequests: string[] = []; + const pageErrors: string[] = []; + + page.on('pageerror', (error) => { + pageErrors.push(error.message); + }); + await page.route('**/*', async (route) => { + const requestUrl = route.request().url(); + if (isReferenceHostRequest(requestUrl)) { + await route.continue(); + return; + } + rejectedRequests.push(requestUrl); + await route.abort('blockedbyclient'); + }); + + await page.setViewportSize({ width: 320, height: 640 }); + const response = await page.goto(REFERENCE_HOST_URL); + expect(response?.ok()).toBe(true); + + await expect( + page.getByRole('heading', { name: 'Inkspan reference host' }), + ).toBeVisible(); + await expect(page.getByRole('textbox')).toBeVisible(); + await expect(page.getByRole('button', { name: 'Save document' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Reset draft' })).toBeVisible(); + await expect(page.getByText('Loading buyer editor')).toHaveCount(0); + + const layout = await page.evaluate(() => { + const editor = document.querySelector('.cwl-editor'); + if (!editor) { + throw new Error('reference host editor is missing'); + } + const rect = editor.getBoundingClientRect(); + return { + viewportWidth: window.innerWidth, + documentScrollWidth: document.documentElement.scrollWidth, + bodyScrollWidth: document.body.scrollWidth, + editorLeft: rect.left, + editorRight: rect.right, + }; + }); + + expect(layout.viewportWidth).toBe(320); + expect(layout.documentScrollWidth).toBeLessThanOrEqual(layout.viewportWidth); + expect(layout.bodyScrollWidth).toBeLessThanOrEqual(layout.viewportWidth); + expect(layout.editorLeft).toBeGreaterThanOrEqual(0); + expect(layout.editorRight).toBeLessThanOrEqual(layout.viewportWidth); + expect(rejectedRequests).toEqual([]); + expect(pageErrors).toEqual([]); +}); + +test('applies the package print contract inside the real buyer host without runtime network', async ({ + page, +}) => { + const rejectedRequests: string[] = []; + const pageErrors: string[] = []; + + page.on('pageerror', (error) => { + pageErrors.push(error.message); + }); + await page.route('**/*', async (route) => { + const requestUrl = route.request().url(); + if (isReferenceHostRequest(requestUrl)) { + await route.continue(); + return; + } + rejectedRequests.push(requestUrl); + await route.abort('blockedbyclient'); + }); + + await page.emulateMedia({ media: 'print' }); + const response = await page.goto(REFERENCE_HOST_URL); + expect(response?.ok()).toBe(true); + + await expect( + page.getByRole('heading', { name: 'Inkspan reference host' }), + ).toBeVisible(); + await expect(page.locator('.cwl-editor__content')).toContainText('Draft'); + await expect(page.locator('.cwl-toolbar')).toBeHidden(); + + const printStyles = await page.evaluate(() => { + const editor = document.querySelector('.cwl-editor'); + const surface = document.querySelector('.cwl-editor__surface'); + const content = document.querySelector('.cwl-editor__content'); + if (!editor || !surface || !content) { + throw new Error('reference host print surface is incomplete'); + } + const editorStyle = getComputedStyle(editor); + const surfaceStyle = getComputedStyle(surface); + const contentStyle = getComputedStyle(content); + return { + printMediaMatches: matchMedia('print').matches, + editorOverflow: editorStyle.overflow, + editorBorderTopWidth: editorStyle.borderTopWidth, + surfaceOverflow: surfaceStyle.overflow, + surfaceMaxHeight: surfaceStyle.maxHeight, + contentMinHeight: contentStyle.minHeight, + contentPaddingTop: contentStyle.paddingTop, + }; + }); + + expect(printStyles.printMediaMatches).toBe(true); + expect(printStyles.editorOverflow).toBe('visible'); + expect(printStyles.editorBorderTopWidth).toBe('0px'); + expect(printStyles.surfaceOverflow).toBe('visible'); + expect(printStyles.surfaceMaxHeight).toBe('none'); + expect(printStyles.contentMinHeight).toBe('0px'); + expect(printStyles.contentPaddingTop).toBe('0px'); + expect(rejectedRequests).toEqual([]); + expect(pageErrors).toEqual([]); +}); diff --git a/tests/browser/vite.config.ts b/tests/browser/vite.config.ts index 50c49dd19..b3dddc5f1 100644 --- a/tests/browser/vite.config.ts +++ b/tests/browser/vite.config.ts @@ -1,5 +1,6 @@ import { fileURLToPath } from 'node:url'; import { dirname, resolve } from 'node:path'; +import { createRequire } from 'node:module'; import { defineConfig } from 'vite'; const browserDirectory = dirname(fileURLToPath(import.meta.url)); @@ -8,11 +9,63 @@ const configuredPackageEntry = process.env.INKSPAN_BROWSER_PACKAGE_ENTRY?.trim() const packageEntry = configuredPackageEntry ? resolve(configuredPackageEntry) : resolve(repositoryRoot, 'src/index.ts'); +const packedPackageRoot = configuredPackageEntry + ? resolve(dirname(packageEntry), '..') + : null; +const packageRequire = createRequire(packageEntry); +const alias = [ + ...(packedPackageRoot ? ['react', 'react-dom', 'yjs'].map((peerName) => ({ + find: peerName, + replacement: dirname(packageRequire.resolve(`${peerName}/package.json`)), + })) : []), + ...(packedPackageRoot + ? [ + { + find: '@contextualwisdomlab/cwl-editor/collaboration', + replacement: resolve(packedPackageRoot, 'dist/cwl-collaboration.js'), + }, + { + find: '@contextualwisdomlab/cwl-editor/autosave', + replacement: resolve(packedPackageRoot, 'dist/cwl-autosave.js'), + }, + { + find: '@contextualwisdomlab/cwl-editor/styles.css', + replacement: resolve(packedPackageRoot, 'dist/cwl-editor.css'), + }, + { + find: '@contextualwisdomlab/cwl-editor/fonts.css', + replacement: resolve(packedPackageRoot, 'src/fonts/fonts.css'), + }, + { + find: '@contextualwisdomlab/cwl-editor/fonts-latin.css', + replacement: resolve(packedPackageRoot, 'src/fonts/fonts-latin.css'), + }, + { + find: '@contextualwisdomlab/cwl-editor', + replacement: packageEntry, + }, + ] + : []), + { + find: 'inkspan-browser-under-test', + replacement: packageEntry, + }, +]; export default defineConfig({ - resolve: { - alias: { - 'inkspan-browser-under-test': packageEntry, + server: { + fs: { + strict: true, + allow: [repositoryRoot, ...(packedPackageRoot ? [packedPackageRoot] : [])], }, }, + optimizeDeps: { + entries: [ + 'tests/browser/harness.html', + 'examples/reference-host/browser-host.html', + ], + }, + resolve: { + alias, + }, });