From b5126b67c3a88211f33058535ee7e073f4dd7cad Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 22:55:58 +0900 Subject: [PATCH 01/54] test(diagnostics): require no-fallback and hostile-input assurance --- .../writingDiagnosticsSecurity.test.tsx | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 src/components/writingDiagnosticsSecurity.test.tsx diff --git a/src/components/writingDiagnosticsSecurity.test.tsx b/src/components/writingDiagnosticsSecurity.test.tsx new file mode 100644 index 00000000..85c39f04 --- /dev/null +++ b/src/components/writingDiagnosticsSecurity.test.tsx @@ -0,0 +1,128 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { createRef } from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { CwlEditorHandle } from '../types.js'; +import type { CwlWritingDiagnostic } from '../writingDiagnostics.js'; +import { CwlEditor } from './CwlEditor.js'; + +const sourceDocuments = [ + '

The report quotes “this is rude and urgent” without endorsing it.

', + '

This request has the same pragmatic issue without sharing any lexical marker.

', + '

Product incorrect lives at https://example.test/urgent/path.

', + '

이 문장은 무례함이라는 단어를 인용하지만 직접 비난하지 않습니다.

', + '

English와 한국어가 섞인 문장과 中文内容입니다.

', +] as const; + +afterEach(() => { + vi.restoreAllMocks(); + cleanup(); +}); + +describe('writing diagnostics security and semantic-authority boundary', () => { + it.each(sourceDocuments)( + 'produces no diagnostic surface without host diagnostics: %s', + async (source) => { + const editorRef = createRef(); + render(); + await waitFor(() => expect(editorRef.current?.getEditor()).toBeTruthy()); + + expect(screen.queryByRole('region', { name: 'Writing guidance' })).toBeNull(); + expect(document.querySelector('.cwl-writing-diagnostic')).toBeNull(); + expect(editorRef.current!.getHTML()).toContain(source.replace(/^

|<\/p>$/gu, '').split('<')[0]); + }, + ); + + it('rejects hostile diagnostic accessors and proxies without reflecting authored data', async () => { + const editorRef = createRef(); + const onError = vi.fn(); + const accessor = Object.create(null) as Record; + Object.defineProperty(accessor, 'diagnosticId', { + enumerable: true, + get() { + throw new Error('SECRET_AUTHORED_TEXT'); + }, + }); + const proxy = new Proxy(Object.create(null), { + ownKeys() { + throw new Error('SECRET_PROXY_TEXT'); + }, + }); + + const mounted = render( + , + ); + await waitFor(() => expect(onError).toHaveBeenCalledTimes(1)); + expect(String(onError.mock.calls[0]?.[0])).not.toContain('SECRET_AUTHORED_TEXT'); + expect(editorRef.current!.getHTML()).toContain('Alpha beta gamma'); + + onError.mockClear(); + mounted.rerender( + , + ); + await waitFor(() => expect(onError).toHaveBeenCalledTimes(1)); + expect(String(onError.mock.calls[0]?.[0])).not.toContain('SECRET_PROXY_TEXT'); + expect(screen.queryByText(/SECRET_/u)).toBeNull(); + }); + + it('contains host callback exceptions after a successful plain-text application', async () => { + const editorRef = createRef(); + const onAction = vi.fn(() => { + throw new Error('HOST_CALLBACK_SECRET'); + }); + const mounted = render( + , + ); + await waitFor(() => expect(editorRef.current?.getEditor()).toBeTruthy()); + const revision = await editorRef.current!.getDocumentEnvelopeRevision(); + expect(revision).not.toBeNull(); + + const diagnostic: CwlWritingDiagnostic = { + diagnosticId: 'callback-diagnostic', + documentRevision: revision!, + textProjection: { id: 'inkspan-prosemirror-text', version: 1 }, + selector: { type: 'TextPositionSelector', start: 0, end: 5 }, + categoryCode: 'clarity', + priority: 'important', + title: '\u202E', + explanation: ' explanation', + suggestedReplacement: '', + provenance: { + workflowId: 'email-writing-review', + workflowVersion: '1', + judgePolicyVersion: 'evaluation-only-1', + }, + }; + + mounted.rerender( + , + ); + const apply = await screen.findByRole('button', { + name: /Apply suggestion for/u, + }); + fireEvent.click(apply); + await waitFor(() => + expect(editorRef.current!.getHTML()).toContain('<script>alert(1)</script>'), + ); + expect(onAction).toHaveBeenCalledTimes(1); + expect(document.querySelector('script[src="x"]')).toBeNull(); + expect(screen.queryByText('HOST_CALLBACK_SECRET')).toBeNull(); + }); +}); From 6f550d390e42a2295c8ddfa4e842a2e2a5ede52d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 22:56:42 +0900 Subject: [PATCH 02/54] test(diagnostics): require cross-engine browser assurance --- .../specs/writing-diagnostics.browser.spec.ts | 151 ++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 tests/browser/specs/writing-diagnostics.browser.spec.ts diff --git a/tests/browser/specs/writing-diagnostics.browser.spec.ts b/tests/browser/specs/writing-diagnostics.browser.spec.ts new file mode 100644 index 00000000..1cebedac --- /dev/null +++ b/tests/browser/specs/writing-diagnostics.browser.spec.ts @@ -0,0 +1,151 @@ +import { expect, test } from '@playwright/test'; + +interface DiagnosticsProbeOptions { + readonly sourceHtml: string; + readonly withDiagnostics?: boolean; + readonly diagnosticCount?: number; +} + +async function mountProbe( + page: Parameters[0] extends never ? never : any, + options: DiagnosticsProbeOptions, +): Promise { + await page.goto('/tests/browser/harness.html'); + await page.evaluate(async (input: DiagnosticsProbeOptions) => { + await (window as any).mountInkspanWritingDiagnosticsProbe(input); + }, options); +} + +test.describe('writing diagnostics browser assurance', () => { + test('renders, navigates, focuses, applies, invalidates, and undoes exact revision guidance', async ({ + page, + }, testInfo) => { + await mountProbe(page, { + sourceHtml: '

Alpha beta gamma

', + withDiagnostics: true, + diagnosticCount: 2, + }); + + const region = page.getByRole('region', { name: 'Writing guidance' }); + await expect(region).toContainText('2 writing diagnostics'); + await expect(page.locator('.cwl-writing-diagnostic')).toHaveCount(2); + + const cards = region.getByRole('listitem'); + await expect(cards).toHaveCount(2); + await region.getByRole('button', { name: 'Next writing diagnostic' }).click(); + await expect(cards.nth(1)).toBeFocused(); + + await region + .getByRole('button', { name: /Focus affected text for Clarify Alpha/u }) + .click(); + await expect(page.locator('.ProseMirror')).toBeFocused(); + expect( + await page.evaluate(() => globalThis.getSelection()?.toString() ?? ''), + ).toContain('Alpha'); + + const apply = region.getByRole('button', { + name: /Apply suggestion for Clarify Alpha/u, + }); + await expect(apply).toBeEnabled(); + const actionBox = await apply.boundingBox(); + expect(actionBox).not.toBeNull(); + if (testInfo.project.name.includes('mobile')) { + expect(actionBox!.width).toBeGreaterThanOrEqual(44); + expect(actionBox!.height).toBeGreaterThanOrEqual(44); + } + await apply.click(); + + await expect(page.locator('.ProseMirror')).toContainText('Omega beta gamma'); + await expect(region).toContainText('0 writing diagnostics'); + await expect(page.locator('.cwl-writing-diagnostic')).toHaveCount(0); + + await page.evaluate(() => (window as any).undoInkspanWritingDiagnosticsProbe()); + await expect(page.locator('.ProseMirror')).toContainText('Alpha beta gamma'); + + const actions = await page.evaluate( + () => (window as any).getInkspanWritingDiagnosticsProbeState().actions, + ); + expect(actions).toHaveLength(1); + expect(actions[0]).toMatchObject({ + action: 'applied', + reasonCode: 'explicit', + diagnosticId: 'browser-diagnostic-alpha', + }); + }); + + test('invalidates current guidance after an unrelated document change', async ({ + page, + }) => { + await mountProbe(page, { + sourceHtml: '

Alpha beta gamma

', + withDiagnostics: true, + }); + const region = page.getByRole('region', { name: 'Writing guidance' }); + await expect(region).toContainText('1 writing diagnostics'); + + await page.evaluate(() => + (window as any).mutateInkspanWritingDiagnosticsProbe( + '

Alpha beta gamma remote-like edit

', + ), + ); + await expect(region).toContainText('0 writing diagnostics'); + await expect(page.locator('.cwl-writing-diagnostic')).toHaveCount(0); + const result = await page.evaluate(() => + (window as any).applyInkspanWritingDiagnosticProbe( + 'browser-diagnostic-alpha', + ), + ); + expect(result).toBeNull(); + }); + + test('remains usable under forced colors and 200 percent visual scale', async ({ + page, + }) => { + await mountProbe(page, { + sourceHtml: '

Alpha beta gamma

', + withDiagnostics: true, + }); + await page.emulateMedia({ forcedColors: 'active', reducedMotion: 'reduce' }); + await page.evaluate(() => { + document.documentElement.style.fontSize = '200%'; + }); + + const region = page.getByRole('region', { name: 'Writing guidance' }); + await expect(region).toBeVisible(); + const apply = region.getByRole('button', { name: /Apply suggestion/u }); + await expect(apply).toBeVisible(); + const decoration = page.locator('.cwl-writing-diagnostic').first(); + await expect(decoration).toBeVisible(); + const decorationStyle = await decoration.evaluate((element) => { + const style = getComputedStyle(element); + return { + textDecorationLine: style.textDecorationLine, + outlineStyle: style.outlineStyle, + }; + }); + expect( + decorationStyle.textDecorationLine === 'underline' || + decorationStyle.outlineStyle !== 'none', + ).toBe(true); + }); + + test('has no semantic fallback surface for lexical contrast documents', async ({ + page, + }) => { + const sources = [ + '

The quotation says “rude urgent incorrect” but makes no direct accusation.

', + '

A semantically similar concern expressed with completely unrelated wording.

', + '

Product incorrect at https://example.test/urgent/path and code rude_token.

', + '

이 문장은 무례함과 긴급이라는 단어를 인용합니다.

', + '

English 한국어 中文 mixed-language prose.

', + ]; + + for (const sourceHtml of sources) { + await mountProbe(page, { sourceHtml, withDiagnostics: false }); + await expect( + page.getByRole('region', { name: 'Writing guidance' }), + ).toHaveCount(0); + await expect(page.locator('.cwl-writing-diagnostic')).toHaveCount(0); + } + }); +}); From 1cd03c526f4ec06093f7cabd85887c1c12b68ae1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 22:57:24 +0900 Subject: [PATCH 03/54] test(diagnostics): include cross-engine and mobile browser projects --- tests/browser/playwright.config.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/browser/playwright.config.ts b/tests/browser/playwright.config.ts index 375056da..869ae2e2 100644 --- a/tests/browser/playwright.config.ts +++ b/tests/browser/playwright.config.ts @@ -2,7 +2,8 @@ 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|print)\.browser\.spec\.ts/u; +const ENGINE_BROWSER_SPECS = + /(?:clipboard|print|writing-diagnostics)\.browser\.spec\.ts/u; export default defineConfig({ testDir: './specs', @@ -38,6 +39,11 @@ export default defineConfig({ testMatch: ENGINE_BROWSER_SPECS, use: { ...devices['Desktop Safari'], browserName: 'webkit' }, }, + { + name: 'chromium-mobile-diagnostics', + testMatch: /writing-diagnostics\.browser\.spec\.ts/u, + use: { ...devices['Pixel 7'], browserName: 'chromium' }, + }, { name: 'consensus', testMatch: /clipboard\.consensus\.spec\.ts/u, From 23216d85ba33b2bb968daee2e1b352dd03e72e1d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 22:58:20 +0900 Subject: [PATCH 04/54] test(diagnostics): type browser assurance harness --- tests/browser/specs/writing-diagnostics.browser.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/browser/specs/writing-diagnostics.browser.spec.ts b/tests/browser/specs/writing-diagnostics.browser.spec.ts index 1cebedac..5093935e 100644 --- a/tests/browser/specs/writing-diagnostics.browser.spec.ts +++ b/tests/browser/specs/writing-diagnostics.browser.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from '@playwright/test'; +import { expect, test, type Page } from '@playwright/test'; interface DiagnosticsProbeOptions { readonly sourceHtml: string; @@ -7,7 +7,7 @@ interface DiagnosticsProbeOptions { } async function mountProbe( - page: Parameters[0] extends never ? never : any, + page: Page, options: DiagnosticsProbeOptions, ): Promise { await page.goto('/tests/browser/harness.html'); From 8817f8b6bf715cc22c04beef4feffe7e8254b94a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 22:59:29 +0900 Subject: [PATCH 05/54] ci(diagnostics): add browser assurance TDD lane --- .../writing-diagnostics-assurance-tdd.yml | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 .github/workflows/writing-diagnostics-assurance-tdd.yml diff --git a/.github/workflows/writing-diagnostics-assurance-tdd.yml b/.github/workflows/writing-diagnostics-assurance-tdd.yml new file mode 100644 index 00000000..a64452ed --- /dev/null +++ b/.github/workflows/writing-diagnostics-assurance-tdd.yml @@ -0,0 +1,65 @@ +name: Writing Diagnostics Assurance TDD + +on: + push: + branches: + - feat/writing-diagnostics-assurance + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: writing-diagnostics-assurance-tdd-${{ github.ref }} + cancel-in-progress: true + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + unit-assurance: + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + persist-credentials: false + - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - name: Run hostile-input and no-fallback assurance + run: pnpm exec vitest run src/components/writingDiagnosticsSecurity.test.tsx --pool=forks --maxWorkers=1 + - name: Typecheck assurance changes + run: pnpm typecheck + + browser-assurance: + name: Writing diagnostics / Playwright 1.62.0 + runs-on: ubuntu-24.04 + timeout-minutes: 30 + env: + PLAYWRIGHT_BROWSERS_PATH: /tmp/inkspan-playwright-browsers + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + persist-credentials: false + - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm --dir tests/browser install --frozen-lockfile + - name: Install Playwright revisions pinned by the browser-test lock + run: pnpm --dir tests/browser exec playwright install --with-deps chromium firefox webkit + - name: Run writing-diagnostic browser assurance on exact head + env: + INKSPAN_EXPECTED_HEAD_SHA: ${{ github.sha }} + run: >- + pnpm --dir tests/browser exec playwright test + specs/writing-diagnostics.browser.spec.ts + --config playwright.config.ts From 38103c8bc4d525529a51620df92318188f07b9c2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 23:10:26 +0900 Subject: [PATCH 06/54] test(diagnostics): add public browser diagnostics harness --- tests/browser/harness.ts | 160 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 159 insertions(+), 1 deletion(-) diff --git a/tests/browser/harness.ts b/tests/browser/harness.ts index c00b47a7..c3b38895 100644 --- a/tests/browser/harness.ts +++ b/tests/browser/harness.ts @@ -1,11 +1,19 @@ import { Editor } from '@tiptap/core'; +import { createElement, createRef } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; import { ClipboardSanitizationError, + CwlEditor, buildExtensions, sanitizeRichClipboardHtml, type ClipboardConfig, type ClipboardSanitizationErrorCode, + type CwlEditorDocumentRevision, + type CwlEditorHandle, + type CwlWritingDiagnostic, + type CwlWritingDiagnosticActionEvent, } from 'inkspan-browser-under-test'; +import '../../src/styles.css'; interface BrowserClipboardProbeRequest { readonly sourceHtml: string; @@ -23,6 +31,128 @@ interface BrowserHostileDocumentProbeResult { readonly message: string; } +interface BrowserWritingDiagnosticsProbeRequest { + readonly sourceHtml: string; + readonly withDiagnostics?: boolean; + readonly diagnosticCount?: number; +} + +interface BrowserWritingDiagnosticsProbeState { + readonly actions: readonly CwlWritingDiagnosticActionEvent[]; +} + +let diagnosticsRoot: Root | null = null; +let diagnosticsEditorRef = createRef(); +let diagnosticsActions: CwlWritingDiagnosticActionEvent[] = []; +let diagnosticsSourceHtml = ''; +let diagnosticsPayload: readonly CwlWritingDiagnostic[] | undefined; + +function nextFrame(): Promise { + return new Promise((resolve) => requestAnimationFrame(() => resolve())); +} + +async function waitForEditorHandle(): Promise { + for (let attempt = 0; attempt < 120; attempt += 1) { + const handle = diagnosticsEditorRef.current; + if (handle?.getEditor() !== null && handle?.getEditor() !== undefined) { + return handle; + } + await nextFrame(); + } + throw new Error('writing_diagnostics_editor_unavailable'); +} + +function probeContainer(): HTMLElement { + const harness = document.getElementById('harness'); + if (harness === null) throw new Error('browser_harness_missing'); + harness.replaceChildren(); + const container = document.createElement('section'); + container.id = 'writing-diagnostics-probe'; + container.setAttribute('aria-label', 'Writing diagnostics browser probe'); + harness.append(container); + return container; +} + +function diagnosticFor( + revision: CwlEditorDocumentRevision, + index: number, +): CwlWritingDiagnostic { + const alpha = index === 0; + return { + diagnosticId: alpha + ? 'browser-diagnostic-alpha' + : `browser-diagnostic-${index + 1}`, + documentRevision: revision, + textProjection: { + id: 'inkspan-prosemirror-text', + version: 1, + }, + selector: alpha + ? { type: 'TextPositionSelector', start: 0, end: 5 } + : { type: 'TextPositionSelector', start: 6, end: 10 }, + categoryCode: alpha ? 'clarity' : 'structure', + priority: alpha ? 'important' : 'advisory', + title: alpha ? 'Clarify Alpha' : `Review diagnostic ${index + 1}`, + explanation: alpha + ? 'State the intended action explicitly.' + : 'Review the neighboring phrase.', + ...(alpha ? { suggestedReplacement: 'Omega' } : {}), + provenance: { + workflowId: 'email-writing-review', + workflowVersion: '1', + judgePolicyVersion: 'evaluation-only-1', + }, + }; +} + +function renderDiagnosticsProbe(): void { + if (diagnosticsRoot === null) throw new Error('writing_diagnostics_root_missing'); + diagnosticsRoot.render( + createElement(CwlEditor, { + ref: diagnosticsEditorRef, + mode: 'html', + defaultValue: diagnosticsSourceHtml, + writingDiagnostics: diagnosticsPayload, + writingDiagnosticsLabel: 'Writing guidance', + onWritingDiagnosticAction: (event) => { + diagnosticsActions.push(event); + }, + }), + ); +} + +async function mountWritingDiagnosticsProbe( + request: BrowserWritingDiagnosticsProbeRequest, +): Promise { + diagnosticsRoot?.unmount(); + diagnosticsRoot = null; + diagnosticsEditorRef = createRef(); + diagnosticsActions = []; + diagnosticsSourceHtml = request.sourceHtml; + diagnosticsPayload = undefined; + diagnosticsRoot = createRoot(probeContainer()); + renderDiagnosticsProbe(); + + const handle = await waitForEditorHandle(); + if (request.withDiagnostics !== true) return; + const revision = await handle.getDocumentEnvelopeRevision(); + if (revision === null) throw new Error('writing_diagnostics_revision_unavailable'); + const diagnosticCount = Math.max(1, Math.min(request.diagnosticCount ?? 1, 2)); + diagnosticsPayload = Object.freeze( + Array.from({ length: diagnosticCount }, (_, index) => + Object.freeze(diagnosticFor(revision, index)), + ), + ); + renderDiagnosticsProbe(); + + for (let attempt = 0; attempt < 120; attempt += 1) { + const region = document.querySelector('[aria-label="Writing guidance"]'); + if (region?.textContent?.includes(`${diagnosticCount} writing diagnostics`)) return; + await nextFrame(); + } + throw new Error('writing_diagnostics_verification_timeout'); +} + declare global { interface Window { runInkspanClipboardProbe( @@ -31,6 +161,15 @@ declare global { runInkspanHostileDocumentProbe( sourceHtml: string, ): BrowserHostileDocumentProbeResult; + mountInkspanWritingDiagnosticsProbe( + request: BrowserWritingDiagnosticsProbeRequest, + ): Promise; + mutateInkspanWritingDiagnosticsProbe(sourceHtml: string): void; + applyInkspanWritingDiagnosticProbe( + diagnosticId: string, + ): Promise; + undoInkspanWritingDiagnosticsProbe(): boolean; + getInkspanWritingDiagnosticsProbeState(): BrowserWritingDiagnosticsProbeState; } } @@ -86,4 +225,23 @@ window.runInkspanHostileDocumentProbe = ( } }; -export {}; +window.mountInkspanWritingDiagnosticsProbe = mountWritingDiagnosticsProbe; + +window.mutateInkspanWritingDiagnosticsProbe = (sourceHtml: string): void => { + diagnosticsEditorRef.current?.setValue(sourceHtml); +}; + +window.applyInkspanWritingDiagnosticProbe = ( + diagnosticId: string, +): Promise => + diagnosticsEditorRef.current?.applyWritingDiagnostic(diagnosticId) ?? + Promise.resolve(null); + +window.undoInkspanWritingDiagnosticsProbe = (): boolean => + diagnosticsEditorRef.current?.getEditor()?.commands.undo() ?? false; + +window.getInkspanWritingDiagnosticsProbeState = + (): BrowserWritingDiagnosticsProbeState => + Object.freeze({ actions: Object.freeze([...diagnosticsActions]) }); + +export {}; \ No newline at end of file From 7dafa56c1eac59be05fec830435d403b705d2532 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 23:11:11 +0900 Subject: [PATCH 07/54] test(diagnostics): tolerate repeated redacted contract errors --- .../writingDiagnosticsSecurity.test.tsx | 28 ++++++++++++++----- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/src/components/writingDiagnosticsSecurity.test.tsx b/src/components/writingDiagnosticsSecurity.test.tsx index 85c39f04..1ff838f7 100644 --- a/src/components/writingDiagnosticsSecurity.test.tsx +++ b/src/components/writingDiagnosticsSecurity.test.tsx @@ -18,6 +18,16 @@ afterEach(() => { cleanup(); }); +function expectRedactedErrors( + onError: ReturnType, + forbidden: string, +): void { + expect(onError.mock.calls.length).toBeGreaterThanOrEqual(1); + for (const call of onError.mock.calls) { + expect(String(call[0])).not.toContain(forbidden); + } +} + describe('writing diagnostics security and semantic-authority boundary', () => { it.each(sourceDocuments)( 'produces no diagnostic surface without host diagnostics: %s', @@ -28,7 +38,9 @@ describe('writing diagnostics security and semantic-authority boundary', () => { expect(screen.queryByRole('region', { name: 'Writing guidance' })).toBeNull(); expect(document.querySelector('.cwl-writing-diagnostic')).toBeNull(); - expect(editorRef.current!.getHTML()).toContain(source.replace(/^

|<\/p>$/gu, '').split('<')[0]); + expect(editorRef.current!.getHTML()).toContain( + source.replace(/^

|<\/p>$/gu, '').split('<')[0], + ); }, ); @@ -57,8 +69,8 @@ describe('writing diagnostics security and semantic-authority boundary', () => { onWritingDiagnosticsError={onError} />, ); - await waitFor(() => expect(onError).toHaveBeenCalledTimes(1)); - expect(String(onError.mock.calls[0]?.[0])).not.toContain('SECRET_AUTHORED_TEXT'); + await waitFor(() => expect(onError.mock.calls.length).toBeGreaterThanOrEqual(1)); + expectRedactedErrors(onError, 'SECRET_AUTHORED_TEXT'); expect(editorRef.current!.getHTML()).toContain('Alpha beta gamma'); onError.mockClear(); @@ -71,8 +83,8 @@ describe('writing diagnostics security and semantic-authority boundary', () => { onWritingDiagnosticsError={onError} />, ); - await waitFor(() => expect(onError).toHaveBeenCalledTimes(1)); - expect(String(onError.mock.calls[0]?.[0])).not.toContain('SECRET_PROXY_TEXT'); + await waitFor(() => expect(onError.mock.calls.length).toBeGreaterThanOrEqual(1)); + expectRedactedErrors(onError, 'SECRET_PROXY_TEXT'); expect(screen.queryByText(/SECRET_/u)).toBeNull(); }); @@ -119,10 +131,12 @@ describe('writing diagnostics security and semantic-authority boundary', () => { }); fireEvent.click(apply); await waitFor(() => - expect(editorRef.current!.getHTML()).toContain('<script>alert(1)</script>'), + expect(editorRef.current!.getHTML()).toContain( + '<script>alert(1)</script>', + ), ); expect(onAction).toHaveBeenCalledTimes(1); expect(document.querySelector('script[src="x"]')).toBeNull(); expect(screen.queryByText('HOST_CALLBACK_SECRET')).toBeNull(); }); -}); +}); \ No newline at end of file From 519cb581cc4a21c60b003fd8c48292785ae712fb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 23:39:37 +0900 Subject: [PATCH 08/54] fix(diagnostics): focus verified affected text --- .../writing-diagnostics-assurance-tdd.yml | 9 +- src/components/WritingDiagnosticsPanel.tsx | 24 +++- .../writingDiagnosticsFocus.test.tsx | 120 ++++++++++++++++++ .../writingDiagnosticsSecurity.test.tsx | 14 +- 4 files changed, 154 insertions(+), 13 deletions(-) create mode 100644 src/components/writingDiagnosticsFocus.test.tsx diff --git a/.github/workflows/writing-diagnostics-assurance-tdd.yml b/.github/workflows/writing-diagnostics-assurance-tdd.yml index a64452ed..20276466 100644 --- a/.github/workflows/writing-diagnostics-assurance-tdd.yml +++ b/.github/workflows/writing-diagnostics-assurance-tdd.yml @@ -31,8 +31,13 @@ jobs: node-version: 22 cache: pnpm - run: pnpm install --frozen-lockfile - - name: Run hostile-input and no-fallback assurance - run: pnpm exec vitest run src/components/writingDiagnosticsSecurity.test.tsx --pool=forks --maxWorkers=1 + - name: Run hostile-input, no-fallback, and focus assurance + run: >- + pnpm exec vitest run + src/components/writingDiagnosticsSecurity.test.tsx + src/components/writingDiagnosticsFocus.test.tsx + --pool=forks + --maxWorkers=1 - name: Typecheck assurance changes run: pnpm typecheck diff --git a/src/components/WritingDiagnosticsPanel.tsx b/src/components/WritingDiagnosticsPanel.tsx index 6b6fa23c..c7ad384d 100644 --- a/src/components/WritingDiagnosticsPanel.tsx +++ b/src/components/WritingDiagnosticsPanel.tsx @@ -3,7 +3,10 @@ import { useState, type KeyboardEvent as ReactKeyboardEvent, } from 'react'; -import type { WritingDiagnosticsController } from './useWritingDiagnosticsController.js'; +import type { + CwlVerifiedWritingDiagnostic, + WritingDiagnosticsController, +} from './useWritingDiagnosticsController.js'; /** Props for Inkspan's provider-neutral writing-guidance presentation surface. */ export interface WritingDiagnosticsPanelProps { @@ -55,6 +58,20 @@ export function WritingDiagnosticsPanel({ itemRefs.current[targetIndex]!.focus(); }; + const focusAffectedText = ( + verified: CwlVerifiedWritingDiagnostic, + ): void => { + const diagnosticId = verified.diagnostic.diagnosticId; + setActiveDiagnosticId(diagnosticId); + const editor = controller.editor; + if (!controller.focusDiagnostic(diagnosticId) || editor === null) return; + editor + .chain() + .setTextSelection({ from: verified.from, to: verified.to }) + .focus() + .run(); + }; + const navigate = (offset: number): void => { focusIndex(activeIndex + offset); }; @@ -147,10 +164,7 @@ export function WritingDiagnosticsPanel({