diff --git a/.github/workflows/writing-diagnostics-editor-actions-tdd.yml b/.github/workflows/writing-diagnostics-editor-actions-tdd.yml new file mode 100644 index 00000000..c2c994e5 --- /dev/null +++ b/.github/workflows/writing-diagnostics-editor-actions-tdd.yml @@ -0,0 +1,77 @@ +name: Writing Diagnostics Editor Actions TDD + +on: + push: + branches: + - feat/writing-diagnostics-editor-actions + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: writing-diagnostics-editor-actions-tdd-${{ github.ref }} + cancel-in-progress: true + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + focused-editor-actions: + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + persist-credentials: false + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - name: Run standalone writing-diagnostic integration tests + run: | + set -euo pipefail + output_file="$(mktemp)" + trap 'rm -f "$output_file"' EXIT + set +e + pnpm exec vitest run \ + src/components/CwlEditor.writingDiagnostics.test.tsx \ + src/components/CwlEditor.writingDiagnosticApply.test.tsx \ + src/components/useWritingDiagnosticsController.apply.test.tsx \ + src/components/useEditorHandle.test.tsx \ + --pool=forks \ + --maxWorkers=1 2>&1 | tee "$output_file" + test_status=${PIPESTATUS[0]} + set -e + if grep -Fq 'not wrapped in act' "$output_file"; then + echo "::error::Focused editor actions emitted a React act warning." + exit 1 + fi + exit "$test_status" + - name: Typecheck standalone writing-diagnostic contracts + run: pnpm typecheck + - name: Run complete production coverage gate + env: + NODE_OPTIONS: --max-old-space-size=6144 + run: | + set -euo pipefail + output_file="$(mktemp)" + trap 'rm -f "$output_file"' EXIT + set +e + pnpm coverage 2>&1 | tee "$output_file" + test_status=${PIPESTATUS[0]} + set -e + if grep -Fq 'not wrapped in act' "$output_file"; then + echo "::error::Production coverage emitted a React act warning." + exit 1 + fi + exit "$test_status" + - name: Build all package entrypoints + run: pnpm build + - name: Verify isolated packed-package consumers + run: pnpm verify:package + - name: Build demonstration application + run: pnpm build:demo diff --git a/src/components/CwlEditor.tsx b/src/components/CwlEditor.tsx index 598ac948..1ed722bf 100644 --- a/src/components/CwlEditor.tsx +++ b/src/components/CwlEditor.tsx @@ -19,6 +19,8 @@ import { applyEditorFormReset } from './editorFormReset.js'; import { editorHtmlToValue, editorValueToHtml } from './editorSerialization.js'; import { useEditorHandle } from './useEditorHandle.js'; import { useLatestRef } from './useLatestRef.js'; +import { useWritingDiagnosticsController } from './useWritingDiagnosticsController.js'; +import { WritingDiagnosticsPanel } from './WritingDiagnosticsPanel.js'; /** * CwlEditor — a commercial-grade rich-text editor with interchangeable @@ -62,6 +64,11 @@ export const CwlEditor = forwardRef( ariaErrorMessage, ariaInvalid, ariaRequired, + writingDiagnostics, + onWritingDiagnosticAction, + onWritingDiagnosticsError, + writingDiagnosticsLabel, + printWritingDiagnostics, }, ref, ) { @@ -190,7 +197,14 @@ export const CwlEditor = forwardRef( }, }); - useEditorHandle(ref, editor, modeRef); + const writingDiagnosticsController = useWritingDiagnosticsController({ + editor, + diagnostics: writingDiagnostics, + onAction: onWritingDiagnosticAction, + onError: onWritingDiagnosticsError, + }); + + useEditorHandle(ref, editor, modeRef, writingDiagnosticsController); useEffect(() => { editor?.setEditable(editable); @@ -248,6 +262,24 @@ export const CwlEditor = forwardRef( formFieldDisabled={formFieldDisabled} formFieldInitialValue={selectedDocumentValue} onFormReset={editor && observesFormReset ? handleFormReset : undefined} + writingDiagnosticsPanel={ + writingDiagnostics === undefined ? undefined : ( + { + void writingDiagnosticsController.applyDiagnostic( + diagnosticId, + ); + } + : undefined + } + printEnabled={printWritingDiagnostics} + /> + ) + } /> ); }, diff --git a/src/components/CwlEditor.writingDiagnosticApply.test.tsx b/src/components/CwlEditor.writingDiagnosticApply.test.tsx new file mode 100644 index 00000000..1e61b9d9 --- /dev/null +++ b/src/components/CwlEditor.writingDiagnosticApply.test.tsx @@ -0,0 +1,146 @@ +import { + act, + 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'; + +afterEach(cleanup); + +async function exactDiagnostic( + handle: CwlEditorHandle, + replacement: string, +): Promise { + const evidence = await handle.getTextPositionSelectorEvidence(); + if (evidence === null) throw new Error('Missing selector evidence'); + return { + diagnosticId: 'apply-diagnostic', + documentRevision: evidence.revision, + textProjection: evidence.textProjection, + selector: evidence.selector, + categoryCode: 'clarity', + priority: 'important', + title: 'Clarify the request', + explanation: 'Make the requested action explicit.', + suggestedReplacement: replacement, + provenance: { + workflowId: 'email-writing-review', + workflowVersion: '1', + judgePolicyVersion: 'evaluation-only-1', + }, + }; +} + +describe('CwlEditor writing-diagnostic application', () => { + it('rechecks the exact revision, inserts plain text, invalidates diagnostics, and remains undoable', async () => { + const handleRef: { current: CwlEditorHandle | null } = { current: null }; + const onAction = vi.fn(); + const onChange = vi.fn(); + const view = render( + , + ); + await waitFor(() => expect(handleRef.current?.getEditor()).not.toBeNull()); + + act(() => { + handleRef.current!.getEditor()!.commands.setTextSelection({ from: 1, to: 6 }); + }); + const hostileTagName = ['scr', 'ipt'].join(''); + const replacement = `<${hostileTagName}>alert(1)`; + const diagnostic = await exactDiagnostic(handleRef.current!, replacement); + + view.rerender( + , + ); + + const apply = await screen.findByRole('button', { + name: 'Apply suggestion for Clarify the request', + }); + expect(apply).toBeEnabled(); + fireEvent.click(apply); + + await waitFor(() => + expect(handleRef.current?.getValue()).toBe(`${replacement} beta`), + ); + expect(document.querySelector('script')).toBeNull(); + expect(document.querySelector('.cwl-writing-diagnostic')).toBeNull(); + expect(onAction).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'applied', + reasonCode: 'explicit', + diagnosticId: 'apply-diagnostic', + resultingDocumentRevision: expect.objectContaining({ + algorithm: 'SHA-256', + }), + }), + ); + expect(onChange).toHaveBeenCalled(); + + act(() => { + expect(handleRef.current!.getEditor()!.commands.undo()).toBe(true); + }); + expect(handleRef.current?.getValue()).toBe('Alpha beta'); + }); + + it('keeps replacement application disabled and inert in read-only mode', async () => { + const handleRef = createRef(); + const onAction = vi.fn(); + const view = render( + , + ); + await waitFor(() => expect(handleRef.current?.getEditor()).not.toBeNull()); + + act(() => { + handleRef.current!.getEditor()!.commands.setTextSelection({ from: 1, to: 6 }); + }); + const diagnostic = await exactDiagnostic(handleRef.current!, 'Omega'); + + view.rerender( + , + ); + + const apply = await screen.findByRole('button', { + name: 'Apply suggestion for Clarify the request', + }); + expect(apply).toBeDisabled(); + await expect( + handleRef.current!.applyWritingDiagnostic('apply-diagnostic'), + ).resolves.toBeNull(); + expect(handleRef.current!.getValue()).toBe('Alpha beta'); + expect(onAction).not.toHaveBeenCalled(); + expect(document.querySelector('.cwl-writing-diagnostic')).not.toBeNull(); + }); +}); diff --git a/src/components/CwlEditor.writingDiagnostics.test.tsx b/src/components/CwlEditor.writingDiagnostics.test.tsx new file mode 100644 index 00000000..c9d0205b --- /dev/null +++ b/src/components/CwlEditor.writingDiagnostics.test.tsx @@ -0,0 +1,201 @@ +import { + act, + cleanup, + 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'; + +afterEach(cleanup); + +/** Capture exact current revision and text-position evidence for one selection. */ +async function diagnosticForSelection( + handle: CwlEditorHandle, + title = 'Clarify the request', +): Promise { + const evidence = await handle.getTextPositionSelectorEvidence(); + if (evidence === null) throw new Error('Missing selector evidence'); + return { + diagnosticId: 'diagnostic-1', + documentRevision: evidence.revision, + textProjection: evidence.textProjection, + selector: evidence.selector, + categoryCode: 'clarity', + priority: 'important', + title, + explanation: 'State who should do what and by when.', + suggestedReplacement: 'Please confirm the owner and due date.', + provenance: { + workflowId: 'email-writing-review', + workflowVersion: '1', + judgePolicyVersion: 'evaluation-only-1', + }, + }; +} + +describe('CwlEditor host-supplied writing diagnostics', () => { + it('creates no semantic guidance when the host omits diagnostics', async () => { + render( + , + ); + + await waitFor(() => + expect(document.querySelector('.cwl-editor__content')).not.toBeNull(), + ); + expect(screen.queryByRole('region', { name: /writing/i })).toBeNull(); + expect(document.querySelector('.cwl-writing-diagnostic')).toBeNull(); + }); + + it('renders an explicitly supplied empty diagnostic set as advisory UI', async () => { + render( + , + ); + + expect( + await screen.findByRole('region', { name: 'Email writing guidance' }), + ).toBeInTheDocument(); + expect(screen.getByText('0 writing diagnostics')).toBeInTheDocument(); + }); + + it('renders a diagnostic only after exact revision verification', async () => { + const handleRef = createRef(); + const onWritingDiagnosticAction = vi.fn(); + const view = render( + , + ); + await waitFor(() => expect(handleRef.current?.getEditor()).not.toBeNull()); + + act(() => { + handleRef.current!.getEditor()!.commands.setTextSelection({ + from: 1, + to: 6, + }); + }); + const diagnostic = await diagnosticForSelection(handleRef.current!); + + view.rerender( + , + ); + + expect( + await screen.findByRole('region', { name: 'Email writing guidance' }), + ).toBeInTheDocument(); + expect(await screen.findByText('Clarify the request')).toBeInTheDocument(); + expect(document.querySelectorAll('.cwl-writing-diagnostic')).toHaveLength(1); + expect(onWritingDiagnosticAction).not.toHaveBeenCalled(); + }); + + it('routes imperative actions through the latest callback without rebuilding the editor', async () => { + const handleRef = createRef(); + const firstAction = vi.fn(); + const secondAction = vi.fn(); + const view = render( + , + ); + await waitFor(() => expect(handleRef.current?.getEditor()).not.toBeNull()); + const editorIdentity = handleRef.current!.getEditor(); + + act(() => { + editorIdentity!.commands.setTextSelection({ from: 1, to: 6 }); + }); + const diagnostic = await diagnosticForSelection(handleRef.current!); + + view.rerender( + , + ); + await screen.findByText('Clarify the request'); + + let focusResult = false; + act(() => { + focusResult = handleRef.current!.focusWritingDiagnostic('diagnostic-1'); + }); + expect(focusResult).toBe(true); + expect(handleRef.current!.getEditor()!.state.selection).toMatchObject({ + from: 1, + to: 6, + }); + + let ignored = null; + act(() => { + ignored = handleRef.current!.ignoreWritingDiagnostic('diagnostic-1'); + }); + expect(ignored).toMatchObject({ + action: 'ignored', + reasonCode: 'explicit', + diagnosticId: 'diagnostic-1', + }); + expect(firstAction).toHaveBeenLastCalledWith(ignored); + + view.rerender( + , + ); + expect(handleRef.current!.getEditor()).toBe(editorIdentity); + + let explanation = null; + act(() => { + explanation = + handleRef.current!.requestWritingDiagnosticExplanation('diagnostic-1'); + }); + expect(explanation).toMatchObject({ + action: 'requested_explanation', + diagnosticId: 'diagnostic-1', + }); + expect(secondAction).toHaveBeenLastCalledWith(explanation); + + const beforeDismiss = handleRef.current!.getValue(); + let dismissed = null; + act(() => { + dismissed = handleRef.current!.dismissWritingDiagnostic('diagnostic-1'); + }); + expect(dismissed).toMatchObject({ + action: 'dismissed', + diagnosticId: 'diagnostic-1', + }); + expect(secondAction).toHaveBeenLastCalledWith(dismissed); + expect(handleRef.current!.getValue()).toBe(beforeDismiss); + expect(document.querySelector('.cwl-writing-diagnostic')).toBeNull(); + expect(handleRef.current!.focusWritingDiagnostic('diagnostic-1')).toBe(false); + }); +}); diff --git a/src/components/WritingDiagnosticsPanel.dismissFocus.test.tsx b/src/components/WritingDiagnosticsPanel.dismissFocus.test.tsx index 3ecab985..53b529ce 100644 --- a/src/components/WritingDiagnosticsPanel.dismissFocus.test.tsx +++ b/src/components/WritingDiagnosticsPanel.dismissFocus.test.tsx @@ -1,5 +1,11 @@ import { useState } from 'react'; -import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { + act, + cleanup, + fireEvent, + render, + screen, +} from '@testing-library/react'; import { afterEach, describe, expect, it } from 'vitest'; import type { CwlVerifiedWritingDiagnostic, @@ -58,6 +64,7 @@ function Harness({ diagnostics, digestProvider: null, focusDiagnostic: () => true, + applyDiagnostic: async () => null, ignoreDiagnostic: () => null, dismissDiagnostic: (diagnosticId) => { const target = diagnostics.find( @@ -86,10 +93,23 @@ function Harness({ ); } +function focusButton(button: HTMLElement): void { + act(() => { + button.focus(); + }); +} + +async function dismissDiagnostic(button: HTMLElement): Promise { + await act(async () => { + fireEvent.click(button); + await Promise.resolve(); + }); +} + afterEach(cleanup); describe('WritingDiagnosticsPanel dismissal focus', () => { - it('moves focus to the next diagnostic when the focused card is dismissed', () => { + it('moves focus to the next diagnostic when the focused card is dismissed', async () => { render( { ); const dismiss = screen.getByRole('button', { name: 'Dismiss First diagnostic' }); - dismiss.focus(); + focusButton(dismiss); expect(dismiss).toHaveFocus(); - fireEvent.click(dismiss); + await dismissDiagnostic(dismiss); const items = screen.getAllByRole('listitem'); expect(items).toHaveLength(1); @@ -111,7 +131,7 @@ describe('WritingDiagnosticsPanel dismissal focus', () => { expect(items[0]).toHaveFocus(); }); - it('moves focus to the previous diagnostic when the last card is dismissed', () => { + it('moves focus to the previous diagnostic when the last card is dismissed', async () => { render( { ); const dismiss = screen.getByRole('button', { name: 'Dismiss Second diagnostic' }); - dismiss.focus(); + focusButton(dismiss); expect(dismiss).toHaveFocus(); - fireEvent.click(dismiss); + await dismissDiagnostic(dismiss); const items = screen.getAllByRole('listitem'); expect(items).toHaveLength(1); @@ -133,7 +153,7 @@ describe('WritingDiagnosticsPanel dismissal focus', () => { expect(items[0]).toHaveFocus(); }); - it('moves focus to the guidance region when the only card is dismissed', () => { + it('moves focus to the guidance region when the only card is dismissed', async () => { render( { ); const dismiss = screen.getByRole('button', { name: 'Dismiss Only diagnostic' }); - dismiss.focus(); + focusButton(dismiss); expect(dismiss).toHaveFocus(); - fireEvent.click(dismiss); + await dismissDiagnostic(dismiss); expect(screen.queryAllByRole('listitem')).toHaveLength(0); expect( diff --git a/src/components/WritingDiagnosticsPanel.dismissalFocus.test.tsx b/src/components/WritingDiagnosticsPanel.dismissalFocus.test.tsx index 62dcbd96..29940588 100644 --- a/src/components/WritingDiagnosticsPanel.dismissalFocus.test.tsx +++ b/src/components/WritingDiagnosticsPanel.dismissalFocus.test.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; import { + act, cleanup, fireEvent, render, @@ -67,6 +68,7 @@ function StatefulDiagnosticsPanel({ diagnostics, digestProvider: null, focusDiagnostic, + applyDiagnostic: async () => null, ignoreDiagnostic: () => null, dismissDiagnostic: (diagnosticId) => { const diagnostic = diagnostics.find( @@ -100,7 +102,7 @@ function StatefulDiagnosticsPanel({ afterEach(cleanup); -it('moves focus to the next surviving diagnostic after a stateful dismissal', () => { +it('moves focus to the next surviving diagnostic after a stateful dismissal', async () => { const first = verifiedDiagnostic('diagnostic-one', 'First diagnostic'); const second = verifiedDiagnostic('diagnostic-two', 'Second diagnostic'); const focusDiagnostic = vi.fn(() => true); @@ -115,10 +117,12 @@ it('moves focus to the next surviving diagnostic after a stateful dismissal', () const dismissFirst = screen.getByRole('button', { name: 'Dismiss First diagnostic', }); - dismissFirst.focus(); + act(() => dismissFirst.focus()); expect(dismissFirst).toHaveFocus(); - fireEvent.click(dismissFirst); + await act(async () => { + fireEvent.click(dismissFirst); + }); const remainingItems = screen.getAllByRole('listitem'); expect(remainingItems).toHaveLength(1); @@ -130,7 +134,7 @@ it('moves focus to the next surviving diagnostic after a stateful dismissal', () ); }); -it('moves focus to the guidance region when the final diagnostic is dismissed', () => { +it('moves focus to the guidance region when the final diagnostic is dismissed', async () => { const only = verifiedDiagnostic('diagnostic-only', 'Only diagnostic'); const focusDiagnostic = vi.fn(() => true); @@ -145,8 +149,10 @@ it('moves focus to the guidance region when the final diagnostic is dismissed', const dismissOnly = screen.getByRole('button', { name: 'Dismiss Only diagnostic', }); - dismissOnly.focus(); - fireEvent.click(dismissOnly); + act(() => dismissOnly.focus()); + await act(async () => { + fireEvent.click(dismissOnly); + }); expect(region).toHaveFocus(); expect(screen.queryAllByRole('listitem')).toHaveLength(0); diff --git a/src/components/WritingDiagnosticsPanel.keyboard.test.tsx b/src/components/WritingDiagnosticsPanel.keyboard.test.tsx index b7ecd333..f087ceb1 100644 --- a/src/components/WritingDiagnosticsPanel.keyboard.test.tsx +++ b/src/components/WritingDiagnosticsPanel.keyboard.test.tsx @@ -62,6 +62,7 @@ function controller( diagnostics, digestProvider: null, focusDiagnostic: vi.fn(() => true), + applyDiagnostic: vi.fn(async () => null), ignoreDiagnostic: vi.fn(() => null), dismissDiagnostic: vi.fn(() => null), requestDiagnosticExplanation: vi.fn(() => null), diff --git a/src/components/WritingDiagnosticsPanel.print.test.tsx b/src/components/WritingDiagnosticsPanel.print.test.tsx index 907d4ef4..e49e3a87 100644 --- a/src/components/WritingDiagnosticsPanel.print.test.tsx +++ b/src/components/WritingDiagnosticsPanel.print.test.tsx @@ -10,6 +10,7 @@ const emptyController: WritingDiagnosticsController = { diagnostics: [], digestProvider: null, focusDiagnostic: vi.fn(() => false), + applyDiagnostic: vi.fn(async () => null), ignoreDiagnostic: vi.fn(() => null), dismissDiagnostic: vi.fn(() => null), requestDiagnosticExplanation: vi.fn(() => null), diff --git a/src/components/WritingDiagnosticsPanel.test.tsx b/src/components/WritingDiagnosticsPanel.test.tsx index 64383b9e..77110df5 100644 --- a/src/components/WritingDiagnosticsPanel.test.tsx +++ b/src/components/WritingDiagnosticsPanel.test.tsx @@ -89,6 +89,7 @@ function controllerFor( diagnostics, digestProvider: null, focusDiagnostic: vi.fn(() => true), + applyDiagnostic: vi.fn(async () => null), ignoreDiagnostic: vi.fn((diagnosticId) => { const diagnostic = diagnostics.find( (candidate) => candidate.diagnostic.diagnosticId === diagnosticId, diff --git a/src/components/editorFormSerialization.test.tsx b/src/components/editorFormSerialization.test.tsx index d317bba3..d8968928 100644 --- a/src/components/editorFormSerialization.test.tsx +++ b/src/components/editorFormSerialization.test.tsx @@ -8,7 +8,10 @@ import type { CwlEditorHandle } from '../types.js'; import { CwlEditor } from './CwlEditor.js'; import { EditorFormField } from './EditorFormField.js'; -afterEach(cleanup); +afterEach(() => { + vi.restoreAllMocks(); + cleanup(); +}); function submittedValue( form: HTMLFormElement, @@ -18,13 +21,24 @@ function submittedValue( } async function dispatchReset(form: HTMLFormElement): Promise { - const allowed = form.dispatchEvent( - new Event('reset', { bubbles: true, cancelable: true }), - ); - await new Promise((resolve) => setTimeout(resolve, 0)); + let allowed = false; + await act(async () => { + allowed = form.dispatchEvent( + new Event('reset', { bubbles: true, cancelable: true }), + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); return allowed; } +function reactActWarnings( + consoleError: ReturnType, +): string[] { + return consoleError.mock.calls + .map((arguments_) => arguments_.map(String).join(' ')) + .filter((message) => message.includes('not wrapped in act')); +} + describe('native form serialization', () => { it('renders an empty native field safely before an editor or form exists', () => { const { container } = render( @@ -302,6 +316,9 @@ describe('native form serialization', () => { }); it('reports collaborative form resets without mutating shared state', async () => { + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined); const collaborationDocument = new Y.Doc(); const editorRef = createRef(); const onFormReset = vi.fn(); @@ -333,8 +350,9 @@ describe('native form serialization', () => { expect(onFormReset).toHaveBeenCalledTimes(1); expect(editorRef.current!.getValue()).toContain('Shared body'); expect(String(submittedValue(form, 'shared_body'))).toContain('Shared body'); + expect(reactActWarnings(consoleError)).toEqual([]); unmount(); collaborationDocument.destroy(); }); -}); \ No newline at end of file +}); diff --git a/src/components/useEditorHandle.test.tsx b/src/components/useEditorHandle.test.tsx index 912ce499..182c7160 100644 --- a/src/components/useEditorHandle.test.tsx +++ b/src/components/useEditorHandle.test.tsx @@ -74,6 +74,15 @@ describe('useEditorHandle', () => { expect(() => handle.insertDocumentJson({ type: 'paragraph' }), ).not.toThrow(); + expect(handle.focusWritingDiagnostic('missing')).toBe(false); + await expect( + handle.applyWritingDiagnostic('missing'), + ).resolves.toBeNull(); + expect(handle.ignoreWritingDiagnostic('missing')).toBeNull(); + expect(handle.dismissWritingDiagnostic('missing')).toBeNull(); + expect( + handle.requestWritingDiagnosticExplanation('missing'), + ).toBeNull(); expect(() => handle.clear()).not.toThrow(); expect(handle.isEmpty()).toBe(true); }); diff --git a/src/components/useEditorHandle.ts b/src/components/useEditorHandle.ts index 710fe883..b2c91fea 100644 --- a/src/components/useEditorHandle.ts +++ b/src/components/useEditorHandle.ts @@ -33,6 +33,7 @@ import { createTextPositionSelector } from '../textPositionSelectorEvidence.js'; import type { CwlEditorHandle, EditorMode } from '../types.js'; import { createEditorDocumentSnapshot } from './editorDocumentSnapshot.js'; import { editorHtmlToValue, editorValueToHtml } from './editorSerialization.js'; +import type { WritingDiagnosticsController } from './useWritingDiagnosticsController.js'; /** Create a validated portable envelope from one active editor revision. */ function createCurrentDocumentEnvelope( @@ -47,6 +48,7 @@ export function useEditorHandle( ref: ForwardedRef, editor: Editor | null, modeRef: MutableRefObject, + writingDiagnosticsController?: WritingDiagnosticsController | null, ): void { useImperativeHandle( ref, @@ -196,11 +198,23 @@ export function useEditorHandle( if (!editor) return; editor.chain().focus().insertContent(documentJson).run(); }, + focusWritingDiagnostic: (diagnosticId) => + writingDiagnosticsController?.focusDiagnostic(diagnosticId) ?? false, + applyWritingDiagnostic: (diagnosticId) => + writingDiagnosticsController?.applyDiagnostic(diagnosticId) ?? + Promise.resolve(null), + ignoreWritingDiagnostic: (diagnosticId) => + writingDiagnosticsController?.ignoreDiagnostic(diagnosticId) ?? null, + dismissWritingDiagnostic: (diagnosticId) => + writingDiagnosticsController?.dismissDiagnostic(diagnosticId) ?? null, + requestWritingDiagnosticExplanation: (diagnosticId) => + writingDiagnosticsController?.requestDiagnosticExplanation(diagnosticId) ?? + null, clear: () => { editor?.commands.clearContent(true); }, isEmpty: () => editor?.isEmpty ?? true, }), - [editor, modeRef], + [editor, modeRef, writingDiagnosticsController], ); } diff --git a/src/components/useWritingDiagnosticsController.apply.test.tsx b/src/components/useWritingDiagnosticsController.apply.test.tsx new file mode 100644 index 00000000..466e06d6 --- /dev/null +++ b/src/components/useWritingDiagnosticsController.apply.test.tsx @@ -0,0 +1,213 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { Editor } from '@tiptap/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { DocumentEnvelopeDigestProvider } from '../documentEnvelopeRevision.js'; +import { buildExtensions } from '../extensions/kit.js'; +import { + TEXT_POSITION_PROJECTION_ID, + TEXT_POSITION_PROJECTION_VERSION, +} from '../textPositionSelectorEvidence.js'; +import type { CwlWritingDiagnostic } from '../writingDiagnostics.js'; +import { useWritingDiagnosticsController } from './useWritingDiagnosticsController.js'; + +const DIGEST_HEX = '11'.repeat(32); +const OTHER_DIGEST_HEX = '22'.repeat(32); +const openEditors: Editor[] = []; + +function digestBytes(hex = DIGEST_HEX): ArrayBuffer { + return Uint8Array.from( + hex.match(/../gu)!.map((part) => Number.parseInt(part, 16)), + ).buffer; +} + +function createEditor(): Editor { + const editor = new Editor({ + extensions: buildExtensions(), + content: '

Alpha beta gamma

', + }); + openEditors.push(editor); + return editor; +} + +function diagnostic(): CwlWritingDiagnostic { + return { + diagnosticId: 'diag-apply', + documentRevision: { + algorithm: 'SHA-256', + digestHex: DIGEST_HEX, + strongEntityTag: `"sha256-${DIGEST_HEX}"`, + }, + textProjection: { + id: TEXT_POSITION_PROJECTION_ID, + version: TEXT_POSITION_PROJECTION_VERSION, + }, + selector: { type: 'TextPositionSelector', start: 0, end: 5 }, + categoryCode: 'clarity', + priority: 'important', + title: 'Clarify the request', + explanation: 'Make the action explicit.', + suggestedReplacement: 'Omega', + provenance: { + workflowId: 'email-writing-review', + workflowVersion: '1', + judgePolicyVersion: 'evaluation-only-1', + }, + }; +} + +function stableProvider(): DocumentEnvelopeDigestProvider { + return { digest: vi.fn(async () => digestBytes()) }; +} + +afterEach(() => { + for (const editor of openEditors.splice(0)) { + if (!editor.isDestroyed) editor.destroy(); + } + vi.restoreAllMocks(); +}); + +describe('revision-safe writing diagnostic application failures', () => { + it('reports a redacted revision error when hashing fails before mutation', async () => { + const editor = createEditor(); + const before = editor.getJSON(); + const onError = vi.fn(); + const provider: DocumentEnvelopeDigestProvider = { + digest: vi + .fn() + .mockResolvedValueOnce(digestBytes()) + .mockRejectedValue(new Error('private digest failure')), + }; + const { result } = renderHook(() => + useWritingDiagnosticsController({ + editor, + diagnostics: [diagnostic()], + digestProvider: provider, + onError, + }), + ); + await waitFor(() => expect(result.current.status).toBe('active')); + + let event = null; + await act(async () => { + event = await result.current.applyDiagnostic('diag-apply'); + }); + + expect(event).toBeNull(); + expect(editor.getJSON()).toEqual(before); + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ code: 'revision' }), + ); + }); + + it('abstains with revision_mismatch when the rechecked document digest differs', async () => { + const editor = createEditor(); + const before = editor.getJSON(); + const onAction = vi.fn(); + const provider: DocumentEnvelopeDigestProvider = { + digest: vi + .fn() + .mockResolvedValueOnce(digestBytes()) + .mockResolvedValueOnce(digestBytes(OTHER_DIGEST_HEX)) + .mockResolvedValueOnce(digestBytes(OTHER_DIGEST_HEX)), + }; + const { result } = renderHook(() => + useWritingDiagnosticsController({ + editor, + diagnostics: [diagnostic()], + digestProvider: provider, + onAction, + }), + ); + await waitFor(() => expect(result.current.status).toBe('active')); + + let event = null; + await act(async () => { + event = await result.current.applyDiagnostic('diag-apply'); + }); + + expect(event).toMatchObject({ + action: 'conflict', + reasonCode: 'revision_mismatch', + diagnosticId: 'diag-apply', + }); + expect(editor.getJSON()).toEqual(before); + expect(onAction).toHaveBeenCalledWith(event); + }); + + it('abstains with document_changed when authored content changes during hashing', async () => { + const editor = createEditor(); + const pendingResolvers: Array<(value: ArrayBuffer) => void> = []; + let defer = false; + const provider: DocumentEnvelopeDigestProvider = { + digest: vi.fn(() => + defer + ? new Promise((resolve) => pendingResolvers.push(resolve)) + : Promise.resolve(digestBytes()), + ), + }; + const onAction = vi.fn(); + const { result } = renderHook(() => + useWritingDiagnosticsController({ + editor, + diagnostics: [diagnostic()], + digestProvider: provider, + onAction, + }), + ); + await waitFor(() => expect(result.current.status).toBe('active')); + + defer = true; + let application!: ReturnType; + act(() => { + application = result.current.applyDiagnostic('diag-apply'); + }); + expect(pendingResolvers).toHaveLength(2); + act(() => { + editor.commands.insertContent('!'); + }); + act(() => { + pendingResolvers[0]!(digestBytes()); + pendingResolvers[1]!(digestBytes()); + }); + const event = await application; + + expect(event).toMatchObject({ + action: 'conflict', + reasonCode: 'document_changed', + diagnosticId: 'diag-apply', + }); + expect(onAction).toHaveBeenCalledWith(event); + expect(editor.getText()).toContain('!'); + expect(editor.getText()).toContain('Alpha'); + }); + + it('reports a redacted lifecycle error when dispatch cannot commit', async () => { + const editor = createEditor(); + const before = editor.getJSON(); + const onError = vi.fn(); + const provider = stableProvider(); + const { result } = renderHook(() => + useWritingDiagnosticsController({ + editor, + diagnostics: [diagnostic()], + digestProvider: provider, + onError, + }), + ); + await waitFor(() => expect(result.current.status).toBe('active')); + vi.spyOn(editor.view, 'dispatch').mockImplementation(() => { + throw new Error('private dispatch failure'); + }); + + let event = null; + await act(async () => { + event = await result.current.applyDiagnostic('diag-apply'); + }); + + expect(event).toBeNull(); + expect(editor.getJSON()).toEqual(before); + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ code: 'lifecycle' }), + ); + }); +}); diff --git a/src/components/useWritingDiagnosticsController.ts b/src/components/useWritingDiagnosticsController.ts index c0179383..d7f24b7f 100644 --- a/src/components/useWritingDiagnosticsController.ts +++ b/src/components/useWritingDiagnosticsController.ts @@ -58,6 +58,8 @@ export interface CwlWritingDiagnosticActionEvent { readonly reasonCode: CwlWritingDiagnosticActionReasonCode; readonly diagnosticId: string; readonly documentRevision: CwlEditorDocumentRevision; + /** Strong revision produced by a successful document mutation. */ + readonly resultingDocumentRevision?: CwlEditorDocumentRevision; readonly categoryCode: string; readonly generation: number; } @@ -87,6 +89,9 @@ export interface WritingDiagnosticsController { /** Exposed only for deterministic integration tests and later adapter plumbing. */ readonly digestProvider: DocumentEnvelopeDigestProvider | null | undefined; readonly focusDiagnostic: (diagnosticId: string) => boolean; + readonly applyDiagnostic: ( + diagnosticId: string, + ) => Promise; readonly ignoreDiagnostic: ( diagnosticId: string, ) => CwlWritingDiagnosticActionEvent | null; @@ -494,6 +499,108 @@ export function useWritingDiagnosticsController( })(); }); + const applyDiagnostic = useCallback( + async ( + diagnosticId: string, + ): Promise => { + const active = currentRef.current; + if ( + active.status !== 'active' || + active.editor === null || + !active.editor.isEditable || + active.editor.isDestroyed + ) { + return null; + } + const target = active.diagnostics.find( + (candidate) => candidate.diagnostic.diagnosticId === diagnosticId, + ); + const replacement = target?.diagnostic.suggestedReplacement; + if (target === undefined || replacement === undefined) return null; + + const activeEditor = active.editor; + const capturedDocument = activeEditor.state.doc; + const transaction = activeEditor.state.tr.insertText( + replacement, + target.from, + target.to, + ); + let currentRevision: CwlEditorDocumentRevision; + let resultingRevision: CwlEditorDocumentRevision; + try { + const currentEnvelope = createDocumentEnvelope( + capturedDocument.toJSON(), + ); + const resultingEnvelope = createDocumentEnvelope( + transaction.doc.toJSON(), + ); + [currentRevision, resultingRevision] = await Promise.all([ + createValidatedDocumentEnvelopeRevision( + currentEnvelope, + digestProvider, + ), + createValidatedDocumentEnvelopeRevision( + resultingEnvelope, + digestProvider, + ), + ]); + } catch { + const error = new WritingDiagnosticError('revision'); + notifyError(errorRef.current, error); + return null; + } + + const latest = currentRef.current; + const revisionMatches = revisionsEqual( + currentRevision, + target.diagnostic.documentRevision, + ); + const documentMatches = activeEditor.state.doc.eq(capturedDocument); + if ( + latest.status !== 'active' || + latest.generation !== active.generation || + latest.editor !== activeEditor || + activeEditor.isDestroyed || + !revisionMatches || + !documentMatches + ) { + const conflict = Object.freeze({ + action: 'conflict' as const, + reasonCode: revisionMatches + ? ('document_changed' as const) + : ('revision_mismatch' as const), + diagnosticId: target.diagnostic.diagnosticId, + documentRevision: target.diagnostic.documentRevision, + categoryCode: target.diagnostic.categoryCode, + generation: generationRef.current, + }); + notifyAction(actionRef.current, conflict); + return conflict; + } + + try { + activeEditor.view.dispatch(transaction); + } catch { + const error = new WritingDiagnosticError('lifecycle'); + notifyError(errorRef.current, error); + return null; + } + + const event = Object.freeze({ + action: 'applied' as const, + reasonCode: 'explicit' as const, + diagnosticId: target.diagnostic.diagnosticId, + documentRevision: target.diagnostic.documentRevision, + resultingDocumentRevision: resultingRevision, + categoryCode: target.diagnostic.categoryCode, + generation: generationRef.current, + }); + notifyAction(actionRef.current, event); + return event; + }, + [actionRef, digestProvider, errorRef], + ); + const focusDiagnostic = useCallback((diagnosticId: string): boolean => { const active = currentRef.current; if ( @@ -608,6 +715,7 @@ export function useWritingDiagnosticsController( diagnostics: current.diagnostics, digestProvider, focusDiagnostic, + applyDiagnostic, ignoreDiagnostic, dismissDiagnostic, requestDiagnosticExplanation, diff --git a/src/types.ts b/src/types.ts index 0292d4a2..31685315 100644 --- a/src/types.ts +++ b/src/types.ts @@ -13,6 +13,11 @@ import type { ClipboardConfig, ClipboardSanitizationError, } from './extensions/SafeClipboard.js'; +import type { + CwlWritingDiagnostic, + WritingDiagnosticError, +} from './writingDiagnostics.js'; +import type { CwlWritingDiagnosticActionEvent } from './components/useWritingDiagnosticsController.js'; /** Which document surface the editor reads from and writes to. */ export type EditorMode = 'markdown' | 'html'; @@ -253,6 +258,27 @@ export interface CwlEditorHandle { * safe-link, and inline-image transaction boundaries as other editor writes. */ insertDocumentJson(documentJson: JSONContent | JSONContent[]): void; + /** Focus the exact current range for one active writing diagnostic. */ + focusWritingDiagnostic(diagnosticId: string): boolean; + /** + * Apply one active diagnostic only after exact-current-revision verification. + * Ordinary stale and conflict outcomes resolve to a typed event or `null`. + */ + applyWritingDiagnostic( + diagnosticId: string, + ): Promise; + /** Report an explicit ignore action without mutating authored content. */ + ignoreWritingDiagnostic( + diagnosticId: string, + ): CwlWritingDiagnosticActionEvent | null; + /** Remove one diagnostic from local presentation without editing the document. */ + dismissWritingDiagnostic( + diagnosticId: string, + ): CwlWritingDiagnosticActionEvent | null; + /** Request an explanation through the host-owned action callback contract. */ + requestWritingDiagnosticExplanation( + diagnosticId: string, + ): CwlWritingDiagnosticActionEvent | null; /** Empty the document. */ clear(): void; /** `true` when the document has no meaningful content. */ @@ -375,4 +401,16 @@ export interface CwlEditorProps { ariaInvalid?: boolean | 'grammar' | 'spelling'; /** Whether the host form requires editor input before submission. */ ariaRequired?: boolean; + /** Host-supplied diagnostics already produced by a trusted external reviewer. */ + writingDiagnostics?: readonly CwlWritingDiagnostic[]; + /** Privacy-minimized observer for explicit diagnostic actions. */ + onWritingDiagnosticAction?: ( + event: CwlWritingDiagnosticActionEvent, + ) => void; + /** Redacted observer for structural verification failures. */ + onWritingDiagnosticsError?: (error: WritingDiagnosticError) => void; + /** Accessible name for the built-in writing-guidance region. */ + writingDiagnosticsLabel?: string; + /** Include a compact diagnostic appendix in printed output. */ + printWritingDiagnostics?: boolean; } diff --git a/src/workflowExactHead.test.ts b/src/workflowExactHead.test.ts index 8da26fe4..c99593d6 100644 --- a/src/workflowExactHead.test.ts +++ b/src/workflowExactHead.test.ts @@ -10,6 +10,9 @@ function repositoryFile(path: string): string { const workflow = repositoryFile('.github/workflows/ci.yml'); const releaseWorkflow = repositoryFile('.github/workflows/release.yml'); +const editorActionsWorkflow = repositoryFile( + '.github/workflows/writing-diagnostics-editor-actions-tdd.yml', +); const CHECKOUT_PIN = 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1'; @@ -62,6 +65,23 @@ describe('exact-head CI workflow contract', () => { ); }); + it('makes editor-action assurance fail closed on React act warnings', () => { + expect(editorActionsWorkflow).toContain(PNPM_ACTION_SETUP_PIN); + expect(editorActionsWorkflow).not.toContain( + VULNERABLE_PNPM_ACTION_SETUP_PIN, + ); + expect(editorActionsWorkflow.match(/not wrapped in act/g)).toHaveLength(2); + expect( + editorActionsWorkflow.match(/test_status=\$\{PIPESTATUS\[0\]\}/g), + ).toHaveLength(2); + expect(editorActionsWorkflow).toContain( + '::error::Focused editor actions emitted a React act warning.', + ); + expect(editorActionsWorkflow).toContain( + '::error::Production coverage emitted a React act warning.', + ); + }); + it('records the evidence boundary and unreleased hardening', () => { const doctoring = repositoryFile( 'docs/doctoring/exact-head-ci-evidence.md',