Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions src/collaboration/CollaborativeCwlEditor.fieldBoundary.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { cleanup, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import * as Y from 'yjs';
import { CollaborativeCwlEditor } from './CollaborativeCwlEditor.js';
import type { CollaborativeCwlEditorProps } from './types.js';

const COLLABORATION_FIELD_MAX_CODE_UNITS = 1_024;
const INVALID_COLLABORATION_FIELD_MESSAGE =
'Collaboration field must be a string within the supported length.';

afterEach(cleanup);

function captureRenderFailure(field: unknown): unknown {
const consoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => undefined);
let failure: unknown;
try {
render(
<CollaborativeCwlEditor
document={new Y.Doc()}
field={field as CollaborativeCwlEditorProps['field']}
/>,
);
} catch (error) {
failure = error;
} finally {
consoleError.mockRestore();
}
return failure;
}

describe('CollaborativeCwlEditor field resource boundary', () => {
it('rejects non-string runtime field metadata through a stable redacted error', () => {
expect(captureRenderFailure(42)).toEqual(
new RangeError(INVALID_COLLABORATION_FIELD_MESSAGE),
);
});

it('rejects oversized field metadata before normalization without reflecting it', () => {
const privateMarker = 'private-room-marker';
const field = `${privateMarker}${'x'.repeat(COLLABORATION_FIELD_MAX_CODE_UNITS)}`;
const failure = captureRenderFailure(field);

expect(failure).toEqual(new RangeError(INVALID_COLLABORATION_FIELD_MESSAGE));
expect(String(failure)).not.toContain(privateMarker);
});

it('accepts an in-bound custom field at the local ceiling', () => {
const field = 'x'.repeat(COLLABORATION_FIELD_MAX_CODE_UNITS);

render(<CollaborativeCwlEditor document={new Y.Doc()} field={field} />);

expect(screen.getByRole('status')).toHaveTextContent(
'Collaboration ready · 0 remote collaborators',
);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// @vitest-environment node

import { renderToString } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import * as Y from 'yjs';
import { CollaborativeCwlEditor } from './CollaborativeCwlEditor.js';
import type {
CollaborationAwareness,
CollaborationProviderLike,
} from './types.js';

function validAwareness(): CollaborationAwareness {
const states = new Map<number, Record<string, unknown>>();
return {
clientID: 17,
states,
getLocalState: () => null,
getStates: () => states,
setLocalStateField: () => undefined,
on: () => undefined,
off: () => undefined,
};
}

describe('collaborative editor provider awareness access', () => {
it('contains a private awareness getter failure after configuration validation', () => {
const privateFailure = new Error('sensitive-provider-awareness-internal');
const awareness = validAwareness();
let reads = 0;
const provider = Object.defineProperty({}, 'awareness', {
enumerable: true,
get() {
reads += 1;
if (reads === 1) return awareness;
throw privateFailure;
},
}) as CollaborationProviderLike;

let observed: unknown;
try {
renderToString(
<CollaborativeCwlEditor document={new Y.Doc()} provider={provider} />,
);
} catch (error) {
observed = error;
}

expect(observed).toBeUndefined();
expect(reads).toBe(2);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { cleanup, render } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import * as Y from 'yjs';
import { CollaborativeCwlEditor } from './CollaborativeCwlEditor.js';
import type {
CollaborationAwareness,
CollaborationAwarenessEvent,
CollaborationProviderLike,
} from './types.js';

afterEach(cleanup);

function awarenessWith(
on: CollaborationAwareness['on'],
off: CollaborationAwareness['off'],
): CollaborationAwareness {
const states = new Map<number, Record<string, unknown>>();
return {
clientID: 23,
states,
getLocalState: () => null,
getStates: () => states,
setLocalStateField: () => undefined,
on,
off,
};
}

describe('collaborative editor provider listener failure containment', () => {
it('does not leak a private change-listener registration failure', () => {
const privateFailure = new Error('sensitive-provider-on-internal');
const awareness = awarenessWith(
(_event: CollaborationAwarenessEvent) => {
throw privateFailure;
},
() => undefined,
);
const provider: CollaborationProviderLike = { awareness };
const consoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => undefined);

let observed: unknown;
try {
render(
<CollaborativeCwlEditor document={new Y.Doc()} provider={provider} />,
);
} catch (error) {
observed = error;
} finally {
consoleError.mockRestore();
}

expect(observed).toBeUndefined();
});

it('does not leak a private change-listener cleanup failure', () => {
const privateFailure = new Error('sensitive-provider-off-internal');
let offCalls = 0;
const awareness = awarenessWith(
() => undefined,
() => {
offCalls += 1;
throw privateFailure;
},
);
const provider: CollaborationProviderLike = { awareness };
const mounted = render(
<CollaborativeCwlEditor document={new Y.Doc()} provider={provider} />,
);
const consoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => undefined);

let observed: unknown;
try {
mounted.unmount();
} catch (error) {
observed = error;
} finally {
consoleError.mockRestore();
}

expect(observed).toBeUndefined();
expect(offCalls).toBeGreaterThan(0);
});
});
60 changes: 60 additions & 0 deletions src/collaboration/CollaborativeCwlEditor.runtimeState.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// @vitest-environment node

import { renderToString } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import * as Y from 'yjs';
import { CollaborativeCwlEditor } from './CollaborativeCwlEditor.js';

describe('collaborative editor runtime state contracts', () => {
it('rejects a non-boolean editable state instead of coercing it into edit authority', () => {
expect(() =>
renderToString(
<CollaborativeCwlEditor
document={new Y.Doc()}
editable={'false' as unknown as boolean}
/>,
),
).toThrowError(
new RangeError('editor editable state must be a boolean when provided'),
);
});

it('rejects a non-boolean toolbar visibility state instead of coercing it', () => {
expect(() =>
renderToString(
<CollaborativeCwlEditor
document={new Y.Doc()}
hideToolbar={'false' as unknown as boolean}
/>,
),
).toThrowError(
new RangeError(
'editor toolbar visibility state must be a boolean when provided',
),
);
});

it('preserves omitted and explicit boolean states', () => {
expect(() =>
renderToString(<CollaborativeCwlEditor document={new Y.Doc()} />),
).not.toThrow();
expect(() =>
renderToString(
<CollaborativeCwlEditor
document={new Y.Doc()}
editable
hideToolbar={false}
/>,
),
).not.toThrow();
expect(() =>
renderToString(
<CollaborativeCwlEditor
document={new Y.Doc()}
editable={false}
hideToolbar
/>,
),
).not.toThrow();
});
});
51 changes: 45 additions & 6 deletions src/collaboration/CollaborativeCwlEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,21 @@ import {
} from './awareness.js';
import type { CollaborativeCwlEditorProps } from './types.js';

const COLLABORATION_FIELD_MAX_CODE_UNITS = 1_024;
const INVALID_COLLABORATION_FIELD_MESSAGE =
'Collaboration field must be a string within the supported length.';

/** Read host-owned awareness for presentation without leaking getter failures. */
function readProviderAwareness(
provider: CollaborativeCwlEditorProps['provider'],
) {
try {
return provider?.awareness;
} catch {
return undefined;
}
}

/**
* Provider-neutral collaborative Inkspan surface backed exclusively by a
* host-owned Yjs document. Inkspan owns neither network nor persistence
Expand Down Expand Up @@ -97,8 +112,23 @@ export const CollaborativeCwlEditor = forwardRef<
ariaRequired,
} = props;

if (typeof editable !== 'boolean') {
throw new RangeError('editor editable state must be a boolean when provided');
}
if (typeof hideToolbar !== 'boolean') {
throw new RangeError(
'editor toolbar visibility state must be a boolean when provided',
);
}
assertCollaborationConfiguration(provider, user);
if (field.trim() === '') {
if (
typeof field !== 'string' ||
field.length > COLLABORATION_FIELD_MAX_CODE_UNITS
) {
throw new RangeError(INVALID_COLLABORATION_FIELD_MESSAGE);
}
const normalizedField = field.trim();
if (normalizedField === '') {
throw new Error('collaboration field must not be empty');
}
if (
Expand All @@ -108,7 +138,6 @@ export const CollaborativeCwlEditor = forwardRef<
throw new Error('collaboration document must be a Y.Doc instance');
}

const normalizedField = field.trim();
const normalizedPlaceholder = useMemo(
() => normalizeEditorPlaceholder(placeholder),
[placeholder],
Expand Down Expand Up @@ -289,17 +318,27 @@ export const CollaborativeCwlEditor = forwardRef<
]);

const [remoteCollaborators, setRemoteCollaborators] = useState(() =>
countRemoteCollaborators(provider?.awareness),
countRemoteCollaborators(readProviderAwareness(provider)),
);
useEffect(() => {
const awareness = provider?.awareness;
const awareness = readProviderAwareness(provider);
const updateCount = () => {
setRemoteCollaborators(countRemoteCollaborators(awareness));
};
updateCount();
if (!awareness) return;
awareness.on('change', updateCount);
return () => awareness.off('change', updateCount);
try {
awareness.on('change', updateCount);
} catch {
return;
}
return () => {
try {
awareness.off('change', updateCount);
} catch {
// Host-owned listener cleanup failure is contained at unmount.
}
};
}, [provider]);

const collaboratorLabel =
Expand Down
Loading