Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
4b5ab0f
test(input): add multilingual browser assurance baseline
seonghobae Aug 20, 2026
109e5b7
test(input): cover structured multilingual edits and history
seonghobae Aug 20, 2026
7d01120
test(input): add narrow viewport multilingual assurance
seonghobae Aug 20, 2026
f144163
test(input): make structured caret placement engine-neutral
seonghobae Aug 20, 2026
e6b5c65
test(input): exercise touch-capable multilingual focus
seonghobae Aug 20, 2026
448b55c
test(input): preserve text across emulated viewport changes
seonghobae Aug 20, 2026
c56ce12
test(input): prove emulated touch pointer delivery
seonghobae Aug 21, 2026
0ec1bd0
test(input): require read-only committed-input isolation
seonghobae Aug 21, 2026
d6c2592
test(input): expose read-only harness control
seonghobae Aug 21, 2026
c9ed56c
test(input): make network-negative control non-vacuous
seonghobae Aug 21, 2026
b4d9a3e
test(input): cover read-only transition during composition
seonghobae Aug 21, 2026
9887b06
test(input): exercise read-only composition through CwlEditor
seonghobae Aug 21, 2026
f67026a
test(input): wait for editor readiness before harness calls
seonghobae Aug 21, 2026
781becd
merge: stack input assurance on editor composition fix
seonghobae Aug 21, 2026
c9e9262
test(input): expose native form serialization
seonghobae Aug 22, 2026
4d8ee51
test(input): prove native form serialization after composition
seonghobae Aug 22, 2026
c15e361
test(input): require narrow touch toolbar targets
seonghobae Aug 23, 2026
22d4df3
test(input): expose toolbar in touch harness
seonghobae Aug 23, 2026
30690cf
test(input): bind composition editability to native form state
seonghobae Aug 23, 2026
61b23cb
chore(stack): merge latest standalone-editor parent into input assurance
seonghobae Aug 24, 2026
bcd789c
test(input): require clean remount after active composition
seonghobae Aug 24, 2026
ca7450b
test(input): support composition teardown remount assurance
seonghobae Aug 24, 2026
4719f83
test(browser): include all engine browser specs
seonghobae Aug 24, 2026
e807c52
test(input): enforce loopback-only native-form assurance
seonghobae Aug 24, 2026
a75254d
chore(stack): restack browser assurance on current parent
seonghobae Aug 25, 2026
bf3be65
test(input): strengthen narrow viewport touch evidence
seonghobae Aug 25, 2026
91a834b
Merge 580ac1ad7453d5f6c443b1cea52493bef822844e into bf3be655c177dab75…
seonghobae Aug 25, 2026
4a1ffa4
test(browser): load production styles in input harness
seonghobae Aug 25, 2026
b89b38e
test(browser): assert canonical form serialization after remount
seonghobae Aug 25, 2026
c994a25
chore(stack): integrate current #201 parent
seonghobae Aug 26, 2026
64954c8
chore(stack): integrate current #201 parent
seonghobae Aug 27, 2026
fccc446
chore(stack): integrate current #201 parent
seonghobae Aug 27, 2026
a437ea7
chore(browser): sync CJK assurance with controlled editor parent
seonghobae Aug 28, 2026
d23b2c1
chore(browser): sync CJK assurance with controlled-value parent
seonghobae Aug 29, 2026
99d8dba
chore(browser): sync CJK assurance with controlled-value parent
seonghobae Aug 29, 2026
67a278e
Merge commit 'refs/codex/pr201-updated' into HEAD
seonghobae Sep 4, 2026
a5d624e
test(input): cover toolbar use during composition
seonghobae Sep 4, 2026
b2e4bb4
chore(input): synchronize controlled-value parent
seonghobae Sep 4, 2026
71cb311
test(input): preserve touch toolbar focus
seonghobae Sep 4, 2026
a90cf87
test: cover controlled value during composition
seonghobae Sep 4, 2026
aceca4b
test: bind composition saves to revisions
seonghobae Sep 4, 2026
5a1ba2e
test: verify composition autosave revision
seonghobae Sep 5, 2026
f10dd90
merge: inherit controlled editor foundation into input assurance
seonghobae Sep 5, 2026
a271dd0
merge: inherit verified editor teardown regression from owner
seonghobae Sep 6, 2026
cdfa061
test(browser): bind stylesheet checks to the selected artifact
seonghobae Sep 6, 2026
015e4fd
test(browser): use the real input textbox role
seonghobae Sep 6, 2026
8f73903
fix(browser): serve stylesheet from the selected package
seonghobae Sep 6, 2026
f440002
docs(browser): record stylesheet provenance and verification limits
seonghobae Sep 6, 2026
d0e4ad4
test(browser): reject an extra worktree stylesheet
seonghobae Sep 6, 2026
7e23132
test: select the matching collaboration browser entry
seonghobae Sep 6, 2026
e1bbcf5
test(browser): reject missing expected package evidence
seonghobae Sep 6, 2026
6a7121e
fix(browser): require archive for expected package evidence
seonghobae Sep 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/TEST_STRATEGY.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ The protected gate pins **Playwright 1.62.0** in an isolated immutable browser-t

