diff --git a/.github/workflows/writing-diagnostics-collaboration-tdd.yml b/.github/workflows/writing-diagnostics-collaboration-tdd.yml new file mode 100644 index 00000000..9c028d95 --- /dev/null +++ b/.github/workflows/writing-diagnostics-collaboration-tdd.yml @@ -0,0 +1,77 @@ +name: Writing Diagnostics Collaboration TDD + +on: + push: + branches: + - feat/writing-diagnostics-collaboration + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: writing-diagnostics-collaboration-tdd-${{ github.ref }} + cancel-in-progress: true + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + collaborative-diagnostics: + 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 collaborative writing-diagnostic parity tests + run: | + set -euo pipefail + output_file="$(mktemp)" + trap 'rm -f "$output_file"' EXIT + set +e + pnpm exec vitest run \ + src/collaboration/CollaborativeCwlEditor.writingDiagnostics.test.tsx \ + src/collaboration/CollaborativeCwlEditor.writingDiagnostics.race.test.tsx \ + src/collaboration/CollaborativeCwlEditor.writingDiagnostics.coverage.test.tsx \ + src/collaboration/CollaborativeCwlEditor.writingDiagnostics.boundary.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 collaborative diagnostics emitted a React act warning." + exit 1 + fi + exit "$test_status" + - name: Typecheck collaborative 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/collaboration/CollaborativeCwlEditor.tsx b/src/collaboration/CollaborativeCwlEditor.tsx index eea89b6c..b313d054 100644 --- a/src/collaboration/CollaborativeCwlEditor.tsx +++ b/src/collaboration/CollaborativeCwlEditor.tsx @@ -19,6 +19,8 @@ import { applyEditorFormReset } from '../components/editorFormReset.js'; import { editorHtmlToValue } from '../components/editorSerialization.js'; import { useEditorHandle } from '../components/useEditorHandle.js'; import { useLatestRef } from '../components/useLatestRef.js'; +import { useWritingDiagnosticsController } from '../components/useWritingDiagnosticsController.js'; +import { WritingDiagnosticsPanel } from '../components/WritingDiagnosticsPanel.js'; import type { ClipboardSanitizationError } from '../extensions/SafeClipboard.js'; import { buildExtensions } from '../extensions/kit.js'; import type { CwlEditorHandle } from '../types.js'; @@ -95,6 +97,11 @@ export const CollaborativeCwlEditor = forwardRef< ariaErrorMessage, ariaInvalid, ariaRequired, + writingDiagnostics, + onWritingDiagnosticAction, + onWritingDiagnosticsError, + writingDiagnosticsLabel, + printWritingDiagnostics, } = props; assertCollaborationConfiguration(provider, user); @@ -260,7 +267,14 @@ export const CollaborativeCwlEditor = forwardRef< [collaborationDocument, scopedProvider, normalizedField, presenceEnabled], ); - useEditorHandle(ref, editor, modeRef); + const writingDiagnosticsController = useWritingDiagnosticsController({ + editor, + diagnostics: writingDiagnostics, + onAction: onWritingDiagnosticAction, + onError: onWritingDiagnosticsError, + }); + + useEditorHandle(ref, editor, modeRef, writingDiagnosticsController); useEffect(() => { editor?.setEditable(editable); @@ -346,6 +360,24 @@ export const CollaborativeCwlEditor = forwardRef< formFieldDisabled={formFieldDisabled} onFormReset={editor && onFormReset ? handleFormReset : undefined} status={status} + writingDiagnosticsPanel={ + writingDiagnostics === undefined ? undefined : ( + { + void writingDiagnosticsController.applyDiagnostic( + diagnosticId, + ); + } + : undefined + } + printEnabled={printWritingDiagnostics} + /> + ) + } /> ); }); diff --git a/src/collaboration/CollaborativeCwlEditor.writingDiagnostics.boundary.test.tsx b/src/collaboration/CollaborativeCwlEditor.writingDiagnostics.boundary.test.tsx new file mode 100644 index 00000000..a6c2e519 --- /dev/null +++ b/src/collaboration/CollaborativeCwlEditor.writingDiagnostics.boundary.test.tsx @@ -0,0 +1,355 @@ +import { act, cleanup, render, screen, waitFor } from '@testing-library/react'; +import { createRef } from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import * as Y from 'yjs'; +import type { CwlEditorHandle } from '../types.js'; +import type { CwlWritingDiagnostic } from '../writingDiagnostics.js'; +import { CollaborativeCwlEditor } from './CollaborativeCwlEditor.js'; +import type { + CollaborationAwareness, + CollaborationAwarenessEvent, + CollaborationProviderLike, +} from './types.js'; + +function connectDocuments(left: Y.Doc, right: Y.Doc): () => void { + const forward = (update: Uint8Array, origin: unknown) => { + if (origin !== right) Y.applyUpdate(right, update, left); + }; + const reverse = (update: Uint8Array, origin: unknown) => { + if (origin !== left) Y.applyUpdate(left, update, right); + }; + left.on('update', forward); + right.on('update', reverse); + return () => { + left.off('update', forward); + right.off('update', reverse); + }; +} + +function diagnostic( + revision: NonNullable< + Awaited> + >, + id = 'shared-diagnostic', + replacement = 'Omega', +): CwlWritingDiagnostic { + return { + diagnosticId: id, + documentRevision: revision, + textProjection: { + id: 'inkspan-prosemirror-text', + version: 1, + }, + selector: { + type: 'TextPositionSelector', + start: 0, + end: 5, + }, + categoryCode: 'clarity', + priority: 'important', + title: 'Clarify the shared action', + explanation: 'State the requested action explicitly.', + suggestedReplacement: replacement, + provenance: { + workflowId: 'email-writing-review', + workflowVersion: '1', + judgePolicyVersion: 'evaluation-only-1', + }, + }; +} + +class FakeAwareness implements CollaborationAwareness { + readonly clientID = 101; + readonly states = new Map>(); + private localState: Record | null = null; + private readonly listeners: Record< + CollaborationAwarenessEvent, + Set<(...args: unknown[]) => void> + > = { + change: new Set(), + update: new Set(), + }; + + getLocalState(): Record | null { + return this.localState; + } + + getStates(): Map> { + return this.states; + } + + setLocalStateField(field: string, value: unknown): void { + this.localState = { ...(this.localState ?? {}), [field]: value }; + this.states.set(this.clientID, this.localState); + for (const listener of this.listeners.change) listener({}, 'test'); + for (const listener of this.listeners.update) listener({}, 'test'); + } + + on( + event: CollaborationAwarenessEvent, + listener: (...args: unknown[]) => void, + ): void { + this.listeners[event].add(listener); + } + + off( + event: CollaborationAwarenessEvent, + listener: (...args: unknown[]) => void, + ): void { + this.listeners[event].delete(listener); + } +} + +function providerWith(awareness: FakeAwareness): CollaborationProviderLike { + return { awareness }; +} + +function reactActWarnings( + consoleError: ReturnType, +): string[] { + return consoleError.mock.calls + .map((arguments_) => arguments_.map(String).join(' ')) + .filter((message) => message.includes('not wrapped in act')); +} + +afterEach(() => { + vi.restoreAllMocks(); + cleanup(); +}); + +describe('CollaborativeCwlEditor writing-diagnostic boundaries', () => { + it('rejects an older digest when a remote Yjs transaction lands while revision verification is pending', async () => { + const leftDocument = new Y.Doc(); + const rightDocument = new Y.Doc(); + const disconnect = connectDocuments(leftDocument, rightDocument); + const leftRef = createRef(); + const rightRef = createRef(); + + const renderEditors = (writingDiagnostics?: readonly CwlWritingDiagnostic[]) => ( +
+ + +
+ ); + + const mounted = render(renderEditors()); + await waitFor(() => { + expect(leftRef.current?.getEditor()).toBeTruthy(); + expect(rightRef.current?.getEditor()).toBeTruthy(); + }); + act(() => leftRef.current!.setValue('

Alpha beta gamma

')); + await waitFor(() => + expect(rightRef.current!.getHTML()).toContain('Alpha beta gamma'), + ); + const revision = await rightRef.current!.getDocumentEnvelopeRevision(); + expect(revision).not.toBeNull(); + + const originalDigest = globalThis.crypto.subtle.digest.bind( + globalThis.crypto.subtle, + ); + let releaseDigest!: () => void; + const digestGate = new Promise((resolve) => { + releaseDigest = resolve; + }); + const digestStarted = vi.fn(); + vi.spyOn(globalThis.crypto.subtle, 'digest').mockImplementation( + async (algorithm, data) => { + digestStarted(); + const result = originalDigest(algorithm, data); + await digestGate; + return result; + }, + ); + + mounted.rerender(renderEditors([diagnostic(revision!)])); + await waitFor(() => expect(digestStarted).toHaveBeenCalled()); + act(() => leftRef.current!.insertValue('

Remote race edit

')); + await waitFor(() => + expect(rightRef.current!.getHTML()).toContain('Remote race edit'), + ); + releaseDigest(); + + await waitFor(() => + expect( + screen.getByRole('region', { name: 'Digest-race guidance' }), + ).toHaveTextContent('0 writing diagnostics'), + ); + await expect( + rightRef.current!.applyWritingDiagnostic('shared-diagnostic'), + ).resolves.toBeNull(); + + mounted.unmount(); + disconnect(); + }); + + it('keeps editor identity stable and keeps diagnostics out of collaboration awareness', async () => { + const document = new Y.Doc(); + const awareness = new FakeAwareness(); + const provider = providerWith(awareness); + const editorRef = createRef(); + const firstAction = vi.fn(); + const secondAction = vi.fn(); + + const mounted = render( + , + ); + await waitFor(() => expect(editorRef.current?.getEditor()).toBeTruthy()); + const originalEditor = editorRef.current!.getEditor(); + act(() => editorRef.current!.setValue('

Alpha beta gamma

')); + const revision = await editorRef.current!.getDocumentEnvelopeRevision(); + expect(revision).not.toBeNull(); + + mounted.rerender( + , + ); + await waitFor(() => + expect( + screen.getByRole('region', { name: 'Writing guidance' }), + ).toHaveTextContent('1 writing diagnostics'), + ); + expect(editorRef.current!.getEditor()).toBe(originalEditor); + expect(awareness.getLocalState()).toEqual({ + user: { + id: 'writer-one', + name: 'Writer One', + color: '#2563eb', + }, + }); + + mounted.rerender( + , + ); + expect(editorRef.current!.getEditor()).toBe(originalEditor); + expect(awareness.getLocalState()).toEqual({ + user: { + id: 'writer-one', + name: 'Writer One', + color: '#2563eb', + }, + }); + expect(JSON.stringify(awareness.getLocalState())).not.toContain( + 'identity-diagnostic', + ); + expect(JSON.stringify(awareness.getLocalState())).not.toContain('Omega'); + }); + + it('emits an Apply action only on the client that explicitly invoked it', async () => { + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined); + const leftDocument = new Y.Doc(); + const rightDocument = new Y.Doc(); + const disconnect = connectDocuments(leftDocument, rightDocument); + const leftRef = createRef(); + const rightRef = createRef(); + const leftAction = vi.fn(); + const rightAction = vi.fn(); + + const renderEditors = (writingDiagnostics?: readonly CwlWritingDiagnostic[]) => ( +
+ + +
+ ); + + const mounted = render(renderEditors()); + await waitFor(() => { + expect(leftRef.current?.getEditor()).toBeTruthy(); + expect(rightRef.current?.getEditor()).toBeTruthy(); + }); + act(() => leftRef.current!.setValue('

Alpha beta gamma

')); + await waitFor(() => + expect(rightRef.current!.getHTML()).toContain('Alpha beta gamma'), + ); + const revision = await rightRef.current!.getDocumentEnvelopeRevision(); + expect(revision).not.toBeNull(); + + mounted.rerender(renderEditors([diagnostic(revision!)])); + await waitFor(() => { + expect( + screen.getByRole('region', { name: 'Left guidance' }), + ).toHaveTextContent('1 writing diagnostics'); + expect( + screen.getByRole('region', { name: 'Right guidance' }), + ).toHaveTextContent('1 writing diagnostics'); + }); + + let appliedAction: + | Awaited> + | undefined; + await act(async () => { + appliedAction = await rightRef.current!.applyWritingDiagnostic( + 'shared-diagnostic', + ); + }); + expect(appliedAction).toMatchObject({ + action: 'applied', + reasonCode: 'explicit', + diagnosticId: 'shared-diagnostic', + }); + await waitFor(() => { + expect(leftRef.current!.getHTML()).toContain('Omega beta gamma'); + expect(rightRef.current!.getHTML()).toContain('Omega beta gamma'); + }); + expect(rightAction).toHaveBeenCalledTimes(1); + expect(leftAction).not.toHaveBeenCalled(); + expect(reactActWarnings(consoleError)).toEqual([]); + + mounted.unmount(); + disconnect(); + }); +}); diff --git a/src/collaboration/CollaborativeCwlEditor.writingDiagnostics.coverage.test.tsx b/src/collaboration/CollaborativeCwlEditor.writingDiagnostics.coverage.test.tsx new file mode 100644 index 00000000..bc86e996 --- /dev/null +++ b/src/collaboration/CollaborativeCwlEditor.writingDiagnostics.coverage.test.tsx @@ -0,0 +1,67 @@ +import { act, cleanup, render, screen, waitFor } from '@testing-library/react'; +import { createRef } from 'react'; +import { afterEach, describe, expect, it } from 'vitest'; +import * as Y from 'yjs'; +import type { CwlEditorHandle } from '../types.js'; +import type { CwlWritingDiagnostic } from '../writingDiagnostics.js'; +import { CollaborativeCwlEditor } from './CollaborativeCwlEditor.js'; + +function diagnostic( + revision: NonNullable< + Awaited> + >, +): CwlWritingDiagnostic { + return { + diagnosticId: 'default-label-diagnostic', + documentRevision: revision, + textProjection: { + id: 'inkspan-prosemirror-text', + version: 1, + }, + selector: { + type: 'TextPositionSelector', + start: 0, + end: 5, + }, + categoryCode: 'clarity', + priority: 'advisory', + title: 'Clarify the action', + explanation: 'State the requested action explicitly.', + provenance: { + workflowId: 'email-writing-review', + workflowVersion: '1', + judgePolicyVersion: 'evaluation-only-1', + }, + }; +} + +afterEach(cleanup); + +describe('CollaborativeCwlEditor writing-diagnostic defaults', () => { + it('uses the default accessible guidance label when the host omits one', async () => { + const document = new Y.Doc(); + const editorRef = createRef(); + const mounted = render( + , + ); + await waitFor(() => expect(editorRef.current?.getEditor()).toBeTruthy()); + act(() => editorRef.current!.setValue('

Alpha beta gamma

')); + const revision = await editorRef.current!.getDocumentEnvelopeRevision(); + expect(revision).not.toBeNull(); + + mounted.rerender( + , + ); + + await waitFor(() => + expect( + screen.getByRole('region', { name: 'Writing guidance' }), + ).toHaveTextContent('1 writing diagnostics'), + ); + }); +}); diff --git a/src/collaboration/CollaborativeCwlEditor.writingDiagnostics.race.test.tsx b/src/collaboration/CollaborativeCwlEditor.writingDiagnostics.race.test.tsx new file mode 100644 index 00000000..ba7211c9 --- /dev/null +++ b/src/collaboration/CollaborativeCwlEditor.writingDiagnostics.race.test.tsx @@ -0,0 +1,315 @@ +import { act, cleanup, render, screen, waitFor } from '@testing-library/react'; +import { createRef } from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import * as Y from 'yjs'; +import type { CwlEditorHandle } from '../types.js'; +import type { CwlWritingDiagnostic } from '../writingDiagnostics.js'; +import { CollaborativeCwlEditor } from './CollaborativeCwlEditor.js'; +import type { + CollaborationAwareness, + CollaborationAwarenessEvent, + CollaborationProviderLike, + CollaborationUser, +} from './types.js'; + +class FakeAwareness implements CollaborationAwareness { + readonly clientID: number; + readonly states = new Map>(); + private localState: Record | null = null; + private readonly listeners: Record< + CollaborationAwarenessEvent, + Set<(...args: unknown[]) => void> + > = { + change: new Set(), + update: new Set(), + }; + + constructor(clientID = 1) { + this.clientID = clientID; + } + + getLocalState(): Record | null { + return this.localState; + } + + getStates(): Map> { + return this.states; + } + + setLocalStateField(field: string, value: unknown): void { + this.localState = { ...(this.localState ?? {}), [field]: value }; + this.states.set(this.clientID, this.localState); + for (const event of ['change', 'update'] as const) { + for (const listener of this.listeners[event]) listener({}, 'test'); + } + } + + on( + event: CollaborationAwarenessEvent, + listener: (...args: unknown[]) => void, + ): void { + this.listeners[event].add(listener); + } + + off( + event: CollaborationAwarenessEvent, + listener: (...args: unknown[]) => void, + ): void { + this.listeners[event].delete(listener); + } +} + +function connectDocuments(left: Y.Doc, right: Y.Doc): () => void { + const forward = (update: Uint8Array, origin: unknown) => { + if (origin !== right) Y.applyUpdate(right, update, left); + }; + const reverse = (update: Uint8Array, origin: unknown) => { + if (origin !== left) Y.applyUpdate(left, update, right); + }; + left.on('update', forward); + right.on('update', reverse); + return () => { + left.off('update', forward); + right.off('update', reverse); + }; +} + +async function diagnosticFor( + handle: CwlEditorHandle, + diagnosticId = 'collaborative-race-diagnostic', +): Promise { + act(() => { + handle.getEditor()!.commands.setTextSelection({ from: 1, to: 6 }); + }); + const evidence = await handle.getTextPositionSelectorEvidence(); + if (evidence === null) throw new Error('Missing collaborative selector evidence'); + return { + diagnosticId, + documentRevision: evidence.revision, + textProjection: evidence.textProjection, + selector: evidence.selector, + categoryCode: 'clarity', + priority: 'important', + title: 'Clarify the shared request', + explanation: 'Make the shared action explicit.', + suggestedReplacement: 'Omega', + provenance: { + workflowId: 'email-writing-review', + workflowVersion: '1', + judgePolicyVersion: 'evaluation-only-1', + }, + }; +} + +const ALICE: CollaborationUser = { + userId: 'editor-alice', + displayName: 'Alice', + cursorColor: '#2563eb', +}; + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +describe('CollaborativeCwlEditor diagnostic race and privacy boundaries', () => { + it('blocks a local apply when a remote update arrives while both revision digests are pending', async () => { + const leftDocument = new Y.Doc(); + const rightDocument = new Y.Doc(); + const disconnect = connectDocuments(leftDocument, rightDocument); + const leftRef = createRef(); + const rightRef = createRef(); + const leftAction = vi.fn(); + const rightAction = vi.fn(); + + const renderEditors = (writingDiagnostics?: readonly CwlWritingDiagnostic[]) => ( +
+ + +
+ ); + + const mounted = render(renderEditors()); + await waitFor(() => { + expect(leftRef.current?.getEditor()).toBeTruthy(); + expect(rightRef.current?.getEditor()).toBeTruthy(); + }); + act(() => leftRef.current!.setValue('

Alpha beta gamma

')); + await waitFor(() => + expect(rightRef.current!.getHTML()).toContain('Alpha beta gamma'), + ); + const diagnostic = await diagnosticFor(rightRef.current!); + + mounted.rerender(renderEditors([diagnostic])); + await waitFor(() => { + expect( + screen.getByRole('region', { name: 'Left race guidance' }), + ).toHaveTextContent('1 writing diagnostics'); + expect( + screen.getByRole('region', { name: 'Right race guidance' }), + ).toHaveTextContent('1 writing diagnostics'); + }); + + const originalDigest = globalThis.crypto.subtle.digest.bind( + globalThis.crypto.subtle, + ); + const pending: Array<{ + algorithm: AlgorithmIdentifier; + source: BufferSource; + resolve: (value: ArrayBuffer) => void; + }> = []; + vi.spyOn(globalThis.crypto.subtle, 'digest').mockImplementation( + (algorithm, source) => + new Promise((resolve) => { + pending.push({ algorithm, source, resolve }); + }), + ); + + let application!: ReturnType; + act(() => { + application = rightRef.current!.applyWritingDiagnostic( + 'collaborative-race-diagnostic', + ); + }); + await waitFor(() => expect(pending).toHaveLength(2)); + + act(() => leftRef.current!.insertValue('!')); + await waitFor(() => expect(rightRef.current!.getHTML()).toContain('!')); + + await act(async () => { + for (const request of pending) { + request.resolve(await originalDigest(request.algorithm, request.source)); + } + }); + let event = null; + await act(async () => { + event = await application; + }); + + expect(event).toMatchObject({ + action: 'conflict', + reasonCode: 'document_changed', + diagnosticId: 'collaborative-race-diagnostic', + }); + expect(leftRef.current!.getHTML()).not.toContain('Omega'); + expect(rightRef.current!.getHTML()).not.toContain('Omega'); + expect(rightAction).toHaveBeenCalledWith(event); + expect(leftAction).not.toHaveBeenCalled(); + expect( + screen.getByRole('region', { name: 'Left race guidance' }), + ).toHaveTextContent('0 writing diagnostics'); + expect( + screen.getByRole('region', { name: 'Right race guidance' }), + ).toHaveTextContent('0 writing diagnostics'); + + mounted.unmount(); + disconnect(); + }); + + it('keeps diagnostics out of awareness and uses the latest callback without recreating or owning host resources', async () => { + const collaborationDocument = new Y.Doc(); + const destroy = vi.spyOn(collaborationDocument, 'destroy'); + const awareness = new FakeAwareness(10); + const provider: CollaborationProviderLike = { awareness }; + const editorRef = createRef(); + const firstAction = vi.fn(); + const secondAction = vi.fn(); + + const mounted = render( + , + ); + await waitFor(() => { + expect(editorRef.current?.getEditor()).toBeTruthy(); + expect(awareness.getLocalState()?.user).toEqual({ + id: 'editor-alice', + name: 'Alice', + color: '#2563eb', + }); + }); + const editorIdentity = editorRef.current!.getEditor(); + act(() => editorRef.current!.setValue('

Alpha beta gamma

')); + const diagnostic = await diagnosticFor(editorRef.current!, 'awareness-diagnostic'); + + mounted.rerender( + , + ); + await waitFor(() => + expect( + screen.getByRole('region', { name: 'Awareness-safe guidance' }), + ).toHaveTextContent('1 writing diagnostics'), + ); + expect(editorRef.current!.getEditor()).toBe(editorIdentity); + + const serializedAwareness = JSON.stringify(awareness.getLocalState()); + for (const forbidden of [ + diagnostic.diagnosticId, + diagnostic.title, + diagnostic.explanation, + diagnostic.suggestedReplacement!, + diagnostic.documentRevision.digestHex, + diagnostic.provenance.workflowId, + ]) { + expect(serializedAwareness).not.toContain(forbidden); + } + + mounted.rerender( + , + ); + expect(editorRef.current!.getEditor()).toBe(editorIdentity); + + let explanation = null; + act(() => { + explanation = editorRef.current!.requestWritingDiagnosticExplanation( + 'awareness-diagnostic', + ); + }); + expect(explanation).toMatchObject({ + action: 'requested_explanation', + diagnosticId: 'awareness-diagnostic', + }); + expect(secondAction).toHaveBeenCalledWith(explanation); + expect(firstAction).not.toHaveBeenCalled(); + + mounted.unmount(); + expect(destroy).not.toHaveBeenCalled(); + }); +}); diff --git a/src/collaboration/CollaborativeCwlEditor.writingDiagnostics.test.tsx b/src/collaboration/CollaborativeCwlEditor.writingDiagnostics.test.tsx new file mode 100644 index 00000000..ae331769 --- /dev/null +++ b/src/collaboration/CollaborativeCwlEditor.writingDiagnostics.test.tsx @@ -0,0 +1,285 @@ +import { + act, + cleanup, + fireEvent, + render, + screen, + waitFor, + within, +} from '@testing-library/react'; +import { createRef } from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import * as Y from 'yjs'; +import type { CwlEditorDocumentRevision } from '../documentEnvelopeRevision.js'; +import { writingDiagnosticsPluginKey } from '../extensions/WritingDiagnostics.js'; +import type { CwlEditorHandle } from '../types.js'; +import type { CwlWritingDiagnostic } from '../writingDiagnostics.js'; +import { CollaborativeCwlEditor } from './CollaborativeCwlEditor.js'; + +function connectDocuments(left: Y.Doc, right: Y.Doc): () => void { + const forward = (update: Uint8Array, origin: unknown) => { + if (origin !== right) Y.applyUpdate(right, update, left); + }; + const reverse = (update: Uint8Array, origin: unknown) => { + if (origin !== left) Y.applyUpdate(left, update, right); + }; + left.on('update', forward); + right.on('update', reverse); + return () => { + left.off('update', forward); + right.off('update', reverse); + }; +} + +function diagnostic( + revision: CwlEditorDocumentRevision, + replacement = 'Omega', + diagnosticId = 'collaborative-diagnostic', +): CwlWritingDiagnostic { + return { + diagnosticId, + documentRevision: revision, + textProjection: { + id: 'inkspan-prosemirror-text', + version: 1, + }, + selector: { + type: 'TextPositionSelector', + start: 0, + end: 5, + }, + categoryCode: 'clarity', + priority: 'important', + title: `Clarify the shared request ${diagnosticId}`, + explanation: 'Make the shared action explicit.', + suggestedReplacement: replacement, + provenance: { + workflowId: 'email-writing-review', + workflowVersion: '1', + judgePolicyVersion: 'evaluation-only-1', + }, + }; +} + +afterEach(cleanup); + +describe('CollaborativeCwlEditor writing diagnostics', () => { + it('applies through Yjs, converges, and preserves collaborative undo/redo without fabricating remote actions', async () => { + const leftDocument = new Y.Doc(); + const rightDocument = new Y.Doc(); + const disconnect = connectDocuments(leftDocument, rightDocument); + const leftRef = createRef(); + const rightRef = createRef(); + const leftAction = vi.fn(); + const rightAction = vi.fn(); + + const renderEditors = (writingDiagnostics?: readonly CwlWritingDiagnostic[]) => ( +
+ + +
+ ); + + const mounted = render(renderEditors()); + await waitFor(() => { + expect(leftRef.current?.getEditor()).toBeTruthy(); + expect(rightRef.current?.getEditor()).toBeTruthy(); + }); + + act(() => leftRef.current!.setValue('

Alpha beta gamma

')); + await waitFor(() => + expect(rightRef.current!.getHTML()).toContain('Alpha beta gamma'), + ); + const revision = await rightRef.current!.getDocumentEnvelopeRevision(); + expect(revision).not.toBeNull(); + const sharedDiagnostic = diagnostic(revision!); + + mounted.rerender(renderEditors([sharedDiagnostic])); + await waitFor(() => { + expect( + screen.getByRole('region', { name: 'Left shared guidance' }), + ).toHaveTextContent('1 writing diagnostics'); + expect( + screen.getByRole('region', { name: 'Right shared guidance' }), + ).toHaveTextContent('1 writing diagnostics'); + }); + const apply = within( + screen.getByRole('region', { name: 'Right shared guidance' }), + ).getByRole('button', { + name: 'Apply suggestion for Clarify the shared request collaborative-diagnostic', + }); + expect(apply).toBeEnabled(); + + fireEvent.click(apply); + await waitFor(() => { + expect(rightRef.current!.getHTML()).toContain('Omega beta gamma'); + expect(leftRef.current!.getHTML()).toContain('Omega beta gamma'); + }); + expect(rightAction).toHaveBeenCalledTimes(1); + expect(rightAction).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'applied', + reasonCode: 'explicit', + diagnosticId: 'collaborative-diagnostic', + resultingDocumentRevision: expect.objectContaining({ + algorithm: 'SHA-256', + }), + }), + ); + expect(leftAction).not.toHaveBeenCalled(); + expect( + screen.getByRole('region', { name: 'Left shared guidance' }), + ).toHaveTextContent('0 writing diagnostics'); + expect( + screen.getByRole('region', { name: 'Right shared guidance' }), + ).toHaveTextContent('0 writing diagnostics'); + + act(() => { + expect(rightRef.current!.getEditor()!.commands.undo()).toBe(true); + }); + await waitFor(() => { + expect(rightRef.current!.getHTML()).toContain('Alpha beta gamma'); + expect(leftRef.current!.getHTML()).toContain('Alpha beta gamma'); + }); + expect(rightAction).toHaveBeenCalledTimes(1); + expect(leftAction).not.toHaveBeenCalled(); + + act(() => { + expect(rightRef.current!.getEditor()!.commands.redo()).toBe(true); + }); + await waitFor(() => { + expect(rightRef.current!.getHTML()).toContain('Omega beta gamma'); + expect(leftRef.current!.getHTML()).toContain('Omega beta gamma'); + }); + expect(rightAction).toHaveBeenCalledTimes(1); + expect(leftAction).not.toHaveBeenCalled(); + + mounted.unmount(); + disconnect(); + }); + + it('invalidates every current diagnostic when a remote Yjs update changes the document', async () => { + const leftDocument = new Y.Doc(); + const rightDocument = new Y.Doc(); + const disconnect = connectDocuments(leftDocument, rightDocument); + const leftRef = createRef(); + const rightRef = createRef(); + const rightAction = vi.fn(); + + const renderEditors = (writingDiagnostics?: readonly CwlWritingDiagnostic[]) => ( +
+ + +
+ ); + + const mounted = render(renderEditors()); + await waitFor(() => { + expect(leftRef.current?.getEditor()).toBeTruthy(); + expect(rightRef.current?.getEditor()).toBeTruthy(); + }); + act(() => leftRef.current!.setValue('

Alpha beta gamma

')); + await waitFor(() => + expect(rightRef.current!.getHTML()).toContain('Alpha beta gamma'), + ); + const revision = await rightRef.current!.getDocumentEnvelopeRevision(); + expect(revision).not.toBeNull(); + const diagnostics = [ + diagnostic(revision!, 'Omega', 'remote-diagnostic-one'), + diagnostic(revision!, 'Sigma', 'remote-diagnostic-two'), + ]; + + mounted.rerender(renderEditors(diagnostics)); + await waitFor(() => + expect( + screen.getByRole('region', { name: 'Remote-safe guidance' }), + ).toHaveTextContent('2 writing diagnostics'), + ); + expect( + writingDiagnosticsPluginKey.getState( + rightRef.current!.getEditor()!.state, + )?.diagnostics, + ).toHaveLength(2); + + act(() => leftRef.current!.insertValue('

Remote edit

')); + await waitFor(() => + expect( + screen.getByRole('region', { name: 'Remote-safe guidance' }), + ).toHaveTextContent('0 writing diagnostics'), + ); + expect( + writingDiagnosticsPluginKey.getState( + rightRef.current!.getEditor()!.state, + )?.diagnostics, + ).toEqual([]); + await expect( + rightRef.current!.applyWritingDiagnostic('remote-diagnostic-one'), + ).resolves.toBeNull(); + await expect( + rightRef.current!.applyWritingDiagnostic('remote-diagnostic-two'), + ).resolves.toBeNull(); + expect(rightAction).not.toHaveBeenCalled(); + expect(rightRef.current!.getHTML()).toContain('Remote edit'); + expect(rightRef.current!.getHTML()).toContain('Alpha beta gamma'); + + mounted.unmount(); + disconnect(); + }); + + it('keeps collaborative replacement actions disabled and inert in read-only mode', async () => { + const document = new Y.Doc(); + const editorRef = createRef(); + const mounted = render( + , + ); + await waitFor(() => expect(editorRef.current?.getEditor()).toBeTruthy()); + act(() => editorRef.current!.setValue('

Alpha beta gamma

')); + const revision = await editorRef.current!.getDocumentEnvelopeRevision(); + expect(revision).not.toBeNull(); + const before = editorRef.current!.getHTML(); + + mounted.rerender( + , + ); + const apply = await screen.findByRole('button', { + name: 'Apply suggestion for Clarify the shared request collaborative-diagnostic', + }); + expect(apply).toBeDisabled(); + await expect( + editorRef.current!.applyWritingDiagnostic('collaborative-diagnostic'), + ).resolves.toBeNull(); + expect(editorRef.current!.getHTML()).toBe(before); + }); +}); diff --git a/src/workflowExactHead.test.ts b/src/workflowExactHead.test.ts index c99593d6..30066608 100644 --- a/src/workflowExactHead.test.ts +++ b/src/workflowExactHead.test.ts @@ -13,6 +13,9 @@ const releaseWorkflow = repositoryFile('.github/workflows/release.yml'); const editorActionsWorkflow = repositoryFile( '.github/workflows/writing-diagnostics-editor-actions-tdd.yml', ); +const collaborationWorkflow = repositoryFile( + '.github/workflows/writing-diagnostics-collaboration-tdd.yml', +); const CHECKOUT_PIN = 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1'; @@ -82,6 +85,25 @@ describe('exact-head CI workflow contract', () => { ); }); + it('makes collaboration assurance fail closed on React act warnings', () => { + expect(collaborationWorkflow).toContain(PNPM_ACTION_SETUP_PIN); + expect(collaborationWorkflow).not.toContain( + VULNERABLE_PNPM_ACTION_SETUP_PIN, + ); + expect( + collaborationWorkflow.match(/not wrapped in act/g), + ).toHaveLength(2); + expect( + collaborationWorkflow.match(/test_status=\$\{PIPESTATUS\[0\]\}/g), + ).toHaveLength(2); + expect(collaborationWorkflow).toContain( + '::error::Focused collaborative diagnostics emitted a React act warning.', + ); + expect(collaborationWorkflow).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',