421 lines
14 KiB
TypeScript
421 lines
14 KiB
TypeScript
import { readdir, readFile } from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
|
|
import { IDBFactory, IDBKeyRange } from 'fake-indexeddb';
|
|
import { BrowserRuntimeStore, type MaicDocument, type RuntimeStore } from '@openmaic/storage';
|
|
import type { Whiteboard } from '@openmaic/dsl';
|
|
import { beforeAll, describe, expect, it, vi } from 'vitest';
|
|
|
|
import type { AppStage } from '@/lib/document-store/persistence-types';
|
|
import {
|
|
configureDocumentStorage,
|
|
resetDocumentStorageForTests,
|
|
} from '@/lib/document-store/config';
|
|
import { APP_RUNTIME_PAYLOAD_VALIDATORS } from '@/lib/runtime/payload-validators';
|
|
import { configureRuntimeStorage, resetRuntimeStorageForTests } from '@/lib/runtime/config';
|
|
import type { AppScene } from '@/lib/types/stage';
|
|
import {
|
|
createLegacyWhiteboardImporterForTests,
|
|
isLegacyWhiteboardAutoImportEligible,
|
|
} from '@/lib/whiteboard/runtime/legacy-import';
|
|
import {
|
|
createWhiteboardRuntimeService,
|
|
whiteboardRuntimeSessionId,
|
|
} from '@/lib/whiteboard/runtime/store';
|
|
|
|
beforeAll(() => {
|
|
vi.stubGlobal('IDBKeyRange', IDBKeyRange);
|
|
});
|
|
|
|
function board(id = 'legacy-board'): Whiteboard {
|
|
return { id, viewportSize: 1000, viewportRatio: 0.5625, elements: [] };
|
|
}
|
|
|
|
function document(whiteboard?: Whiteboard[]): MaicDocument<AppScene, AppStage> {
|
|
return {
|
|
stage: {
|
|
id: 'stage-1',
|
|
name: 'Stage',
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
...(whiteboard === undefined ? {} : { whiteboard }),
|
|
},
|
|
scenes: [],
|
|
};
|
|
}
|
|
|
|
function harness() {
|
|
const store = new BrowserRuntimeStore({
|
|
indexedDB: new IDBFactory(),
|
|
payloadValidators: APP_RUNTIME_PAYLOAD_VALIDATORS,
|
|
});
|
|
const service = createWhiteboardRuntimeService({
|
|
store,
|
|
resolveLearnerKey: () => 'learner-1',
|
|
now: () => '2026-08-06T00:00:00.000Z',
|
|
withMaintenanceLock: (work) => work(),
|
|
});
|
|
return { store, service };
|
|
}
|
|
|
|
async function productionSourceFiles(directory: string): Promise<string[]> {
|
|
const entries = await readdir(directory, { withFileTypes: true });
|
|
const files = await Promise.all(
|
|
entries.map(async (entry) => {
|
|
const resolved = path.join(directory, entry.name);
|
|
if (entry.isDirectory()) return productionSourceFiles(resolved);
|
|
return /\.[cm]?[jt]sx?$/u.test(entry.name) ? [resolved] : [];
|
|
}),
|
|
);
|
|
return files.flat();
|
|
}
|
|
|
|
function resolveLocalImport(repoRoot: string, importer: string, specifier: string): string | null {
|
|
if (specifier.startsWith('@/')) return path.resolve(repoRoot, specifier.slice(2));
|
|
if (specifier.startsWith('.')) return path.resolve(path.dirname(importer), specifier);
|
|
return null;
|
|
}
|
|
|
|
describe('dormant Legacy whiteboard import seam', () => {
|
|
it('has no production importer activation', async () => {
|
|
const repoRoot = process.cwd();
|
|
const target = path.resolve(repoRoot, 'lib/whiteboard/runtime/legacy-import');
|
|
const roots = ['app', 'components', 'lib'].map((root) => path.resolve(repoRoot, root));
|
|
const files = (await Promise.all(roots.map(productionSourceFiles))).flat();
|
|
const callers: string[] = [];
|
|
|
|
for (const file of files) {
|
|
if (file === `${target}.ts`) continue;
|
|
const source = await readFile(file, 'utf8');
|
|
const specifiers = source.matchAll(
|
|
/(?:from\s+|import\s*\(\s*|require\s*\(\s*|import\s+)['"]([^'"]+)['"]/gu,
|
|
);
|
|
for (const match of specifiers) {
|
|
const resolved = resolveLocalImport(repoRoot, file, match[1]!);
|
|
if (resolved?.replace(/\.[cm]?[jt]sx?$/u, '') === target) {
|
|
callers.push(path.relative(repoRoot, file));
|
|
}
|
|
}
|
|
}
|
|
|
|
expect(callers).toEqual([]);
|
|
});
|
|
|
|
it('imports one eligible local board exactly once across concurrent attempts', async () => {
|
|
const { store, service } = harness();
|
|
const loadDocument = vi.fn().mockResolvedValue(document([board()]));
|
|
let tail = Promise.resolve();
|
|
const withDocumentLock = async <T>(_stageId: string, work: () => Promise<T>): Promise<T> => {
|
|
const previous = tail;
|
|
let release!: () => void;
|
|
tail = new Promise<void>((resolve) => {
|
|
release = resolve;
|
|
});
|
|
await previous;
|
|
try {
|
|
return await work();
|
|
} finally {
|
|
release();
|
|
}
|
|
};
|
|
const run = createLegacyWhiteboardImporterForTests({
|
|
service,
|
|
provenanceEligible: () => true,
|
|
loadDocument,
|
|
withDocumentLock,
|
|
});
|
|
const results = await Promise.all([run('stage-1'), run('stage-1')]);
|
|
expect(results).toContainEqual({ status: 'imported', replayed: false, committedSeq: 0 });
|
|
expect(results).toContainEqual({ status: 'not_imported', reason: 'runtime_authoritative' });
|
|
expect(loadDocument).toHaveBeenCalledOnce();
|
|
const records = await store.listRecords(whiteboardRuntimeSessionId('stage-1', 'learner-1'));
|
|
expect(records).toHaveLength(1);
|
|
expect((await service.read('stage-1')).whiteboard?.id).toBe('legacy-board');
|
|
});
|
|
|
|
it('reports exact replay when commit response loss is followed by one failed recovery read', async () => {
|
|
const backing = new BrowserRuntimeStore({
|
|
indexedDB: new IDBFactory(),
|
|
payloadValidators: APP_RUNTIME_PAYLOAD_VALIDATORS,
|
|
});
|
|
let committed = false;
|
|
let failFirstRecoveryRead = true;
|
|
const lossy = new Proxy(backing, {
|
|
get(target, property) {
|
|
if (property === 'appendRecord') {
|
|
return async (...args: Parameters<RuntimeStore['appendRecord']>) => {
|
|
await target.appendRecord(...args);
|
|
committed = true;
|
|
throw new Error('append response lost');
|
|
};
|
|
}
|
|
if (property === 'listRecords') {
|
|
return async (...args: Parameters<RuntimeStore['listRecords']>) => {
|
|
if (committed && failFirstRecoveryRead) {
|
|
failFirstRecoveryRead = false;
|
|
throw new Error('first recovery read unavailable');
|
|
}
|
|
return target.listRecords(...args);
|
|
};
|
|
}
|
|
const value = Reflect.get(target, property, target) as unknown;
|
|
return typeof value === 'function' ? value.bind(target) : value;
|
|
},
|
|
}) as RuntimeStore;
|
|
const service = createWhiteboardRuntimeService({
|
|
store: lossy,
|
|
resolveLearnerKey: () => 'learner-1',
|
|
now: () => '2026-08-06T00:00:00.000Z',
|
|
withMaintenanceLock: (work) => work(),
|
|
});
|
|
const run = createLegacyWhiteboardImporterForTests({
|
|
service,
|
|
provenanceEligible: () => true,
|
|
loadDocument: async () => document([board()]),
|
|
});
|
|
|
|
await expect(run('stage-1')).resolves.toEqual({
|
|
status: 'imported',
|
|
replayed: true,
|
|
committedSeq: 0,
|
|
});
|
|
expect(
|
|
await backing.listRecords(whiteboardRuntimeSessionId('stage-1', 'learner-1')),
|
|
).toHaveLength(1);
|
|
});
|
|
|
|
it('never reads Legacy state once RuntimeStore has a domain record', async () => {
|
|
const { service } = harness();
|
|
const first = createLegacyWhiteboardImporterForTests({
|
|
service,
|
|
provenanceEligible: () => true,
|
|
loadDocument: async () => document([board()]),
|
|
});
|
|
await first('stage-1');
|
|
const loadDocument = vi.fn().mockResolvedValue(document([board('other')]));
|
|
const again = createLegacyWhiteboardImporterForTests({
|
|
service,
|
|
provenanceEligible: () => true,
|
|
loadDocument,
|
|
});
|
|
await expect(again('stage-1')).resolves.toEqual({
|
|
status: 'not_imported',
|
|
reason: 'runtime_authoritative',
|
|
});
|
|
expect(loadDocument).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('allows an empty session shell to import', async () => {
|
|
const { store, service } = harness();
|
|
await store.createSession({
|
|
id: whiteboardRuntimeSessionId('stage-1', 'learner-1'),
|
|
kind: 'whiteboard',
|
|
stageId: 'stage-1',
|
|
learnerKey: 'learner-1',
|
|
status: 'active',
|
|
createdAt: '2026-08-06T00:00:00.000Z',
|
|
updatedAt: '2026-08-06T00:00:00.000Z',
|
|
});
|
|
const run = createLegacyWhiteboardImporterForTests({
|
|
service,
|
|
provenanceEligible: () => true,
|
|
loadDocument: async () => document([board()]),
|
|
});
|
|
await expect(run('stage-1')).resolves.toMatchObject({ status: 'imported' });
|
|
});
|
|
|
|
it('fails closed before document reads for shared or unknown provenance', async () => {
|
|
const { service } = harness();
|
|
const loadDocument = vi.fn().mockResolvedValue(document([board()]));
|
|
const withDocumentLock = vi.fn();
|
|
const run = createLegacyWhiteboardImporterForTests({
|
|
service,
|
|
provenanceEligible: () => false,
|
|
loadDocument,
|
|
withDocumentLock,
|
|
});
|
|
await expect(run('stage-1')).resolves.toEqual({
|
|
status: 'not_imported',
|
|
reason: 'provenance_ineligible',
|
|
});
|
|
expect(loadDocument).not.toHaveBeenCalled();
|
|
expect(withDocumentLock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('takes the source snapshot and append inside the per-stage document lock', async () => {
|
|
const { service } = harness();
|
|
let locked = false;
|
|
const guardedService = {
|
|
read: service.read,
|
|
append: vi.fn(async (input: Parameters<typeof service.append>[0]) => {
|
|
expect(locked).toBe(true);
|
|
return service.append(input);
|
|
}),
|
|
reconcileOperation: service.reconcileOperation,
|
|
};
|
|
const loadDocument = vi.fn(async () => {
|
|
expect(locked).toBe(true);
|
|
return document([board()]);
|
|
});
|
|
const run = createLegacyWhiteboardImporterForTests({
|
|
service: guardedService,
|
|
provenanceEligible: () => true,
|
|
loadDocument,
|
|
withDocumentLock: async (stageId, work) => {
|
|
expect(stageId).toBe('stage-1');
|
|
locked = true;
|
|
try {
|
|
return await work();
|
|
} finally {
|
|
locked = false;
|
|
}
|
|
},
|
|
});
|
|
|
|
await expect(run('stage-1')).resolves.toMatchObject({ status: 'imported' });
|
|
expect(loadDocument).toHaveBeenCalledOnce();
|
|
expect(guardedService.append).toHaveBeenCalledOnce();
|
|
expect(locked).toBe(false);
|
|
});
|
|
|
|
it.each([
|
|
['missing', document(), 'legacy_missing'],
|
|
['multiple', document([board('one'), board('two')]), 'legacy_ambiguous'],
|
|
[
|
|
'malformed',
|
|
document([
|
|
{
|
|
id: 'bad',
|
|
viewportSize: 1000,
|
|
viewportRatio: 0.5625,
|
|
elements: [{ type: 'bad' }],
|
|
} as never,
|
|
]),
|
|
'legacy_malformed',
|
|
],
|
|
])('skips %s Legacy data without creating Runtime state', async (_label, source, reason) => {
|
|
const { store, service } = harness();
|
|
const run = createLegacyWhiteboardImporterForTests({
|
|
service,
|
|
provenanceEligible: () => true,
|
|
loadDocument: async () => source,
|
|
});
|
|
await expect(run('stage-1')).resolves.toEqual({ status: 'not_imported', reason });
|
|
expect(await store.listSessions('stage-1', 'learner-1')).toEqual([]);
|
|
});
|
|
|
|
it('ignores Scene.whiteboards and reports document read failure without content', async () => {
|
|
const { service } = harness();
|
|
const sceneOnly = {
|
|
...document(),
|
|
scenes: [{ id: 'scene-1', whiteboards: [board('scene-board')] } as never],
|
|
};
|
|
const ignoreScene = createLegacyWhiteboardImporterForTests({
|
|
service,
|
|
provenanceEligible: () => true,
|
|
loadDocument: async () => sceneOnly,
|
|
});
|
|
await expect(ignoreScene('stage-1')).resolves.toEqual({
|
|
status: 'not_imported',
|
|
reason: 'legacy_missing',
|
|
});
|
|
|
|
const failing = createLegacyWhiteboardImporterForTests({
|
|
service,
|
|
provenanceEligible: () => true,
|
|
loadDocument: async () => {
|
|
throw new Error('document contains private learner material');
|
|
},
|
|
});
|
|
await expect(failing('stage-2')).resolves.toEqual({
|
|
status: 'uncertain',
|
|
reason: 'document_read_failed',
|
|
});
|
|
});
|
|
|
|
it('derives the same import identity from property-order-equivalent snapshots', async () => {
|
|
const first = harness();
|
|
const second = harness();
|
|
const reordered = {
|
|
elements: [],
|
|
viewportRatio: 0.5625,
|
|
viewportSize: 1000,
|
|
id: 'legacy-board',
|
|
} as Whiteboard;
|
|
const runFirst = createLegacyWhiteboardImporterForTests({
|
|
service: first.service,
|
|
provenanceEligible: () => true,
|
|
loadDocument: async () => document([board()]),
|
|
});
|
|
const runSecond = createLegacyWhiteboardImporterForTests({
|
|
service: second.service,
|
|
provenanceEligible: () => true,
|
|
loadDocument: async () => document([reordered]),
|
|
});
|
|
await runFirst('stage-1');
|
|
await runSecond('stage-1');
|
|
const firstRecord = (
|
|
await first.store.listRecords(whiteboardRuntimeSessionId('stage-1', 'learner-1'))
|
|
)[0]!;
|
|
const secondRecord = (
|
|
await second.store.listRecords(whiteboardRuntimeSessionId('stage-1', 'learner-1'))
|
|
)[0]!;
|
|
expect(firstRecord.id).toBe(secondRecord.id);
|
|
expect(firstRecord.payload).toEqual(secondRecord.payload);
|
|
});
|
|
|
|
it('lets a concurrently committed RuntimeStore board win without overwriting it', async () => {
|
|
const { service } = harness();
|
|
const run = createLegacyWhiteboardImporterForTests({
|
|
service,
|
|
provenanceEligible: () => true,
|
|
loadDocument: async () => {
|
|
await service.append({
|
|
stageId: 'stage-1',
|
|
expectedLastSeq: null,
|
|
payload: {
|
|
payloadVersion: 1,
|
|
operationId: 'concurrent-winner',
|
|
operation: {
|
|
kind: 'legacy_snapshot_imported',
|
|
source: {
|
|
kind: 'stage.whiteboard',
|
|
fingerprint: `sha256:${'9'.repeat(64)}`,
|
|
},
|
|
whiteboard: board('runtime-winner'),
|
|
},
|
|
},
|
|
});
|
|
return document([board('legacy-loser')]);
|
|
},
|
|
});
|
|
|
|
await expect(run('stage-1')).resolves.toEqual({
|
|
status: 'not_imported',
|
|
reason: 'runtime_won',
|
|
});
|
|
expect((await service.read('stage-1')).whiteboard?.id).toBe('runtime-winner');
|
|
});
|
|
|
|
it('disables automatic import whenever server persistence is requested', () => {
|
|
const previous = process.env.NEXT_PUBLIC_PERSISTENCE;
|
|
process.env.NEXT_PUBLIC_PERSISTENCE = '1';
|
|
try {
|
|
expect(isLegacyWhiteboardAutoImportEligible()).toBe(false);
|
|
} finally {
|
|
if (previous === undefined) delete process.env.NEXT_PUBLIC_PERSISTENCE;
|
|
else process.env.NEXT_PUBLIC_PERSISTENCE = previous;
|
|
}
|
|
});
|
|
|
|
it('disables automatic import for any explicit storage configuration', () => {
|
|
configureRuntimeStorage({ learnerKey: () => 'configured' });
|
|
expect(isLegacyWhiteboardAutoImportEligible()).toBe(false);
|
|
resetRuntimeStorageForTests();
|
|
|
|
configureDocumentStorage({ store: {} as never });
|
|
expect(isLegacyWhiteboardAutoImportEligible()).toBe(false);
|
|
resetDocumentStorageForTests();
|
|
});
|
|
});
|