Differences are not normalized away merely to produce parity. The default gate has no generic normalization or broad engine allowlist; a permitted difference requires a focused regression fixture, authoritative standards basis, threat analysis, exact affected engine/version evidence, canonical interpretation, compatibility impact, and rollback. Missing, skipped, cancelled, incomplete, stale-run, stale-lock, package-mismatched, or divergent required browser evidence must fail closed rather than becoming successful release evidence. A source movement after evidence generation requires the new exact release candidate to re-prove the gate.

Active PR browser assurance binds the actual editor and shared focus/print stylesheet route to the selected source build or extracted package. Archive-digest metadata alone cannot prove which CSS the browser loaded. The [stylesheet provenance regression](doctoring/browser-stylesheet-provenance.md) checks the loaded stylesheet identity and shared-route bytes in each engine; it does not establish physical-device or protected-release acceptance.

Active PR browser evidence also rejects a missing archive when an expected package digest is supplied. Ordinary source-only checks without an expected digest may retain a null package identity. This distinction is exercised against the shared evidence helper by `src/releaseBrowserEvidence.test.ts`; it does not change editor runtime or public document contracts.

### Office artifact tests

Exercise realistic DOCX, XLSX, and PPTX fixtures and inspect the generated package structure. Cover XML 1.0 validity, formula-injection neutralization, worksheet naming and freeze panes, bounds/depth/cycles, deterministic metadata, publication races, overwrite semantics, and wheel/package contents. No macro, network, model, or Desktop Office execution is required by the deterministic renderer.
Expand Down
61 changes: 61 additions & 0 deletions docs/doctoring/browser-stylesheet-provenance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# Browser stylesheet provenance

Status: Active PR research and verification repair; not protected-release evidence.

## Failure and smallest repair

Selecting an extracted editor package changed the browser harness's JavaScript
entry but left the actual input editor loading source CSS. Focus and print
fixtures requested the worktree's rebuilt stylesheet. A receipt could therefore
name an archive without proving that its styles were exercised.

At `015e4fd2ab5311c47ece1fb4a98e497354660db0`, both route checks failed in
Chromium, Firefox and WebKit: six failures, no skipped cases. Browser metadata
identified the worktree source/build paths instead of the selected archive's
stylesheet. The extracted JavaScript, autosave and CSS bytes matched the build;
path identity exposed the error even while CSS bytes happened to be equal.

The shared `/dist/cwl-editor.css` route now resolves to the selected package's
stylesheet, or to the local build in source mode. The input harness imports
that same route. Vite's existing absolute-path alias mechanism covers the
callers without a server plugin, a new helper, copied CSS or per-spec routing.
Changing only the input import would leave focus/print consumers uncorrected;
checking archive metadata alone would not observe stylesheet selection.

The downstream forced-colors inspection in #151 also needs the real collaboration
renderer, whose inline colors are missing from hand-written cursor markup.
This browser-harness owner provides a matching collaboration-entry alias and
lazy probe, following the existing source/package selection. The shared test
checks normal label colors; #151 owns the forced-palette and visual regression.
Integrate these helpers into the child by a normal merge so its net delta does
not become a competing browser-configuration writer.

## Runnable regression and limits

After building, run from the repository root:

```sh
pnpm --dir tests/browser exec playwright test --config playwright.config.ts stylesheet-identity.browser.spec.ts --workers=1
```

Repeat with `INKSPAN_BROWSER_PACKAGE_ENTRY` pointing to the selected extracted
archive's `dist/cwl-editor.js`. The existing release workflow supplies that
entry after verifying and extracting the release archive. The tests check the
actual input editor's loaded CSS identity, shared-route resolution, and served
CSS bytes. Source-mode success alone does not prove package-mode behavior.

The first fixed focused run passed five cases; Firefox's editor case exceeded
its unchanged 20-second deadline during evaluation on a host reporting about
47 GB of swap in use.
Its snapshot and failure remain retained. The unchanged case then passed alone
with trace. This does not prove the timeout's cause or erase that failed run;
final acceptance still requires fresh complete source and package runs.

No editor styles, commands, API, package exports, browser deadline, retry
allowance, dependency or host authority changed. This is a browser-evidence
repair, not a new physical-device support or performance claim.

## Reference

Vite. (n.d.). *Shared options: resolve.alias*. Retrieved September 6, 2026, from
https://vite.dev/config/shared-options.html#resolve-alias
31 changes: 29 additions & 2 deletions src/releaseBrowserEvidence.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { readFileSync } from 'node:fs';
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { tmpdir } from 'node:os';
import { resolve } from 'node:path';

import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import { packedPackageSha256 } from '../tests/browser/evidenceContract';

/** Read one authoritative repository file as UTF-8 text. */
function repositoryFile(path: string): string {
Expand All @@ -25,6 +28,30 @@ const browserSpec = repositoryFile('tests/browser/specs/clipboard.browser.spec.t
const consensusSpec = repositoryFile('tests/browser/specs/clipboard.consensus.spec.ts');

describe('release cross-engine browser evidence contract', () => {
it('requires the actual archive when an expected package digest is supplied', async () => {
const temporaryRoot = mkdtempSync(resolve(tmpdir(), 'inkspan-browser-digest-'));
const packageBytes = 'local package fixture';
const expectedDigest = createHash('sha256').update(packageBytes).digest('hex');
try {
vi.stubEnv('INKSPAN_EXPECTED_PACKAGE_SHA256', '');
await expect(packedPackageSha256(temporaryRoot)).resolves.toBeNull();
vi.stubEnv('INKSPAN_EXPECTED_PACKAGE_SHA256', expectedDigest);
await expect(packedPackageSha256(temporaryRoot)).rejects.toThrow(
'Expected packed npm artifact is missing.',
);
mkdirSync(resolve(temporaryRoot, 'release'));
writeFileSync(resolve(temporaryRoot, 'release/editor.tgz'), packageBytes);
await expect(packedPackageSha256(temporaryRoot)).resolves.toBe(expectedDigest);
vi.stubEnv('INKSPAN_EXPECTED_PACKAGE_SHA256', '0'.repeat(64));
await expect(packedPackageSha256(temporaryRoot)).rejects.toThrow(
'Packed npm artifact digest does not match propagated release evidence.',
);
} finally {
vi.unstubAllEnvs();
rmSync(temporaryRoot, { recursive: true, force: true });
}
});

it('requires the release tag commit to equal the current protected main tip', () => {
const buildJob = workflowJob(
workflow,
Expand Down
10 changes: 7 additions & 3 deletions tests/browser/evidenceContract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,21 @@ export const BROWSER_EVIDENCE_SCHEMA_VERSION = 2;
* Resolve the exact packed npm artifact digest when a release artifact is present.
*
* Ordinary pull-request browser CI has no `release/` directory and therefore
* returns `null`. The tag release lane must expose exactly one `.tgz`; its digest
* returns `null` only when no expected package digest was supplied. The tag
* release lane must expose exactly one `.tgz`; its digest
* is recomputed from bytes rather than trusting a propagated environment value.
*/
export async function packedPackageSha256(repositoryRoot: string): Promise<string | null> {
const releaseDirectory = resolve(repositoryRoot, 'release');
const propagatedDigest = process.env.INKSPAN_EXPECTED_PACKAGE_SHA256?.trim();
let entries;
try {
entries = await readdir(releaseDirectory, { withFileTypes: true });
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
if (propagatedDigest) throw new Error('Expected packed npm artifact is missing.');
return null;
}
throw error;
}

Expand All @@ -42,7 +47,6 @@ export async function packedPackageSha256(repositoryRoot: string): Promise<strin
stream.once('end', resolveStream);
});
const actualDigest = digest.digest('hex');
const propagatedDigest = process.env.INKSPAN_EXPECTED_PACKAGE_SHA256?.trim();
if (propagatedDigest && propagatedDigest !== actualDigest) {
throw new Error('Packed npm artifact digest does not match propagated release evidence.');
}
Expand Down
6 changes: 6 additions & 0 deletions tests/browser/harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ interface BrowserHostileDocumentProbeResult {

declare global {
interface Window {
renderInkspanCursorProbe(user: Record<string, unknown>): Promise<HTMLElement>;
runInkspanClipboardProbe(
request: BrowserClipboardProbeRequest,
): BrowserClipboardProbeResult;
Expand All @@ -34,6 +35,11 @@ declare global {
}
}

window.renderInkspanCursorProbe = async (user) => {
const { renderCollaborationCursor } = await import('inkspan-collaboration-under-test');
return renderCollaborationCursor(user);
};

window.runInkspanClipboardProbe = (
request: BrowserClipboardProbeRequest,
): BrowserClipboardProbeResult => {
Expand Down
14 changes: 14 additions & 0 deletions tests/browser/input-harness.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Inkspan input assurance harness</title>
</head>
<body>
<main>
<div id="editor"></div>
</main>
<script type="module" src="./input-harness.ts"></script>
</body>
</html>
138 changes: 138 additions & 0 deletions tests/browser/input-harness.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import type { Editor } from '@tiptap/core';
import { createElement, createRef } from 'react';
import { createRoot } from 'react-dom/client';
import {
createDocumentEnvelope,
createDocumentEnvelopeRevisionEvidence,
CwlEditor,
type CwlEditorHandle,
} from 'inkspan-browser-under-test';
import { createDocumentAutosaveQueue } from 'inkspan-autosave-under-test';
import '/dist/cwl-editor.css';

declare global {
interface Window {
inkspanInputHarness: {
getHtml: () => string;
getDocumentChanges: () => string[];
getDocumentChangeRevisionTags: () => Promise<string[]>;
getAutosaveRevisionTags: () => Promise<string[]>;
getRevisionTag: () => Promise<string>;
getText: () => string;
isComposing: () => boolean;
insertText: (text: string) => boolean;
redo: () => boolean;
remount: () => boolean;
setEditable: (editable: boolean) => boolean;
setControlledHtml: (html: string) => boolean;
setHtml: (html: string) => boolean;
undo: () => boolean;
};
}
}

const element = document.getElementById('editor');
if (!(element instanceof HTMLElement)) {
throw new Error('Input assurance harness editor host is missing.');
}

const searchParams = new URLSearchParams(window.location.search);
const showToolbar = searchParams.get('toolbar') === '1';
const controlled = searchParams.get('controlled') === '1';
let editor: Editor | null = null;
let editable = true;
let controlledHtml = '';
const documentChanges: string[] = [];
const documentChangeRevisionTags: Promise<string>[] = [];
const autosaveRevisionTags: string[] = [];
const autosaveQueue = searchParams.get('autosave') === '1'
? createDocumentAutosaveQueue({
save: (evidence) => {
autosaveRevisionTags.push(evidence.revision.strongEntityTag);
return { status: 'saved' };
},
})
: null;
const editorHandle = createRef<CwlEditorHandle>();
let root = createRoot(element);

const renderEditor = () => {
root.render(
createElement(CwlEditor, {
ref: editorHandle,
mode: 'html',
...(controlled ? { value: controlledHtml } : { defaultValue: '' }),
editable,
hideToolbar: !showToolbar,
formFieldName: 'message_body',
onChange: controlled
? (value: string) => {
controlledHtml = value;
}
: undefined,
onDocumentChange: ({ snapshot }) => {
documentChanges.push(snapshot.value);
documentChangeRevisionTags.push(
createDocumentEnvelopeRevisionEvidence(
createDocumentEnvelope(snapshot.documentJson),
).then(async (evidence) => {
await autosaveQueue?.enqueue(evidence);
return evidence.revision.strongEntityTag;
}),
);
},
onReady: (instance: Editor) => {
editor = instance;
},
}),
);
};

const getEditor = (): Editor => {
if (!editor) {
throw new Error('Input assurance harness editor is not ready.');
}
return editor;
};

renderEditor();

window.inkspanInputHarness = Object.freeze({
getHtml: () => getEditor().getHTML(),
getDocumentChanges: () => [...documentChanges],
getDocumentChangeRevisionTags: () =>
Promise.all(documentChangeRevisionTags),
getAutosaveRevisionTags: async () => {
await Promise.all(documentChangeRevisionTags);
await autosaveQueue?.flush();
return [...autosaveRevisionTags];
},
getRevisionTag: async () =>
(await editorHandle.current?.getDocumentEnvelopeRevision())
?.strongEntityTag ?? '',
getText: () => getEditor().getText(),
isComposing: () => getEditor().view.composing,
insertText: (text: string) => getEditor().commands.insertContent(text),
redo: () => getEditor().commands.redo(),
remount: () => {
root.unmount();
editor = null;
root = createRoot(element);
renderEditor();
return true;
},
setEditable: (nextEditable: boolean) => {
editable = nextEditable;
renderEditor();
return editable;
},
setControlledHtml: (html: string) => {
if (!controlled) return false;
controlledHtml = html;
renderEditor();
return true;
},
setHtml: (html: string) =>
getEditor().commands.setContent(html, { emitUpdate: false }),
undo: () => getEditor().commands.undo(),
});
2 changes: 1 addition & 1 deletion tests/browser/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading