Files
openmaic/OpenMAIC/tests/media/media-orchestrator.test.ts
2026-08-16 14:58:47 +08:00

1695 lines
61 KiB
TypeScript

import { IDBFactory } from 'fake-indexeddb';
import { BrowserAssetStore } from '@openmaic/storage';
import type { Slide } from '@openmaic/dsl';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { MediaTask } from '@/lib/store/media-generation';
import type { Scene, Stage } from '@/lib/types/stage';
import type { MediaFileRecord } from '@/lib/utils/database';
const mocks = vi.hoisted(() => ({
probePresence: vi.fn(),
settings: vi.fn(),
mediaPut: vi.fn(),
mediaGet: vi.fn(),
mediaDelete: vi.fn(),
getPool: vi.fn(),
document: null as ReturnType<typeof documentWithMedia> | null,
stageState: { stage: null, scenes: [] } as {
stage: ReturnType<typeof documentWithMedia>['stage'] | null;
scenes: ReturnType<typeof documentWithMedia>['scenes'];
},
stageSetState: vi.fn(),
markStagePersistenceDirty: vi.fn(),
beforeDocumentWork: vi.fn(),
accessDocument: vi.fn(),
listDocuments: vi.fn(),
loadDocument: vi.fn(),
otherDocuments: new Map<string, ReturnType<typeof documentWithMedia>>(),
mediaRows: new Map<string, { id: string; blob: Blob; error?: string }>(),
serverBacked: false,
}));
vi.mock('@/lib/media/asset-pool-config', () => ({
isAssetPoolServerBacked: () => mocks.serverBacked,
}));
vi.mock('@/lib/store/settings', () => ({
useSettingsStore: { getState: mocks.settings },
}));
vi.mock('@/lib/utils/database', () => ({
mediaFileKey: (stageId: string, ref: string) => `${stageId}:${ref}`,
db: {
mediaFiles: {
put: mocks.mediaPut,
get: mocks.mediaGet,
delete: mocks.mediaDelete,
},
},
}));
vi.mock('@/lib/media/asset-pool', () => ({
getAssetPool: mocks.getPool,
putAsset: (...args: unknown[]) => mocks.getPool().put(...args),
replaceAsset: (...args: unknown[]) => mocks.getPool().replace(...args),
removeAsset: (...args: unknown[]) => mocks.getPool().remove(...args),
}));
// Production runs one realm unless a peer answers; individual tests override
// this to model an unavailable probe or a live peer.
vi.mock('@/lib/media/stage-realm-presence', () => ({
probeStageRealmPresence: mocks.probePresence,
}));
vi.mock('@/lib/document-store', () => ({
accessDocument: mocks.accessDocument,
getDocumentStore: () => ({
listDocuments: mocks.listDocuments,
loadDocument: mocks.loadDocument,
}),
mutateDocument: async (
_stageId: string,
work: (document: unknown, store: { saveDocument: (next: unknown) => Promise<void> }) => unknown,
) => (
mocks.beforeDocumentWork(),
work(mocks.document, {
saveDocument: async (next) => {
mocks.document = structuredClone(next) as ReturnType<typeof documentWithMedia>;
},
})
),
}));
vi.mock('@/lib/store/stage', () => ({
useStageStore: {
getState: () => mocks.stageState,
setState: mocks.stageSetState,
},
markStagePersistenceDirty: mocks.markStagePersistenceDirty,
}));
import {
generateMediaForOutlines,
mediaRetryTarget,
reconcileCompletedMediaForScene,
retryMediaTask,
} from '@/lib/media/media-orchestrator';
import { buildRestoredMediaTasks } from '@/lib/classroom/load-classroom';
import { useMediaGenerationStore } from '@/lib/store/media-generation';
import { markStageDeleted, unmarkStageDeleted } from '@/lib/utils/deleted-stages';
import { resolveSlideMediaState } from '@/components/slide-renderer/use-resolved-slide';
import { expectDocumentAssetOwnership } from './assert-stage-asset-ownership';
const stageId = 'stage-write-path';
const placeholder = 'gen_vid_1';
function documentWithMedia(ref = placeholder, type: 'image' | 'video' = 'video') {
const element: {
id: string;
type: 'image' | 'video';
left: number;
top: number;
width: number;
height: number;
rotate: number;
src: string;
mediaRef?: string;
poster?: string;
autoplay?: boolean;
fixedRatio?: boolean;
} =
type === 'video'
? {
id: 'video-1',
type: 'video',
left: 0,
top: 0,
width: 100,
height: 56,
rotate: 0,
src: ref,
mediaRef: ref,
autoplay: false,
}
: {
id: 'image-1',
type: 'image',
left: 0,
top: 0,
width: 100,
height: 100,
rotate: 0,
fixedRatio: true,
src: ref,
};
return {
dslVersion: 1,
stage: {
id: stageId,
name: 'Write paths',
createdAt: 1,
updatedAt: 1,
...(type === 'video'
? { videoManifest: { [ref]: { type: 'video', prompt: 'A short clip' } } }
: {}),
},
scenes: [
{
id: 'scene-1',
stageId,
title: 'Scene',
order: 1,
type: 'slide',
content: {
type: 'slide',
canvas: {
id: 'slide-1',
viewportSize: 1000,
viewportRatio: 0.5625,
background: { type: 'solid', color: '#fff' },
theme: {
fontName: 'Arial',
fontColor: '#111',
backgroundColor: '#fff',
themeColors: ['#111'],
},
elements: [element],
},
},
},
],
};
}
function failedTask(ref: string, type: 'image' | 'video' = 'image'): MediaTask {
return {
elementId: ref,
type,
status: 'failed',
prompt: type === 'image' ? 'A new image' : 'A short clip',
params: { aspectRatio: '16:9' },
retryCount: 0,
stageId,
error: 'retry me',
};
}
function doneTask(ref: string, type: 'image' | 'video' = 'image'): MediaTask {
return {
...failedTask(ref, type),
status: 'done',
objectUrl: 'blob:source',
error: undefined,
};
}
describe('media orchestrator asset write paths', () => {
let pool: BrowserAssetStore;
let objectUrls: Map<string, Blob>;
let fetchMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
pool = new BrowserAssetStore({
indexedDB: new IDBFactory(),
dbName: `orchestrator-${crypto.randomUUID()}`,
});
mocks.getPool.mockReset();
mocks.getPool.mockReturnValue(pool);
mocks.probePresence.mockReset().mockResolvedValue('absent');
mocks.serverBacked = false;
mocks.mediaRows.clear();
mocks.mediaPut.mockReset().mockImplementation(async (row) => {
mocks.mediaRows.set(row.id, row);
});
mocks.mediaGet.mockReset().mockImplementation(async (id) => mocks.mediaRows.get(id));
mocks.mediaDelete.mockReset().mockImplementation(async (id) => {
mocks.mediaRows.delete(id);
});
mocks.stageSetState.mockReset();
mocks.markStagePersistenceDirty.mockReset();
mocks.beforeDocumentWork.mockReset();
mocks.otherDocuments.clear();
mocks.listDocuments
.mockReset()
.mockImplementation(async () => [
...(mocks.document ? [{ id: mocks.document.stage.id }] : []),
...[...mocks.otherDocuments.keys()].map((id) => ({ id })),
]);
mocks.loadDocument
.mockReset()
.mockImplementation(async (id) =>
id === mocks.document?.stage.id ? mocks.document : (mocks.otherDocuments.get(id) ?? null),
);
mocks.accessDocument.mockReset().mockImplementation(async () => ({
document: mocks.document,
readOnlyLegacy: false,
}));
mocks.stageState = { stage: null, scenes: [] };
mocks.settings.mockReset().mockReturnValue({
imageGenerationEnabled: true,
videoGenerationEnabled: true,
imageProviderId: 'image-provider',
imageModelId: 'image-model',
imageProvidersConfig: {},
videoProviderId: 'video-provider',
videoModelId: 'video-model',
videoProvidersConfig: {},
});
useMediaGenerationStore.setState({ tasks: {} });
objectUrls = new Map();
vi.stubGlobal('URL', {
createObjectURL: vi.fn((blob: Blob) => {
const url = `blob:test-${objectUrls.size + 1}`;
objectUrls.set(url, blob);
return url;
}),
revokeObjectURL: vi.fn(),
});
fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
});
afterEach(async () => {
await pool.close();
vi.unstubAllGlobals();
});
function serveVideo(bytes = 'video-new') {
fetchMock.mockImplementation(async (input: string, init?: RequestInit) => {
if (input === '/api/generate/video') {
return new Response(
JSON.stringify({
success: true,
result: {
url: 'https://media.test/video',
poster: 'https://media.test/poster',
width: 1280,
height: 720,
duration: 5,
},
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
);
}
if (input === '/api/proxy-media') {
const requested = JSON.parse(String(init?.body)).url as string;
return requested.endsWith('/poster')
? new Response(new Blob(['poster-new'], { type: 'image/jpeg' }), { status: 200 })
: new Response(new Blob([bytes], { type: 'video/mp4' }), { status: 200 });
}
throw new Error(`Unexpected fetch: ${input}`);
});
}
function serveImage(bytes = 'image-new') {
fetchMock.mockImplementation(async (input: string) => {
if (input === '/api/generate/image') {
return new Response(
JSON.stringify({
success: true,
result: { url: 'https://media.test/image', width: 1024, height: 576 },
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
);
}
if (input === '/api/proxy-media') {
return new Response(new Blob([bytes], { type: 'image/png' }), { status: 200 });
}
throw new Error(`Unexpected fetch: ${input}`);
});
}
async function resolvedText(ref: string): Promise<string | null> {
const url = await pool.resolve(ref);
return url ? ((await objectUrls.get(url)?.text()) ?? null) : null;
}
async function assertOwnership(): Promise<void> {
if (!mocks.document) throw new Error('Expected a document');
await expectDocumentAssetOwnership({
document: mocks.document as unknown as Parameters<
typeof expectDocumentAssetOwnership
>[0]['document'],
mediaRowIds: new Set(mocks.mediaRows.keys()),
audioRowIds: new Set(),
poolHas: async (ref) => {
const url = await pool.resolve(ref);
if (!url) return false;
await pool.release(ref);
return true;
},
});
}
it('converges when the scene is inserted before media completes', async () => {
mocks.document = documentWithMedia();
const put = vi.spyOn(pool, 'put');
serveVideo();
await generateMediaForOutlines(
[
{
id: 'outline-1',
type: 'slide',
title: 'Scene',
description: 'Scene',
keyPoints: ['video'],
order: 1,
mediaGenerations: [
{ type: 'video', prompt: 'A short clip', elementId: placeholder, aspectRatio: '16:9' },
],
},
],
stageId,
);
const element = mocks.document.scenes[0].content.canvas.elements[0];
const assetId = element.mediaRef as string;
const posterAssetId = element.poster;
expect(assetId).toMatch(/^ast_/);
expect(element.src).toBe(assetId);
expect(posterAssetId).toMatch(/^ast_/);
expect(mocks.document.stage.videoManifest).toEqual({
[assetId]: { type: 'video', prompt: 'A short clip' },
});
expect(await resolvedText(assetId)).toBe('video-new');
expect(await resolvedText(posterAssetId as string)).toBe('poster-new');
expect(put).toHaveBeenCalledWith(
expect.any(Blob),
expect.objectContaining({
contentType: 'video/mp4',
prompt: 'A short clip',
dimensions: { width: 1280, height: 720 },
duration: 5,
}),
);
expect(mocks.mediaPut).toHaveBeenCalledWith(
expect.objectContaining({ id: `${stageId}:${assetId}`, stageId, type: 'video' }),
);
expect(mocks.mediaPut).toHaveBeenCalledWith(
expect.objectContaining({ id: `${stageId}:${posterAssetId}`, stageId, type: 'image' }),
);
expect(useMediaGenerationStore.getState().tasks[placeholder]).toBeUndefined();
expect(useMediaGenerationStore.getState().tasks[assetId]).toMatchObject({
elementId: assetId,
status: 'done',
});
await assertOwnership();
});
it('converges when media completes before the generated scene is inserted', async () => {
const pendingDocument = documentWithMedia(placeholder, 'video');
const lateScene = structuredClone(pendingDocument.scenes[0]);
pendingDocument.scenes = [];
mocks.document = pendingDocument;
serveVideo('early-media');
await generateMediaForOutlines(
[
{
id: 'outline-1',
type: 'slide',
title: 'Scene',
description: 'Scene',
keyPoints: ['video'],
order: 1,
mediaGenerations: [
{ type: 'video', prompt: 'A short clip', elementId: placeholder, aspectRatio: '16:9' },
],
},
],
stageId,
);
const completed = Object.values(useMediaGenerationStore.getState().tasks)[0];
expect(completed).toMatchObject({
status: 'done',
placeholderRef: placeholder,
elementId: expect.stringMatching(/^ast_/),
});
const reconciled = reconcileCompletedMediaForScene(
lateScene as unknown as Scene,
pendingDocument.stage as unknown as Stage,
useMediaGenerationStore.getState().tasks,
);
mocks.document = {
...pendingDocument,
stage: reconciled.stage,
scenes: [reconciled.scene],
} as unknown as ReturnType<typeof documentWithMedia>;
const insertedDocument = mocks.document;
if (!insertedDocument) throw new Error('Expected the reconciled document');
const insertedRef = insertedDocument.scenes[0].content.canvas.elements[0].src;
expect(insertedRef).toBe(completed.elementId);
expect(insertedDocument.scenes[0].content.canvas.elements[0].poster).toBe(
completed.posterAssetId,
);
expect(insertedDocument.stage.videoManifest).toEqual({
[completed.elementId]: { type: 'video', prompt: 'A short clip' },
});
expect(await resolvedText(completed.elementId)).toBe('early-media');
expect(mocks.mediaPut).toHaveBeenCalledWith(
expect.objectContaining({ id: `${stageId}:${completed.elementId}` }),
);
});
it('makes zero API calls and zero allocations when a restored document no longer owns the placeholder', async () => {
const assetId = await pool.put(new Blob(['persisted-image'], { type: 'image/png' }));
const put = vi.spyOn(pool, 'put');
put.mockClear();
mocks.document = documentWithMedia(assetId, 'image');
useMediaGenerationStore.setState({
tasks: buildRestoredMediaTasks(stageId, [
{
id: `${stageId}:${assetId}`,
stageId,
placeholderRef: placeholder,
type: 'image',
blob: new Blob(['persisted-image'], { type: 'image/png' }),
mimeType: 'image/png',
size: 15,
prompt: 'A new image',
params: '{}',
createdAt: 1,
},
]),
});
await generateMediaForOutlines(
[
{
id: 'outline-1',
type: 'slide',
title: 'Scene',
description: 'Scene',
keyPoints: ['image'],
order: 1,
mediaGenerations: [{ type: 'image', prompt: 'A new image', elementId: placeholder }],
},
],
stageId,
);
expect(fetchMock).not.toHaveBeenCalled();
expect(put).not.toHaveBeenCalled();
expect(mocks.document.scenes[0].content.canvas.elements[0].src).toBe(assetId);
});
it('runs a final document reconciliation before task re-keying when a scene lands late', async () => {
const pendingDocument = documentWithMedia(placeholder, 'video');
const lateScene = structuredClone(pendingDocument.scenes[0]);
pendingDocument.scenes = [];
mocks.document = pendingDocument;
serveVideo('late-window-media');
let documentScans = 0;
mocks.beforeDocumentWork.mockImplementation(() => {
documentScans += 1;
if (documentScans !== 2 || !mocks.document) return;
const pending = Object.values(useMediaGenerationStore.getState().tasks)[0];
expect(pending).toMatchObject({
status: 'generating',
elementId: placeholder,
});
mocks.document.scenes = [lateScene];
});
await generateMediaForOutlines(
[
{
id: 'outline-1',
type: 'slide',
title: 'Scene',
description: 'Scene',
keyPoints: ['video'],
order: 1,
mediaGenerations: [
{ type: 'video', prompt: 'A short clip', elementId: placeholder, aspectRatio: '16:9' },
],
},
],
stageId,
);
const completed = Object.values(useMediaGenerationStore.getState().tasks)[0];
expect(mocks.document.scenes[0].content.canvas.elements[0]).toMatchObject({
src: completed.elementId,
mediaRef: completed.elementId,
poster: completed.posterAssetId,
});
});
it('rollback layer 1: leaves the document and Dexie untouched when pool allocation fails', async () => {
mocks.document = documentWithMedia(placeholder, 'image');
const before = structuredClone(mocks.document);
serveImage();
mocks.getPool.mockReturnValueOnce({
put: vi.fn().mockRejectedValue(new Error('pool write failed')),
});
await generateMediaForOutlines(
[
{
id: 'outline-1',
type: 'slide',
title: 'Scene',
description: 'Scene',
keyPoints: ['image'],
order: 1,
mediaGenerations: [{ type: 'image', prompt: 'A new image', elementId: placeholder }],
},
],
stageId,
);
expect(mocks.document).toEqual(before);
expect(mocks.mediaPut).not.toHaveBeenCalled();
expect(useMediaGenerationStore.getState().tasks[placeholder]?.status).toBe('failed');
});
it('rollback layer 2: removes every fresh allocation when compatibility persistence fails', async () => {
mocks.document = documentWithMedia(placeholder, 'video');
serveVideo('uncommitted-video');
const put = vi.spyOn(pool, 'put');
mocks.mediaPut.mockRejectedValueOnce(new Error('compatibility write failed'));
await generateMediaForOutlines(
[
{
id: 'outline-1',
type: 'slide',
title: 'Scene',
description: 'Scene',
keyPoints: ['video'],
order: 1,
mediaGenerations: [
{ type: 'video', prompt: 'A short clip', elementId: placeholder, aspectRatio: '16:9' },
],
},
],
stageId,
);
const allocated = await Promise.all(put.mock.results.map((result) => result.value));
expect(allocated).toHaveLength(2);
for (const ref of allocated) expect(await pool.resolve(ref)).toBeNull();
expect(mocks.mediaRows.size).toBe(0);
expect(mocks.document.scenes[0].content.canvas.elements[0].src).toBe(placeholder);
});
it('rollback layer 3: removes pool and compatibility writes when document commit fails', async () => {
mocks.document = documentWithMedia(placeholder, 'image');
const before = structuredClone(mocks.document);
serveImage('uncommitted-image');
const put = vi.spyOn(pool, 'put');
mocks.beforeDocumentWork.mockImplementationOnce(() => {
throw new Error('document write failed');
});
await generateMediaForOutlines(
[
{
id: 'outline-1',
type: 'slide',
title: 'Scene',
description: 'Scene',
keyPoints: ['image'],
order: 1,
mediaGenerations: [{ type: 'image', prompt: 'A new image', elementId: placeholder }],
},
],
stageId,
);
const [allocated] = await Promise.all(put.mock.results.map((result) => result.value));
expect(await pool.resolve(allocated)).toBeNull();
expect(mocks.mediaRows.has(`${stageId}:${allocated}`)).toBe(false);
expect(mocks.document).toEqual(before);
});
it('replaces exclusively owned media bytes for a production-shaped targeted retry', async () => {
const assetId = await pool.put(new Blob(['image-old'], { type: 'image/png' }));
mocks.document = documentWithMedia(assetId, 'image');
const before = structuredClone(mocks.document);
const put = vi.spyOn(pool, 'put');
const replace = vi.spyOn(pool, 'replace');
const release = vi.spyOn(pool, 'release');
serveImage('image-replaced');
useMediaGenerationStore.setState({ tasks: { [assetId]: failedTask(assetId) } });
await retryMediaTask(assetId, {
elementId: 'image-1',
sceneId: 'scene-1',
slideId: 'slide-1',
});
expect(replace).toHaveBeenCalledWith(
assetId,
expect.any(Blob),
expect.objectContaining({
prompt: 'A new image',
dimensions: { width: 1024, height: 576 },
}),
);
expect(put).not.toHaveBeenCalled();
expect(release).toHaveBeenCalledWith(assetId);
expect(await resolvedText(assetId)).toBe('image-replaced');
expect(mocks.document).toEqual(before);
expect(mocks.mediaDelete).not.toHaveBeenCalled();
expect(mocks.mediaPut).toHaveBeenCalledWith(
expect.objectContaining({ id: `${stageId}:${assetId}` }),
);
expect(await mocks.mediaRows.get(`${stageId}:${assetId}`)?.blob.text()).toBe('image-replaced');
expect(useMediaGenerationStore.getState().tasks[assetId]?.status).toBe('done');
expect(mocks.listDocuments).toHaveBeenCalledTimes(2);
expect(mocks.loadDocument).toHaveBeenCalledWith(stageId);
await assertOwnership();
});
it('forks a targeted retry when peer presence cannot be probed', async () => {
// No BroadcastChannel, binding not finished, or a constructor that threw all
// report 'unknown'. Proving nothing about other realms must not authorize a
// global mutation, so the write boundary itself has to fork.
mocks.probePresence.mockResolvedValue('unknown');
const assetId = await pool.put(new Blob(['image-old'], { type: 'image/png' }));
mocks.document = documentWithMedia(assetId, 'image');
const replace = vi.spyOn(pool, 'replace');
serveImage('image-forked');
useMediaGenerationStore.setState({ tasks: { [assetId]: failedTask(assetId) } });
await retryMediaTask(assetId, {
elementId: 'image-1',
sceneId: 'scene-1',
slideId: 'slide-1',
});
const rewritten = mocks.document.scenes[0].content.canvas.elements[0].src;
expect(replace).not.toHaveBeenCalled();
expect(rewritten).toMatch(/^ast_/);
expect(rewritten).not.toBe(assetId);
expect(await resolvedText(rewritten)).toBe('image-forked');
// A peer's unflushed owner, had there been one, still resolves the old bytes.
expect(await resolvedText(assetId)).toBe('image-old');
});
it('forks a targeted retry in server mode even when local ownership is exclusive', async () => {
mocks.serverBacked = true;
const assetId = await pool.put(new Blob(['image-old'], { type: 'image/png' }));
mocks.document = documentWithMedia(assetId, 'image');
const replace = vi.spyOn(pool, 'replace');
serveImage('image-server-fork');
useMediaGenerationStore.setState({ tasks: { [assetId]: failedTask(assetId) } });
await retryMediaTask(assetId, {
elementId: 'image-1',
sceneId: 'scene-1',
slideId: 'slide-1',
});
const rewritten = mocks.document.scenes[0].content.canvas.elements[0].src;
expect(replace).not.toHaveBeenCalled();
expect(rewritten).toMatch(/^ast_/);
expect(rewritten).not.toBe(assetId);
expect(await resolvedText(rewritten)).toBe('image-server-fork');
expect(await resolvedText(assetId)).toBe('image-old');
});
it('forks when an exclusive ref becomes shared while generation is pending', async () => {
const assetId = await pool.put(new Blob(['image-original'], { type: 'image/png' }));
mocks.document = documentWithMedia(assetId, 'image');
mocks.stageState = {
stage: structuredClone(mocks.document.stage),
scenes: structuredClone(mocks.document.scenes),
};
mocks.mediaRows.set(`${stageId}:${assetId}`, {
id: `${stageId}:${assetId}`,
blob: new Blob(['image-original'], { type: 'image/png' }),
});
useMediaGenerationStore.setState({ tasks: { [assetId]: failedTask(assetId) } });
const replace = vi.spyOn(pool, 'replace');
let finishGeneration!: (response: Response) => void;
const generation = new Promise<Response>((resolve) => {
finishGeneration = resolve;
});
fetchMock.mockImplementation(async (input: string) => {
if (input === '/api/generate/image') return generation;
if (input === '/api/proxy-media') {
return new Response(new Blob(['image-retried'], { type: 'image/png' }), { status: 200 });
}
throw new Error(`Unexpected fetch: ${input}`);
});
const retrying = retryMediaTask(assetId, {
elementId: 'image-1',
sceneId: 'scene-1',
slideId: 'slide-1',
});
await vi.waitFor(() => {
expect(useMediaGenerationStore.getState().tasks[assetId]?.status).toBe('generating');
});
const copy = structuredClone(mocks.document.scenes[0]);
copy.id = 'scene-copy';
copy.order = 2;
copy.content.canvas.id = 'slide-copy';
copy.content.canvas.elements[0].id = 'image-copy';
mocks.document.scenes.push(copy);
mocks.stageState.scenes.push(structuredClone(copy));
finishGeneration(
new Response(
JSON.stringify({
success: true,
result: { url: 'https://media.test/image', width: 1024, height: 576 },
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
);
await retrying;
const targetRef = mocks.document.scenes[0].content.canvas.elements[0].src;
const copiedRef = mocks.document.scenes[1].content.canvas.elements[0].src;
expect(targetRef).toMatch(/^ast_/);
expect(targetRef).not.toBe(assetId);
expect(copiedRef).toBe(assetId);
expect(await resolvedText(targetRef)).toBe('image-retried');
expect(await resolvedText(copiedRef)).toBe('image-original');
expect(await mocks.mediaRows.get(`${stageId}:${assetId}`)?.blob.text()).toBe('image-original');
expect(replace).not.toHaveBeenCalled();
expect(mocks.listDocuments).toHaveBeenCalledTimes(2);
});
it('forks when the duplicate exists only in the unflushed editor state', async () => {
const assetId = await pool.put(new Blob(['image-original'], { type: 'image/png' }));
mocks.document = documentWithMedia(assetId, 'image');
mocks.stageState = {
stage: structuredClone(mocks.document.stage),
scenes: structuredClone(mocks.document.scenes),
};
mocks.mediaRows.set(`${stageId}:${assetId}`, {
id: `${stageId}:${assetId}`,
blob: new Blob(['image-original'], { type: 'image/png' }),
});
useMediaGenerationStore.setState({ tasks: { [assetId]: failedTask(assetId) } });
const replace = vi.spyOn(pool, 'replace');
let finishGeneration!: (response: Response) => void;
const generation = new Promise<Response>((resolve) => {
finishGeneration = resolve;
});
fetchMock.mockImplementation(async (input: string) => {
if (input === '/api/generate/image') return generation;
if (input === '/api/proxy-media') {
return new Response(new Blob(['image-retried'], { type: 'image/png' }), { status: 200 });
}
throw new Error(`Unexpected fetch: ${input}`);
});
const retrying = retryMediaTask(assetId, {
elementId: 'image-1',
sceneId: 'scene-1',
slideId: 'slide-1',
});
await vi.waitFor(() => {
expect(useMediaGenerationStore.getState().tasks[assetId]?.status).toBe('generating');
});
// Duplication lands in the Zustand aggregate immediately; persistence is
// still behind its debounce, so the stored document keeps a single owner.
const copy = structuredClone(mocks.stageState.scenes[0]);
copy.id = 'scene-copy';
copy.order = 2;
copy.content.canvas.id = 'slide-copy';
copy.content.canvas.elements[0].id = 'image-copy';
mocks.stageState.scenes.push(copy);
expect(mocks.document.scenes).toHaveLength(1);
finishGeneration(
new Response(
JSON.stringify({
success: true,
result: { url: 'https://media.test/image', width: 1024, height: 576 },
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
);
await retrying;
const targetRef = mocks.document.scenes[0].content.canvas.elements[0].src;
expect(targetRef).toMatch(/^ast_/);
expect(targetRef).not.toBe(assetId);
expect(await resolvedText(targetRef)).toBe('image-retried');
// The unflushed copy still points at the original id, whose bytes are intact.
expect(mocks.stageState.scenes[1].content.canvas.elements[0].src).toBe(assetId);
expect(await resolvedText(assetId)).toBe('image-original');
expect(await mocks.mediaRows.get(`${stageId}:${assetId}`)?.blob.text()).toBe('image-original');
expect(replace).not.toHaveBeenCalled();
});
it('forks a targeted retry when another persisted document aliases the allocated ref', async () => {
const assetId = await pool.put(new Blob(['shared-original'], { type: 'image/png' }));
mocks.document = documentWithMedia(assetId, 'image');
mocks.stageState = {
stage: structuredClone(mocks.document.stage),
scenes: structuredClone(mocks.document.scenes),
};
const other = documentWithMedia(assetId, 'image');
other.stage.id = 'stage-other';
other.scenes[0].stageId = 'stage-other';
mocks.otherDocuments.set('stage-other', other);
mocks.mediaRows.set(`${stageId}:${assetId}`, {
id: `${stageId}:${assetId}`,
blob: new Blob(['shared-original'], { type: 'image/png' }),
});
useMediaGenerationStore.setState({ tasks: { [assetId]: failedTask(assetId) } });
const replace = vi.spyOn(pool, 'replace');
serveImage('retried-target');
await retryMediaTask(assetId, {
elementId: 'image-1',
sceneId: 'scene-1',
slideId: 'slide-1',
});
const retriedRef = mocks.document.scenes[0].content.canvas.elements[0].src;
expect(retriedRef).toMatch(/^ast_/);
expect(retriedRef).not.toBe(assetId);
expect(other.scenes[0].content.canvas.elements[0].src).toBe(assetId);
expect(await resolvedText(assetId)).toBe('shared-original');
expect(await resolvedText(retriedRef)).toBe('retried-target');
expect(await mocks.mediaRows.get(`${stageId}:${assetId}`)?.blob.text()).toBe('shared-original');
expect(mocks.mediaDelete).not.toHaveBeenCalledWith(`${stageId}:${assetId}`);
expect(replace).not.toHaveBeenCalled();
});
it('forks instead of replacing when persisted ownership enumeration fails', async () => {
const assetId = await pool.put(new Blob(['original-bytes'], { type: 'image/png' }));
mocks.document = documentWithMedia(assetId, 'image');
mocks.stageState = {
stage: structuredClone(mocks.document.stage),
scenes: structuredClone(mocks.document.scenes),
};
mocks.listDocuments.mockRejectedValueOnce(new Error('document store unavailable'));
useMediaGenerationStore.setState({ tasks: { [assetId]: failedTask(assetId) } });
const replace = vi.spyOn(pool, 'replace');
serveImage('forked-bytes');
await retryMediaTask(assetId, {
elementId: 'image-1',
sceneId: 'scene-1',
slideId: 'slide-1',
});
const retriedRef = mocks.document.scenes[0].content.canvas.elements[0].src;
expect(retriedRef).toMatch(/^ast_/);
expect(retriedRef).not.toBe(assetId);
expect(await resolvedText(assetId)).toBe('original-bytes');
expect(await resolvedText(retriedRef)).toBe('forked-bytes');
expect(replace).not.toHaveBeenCalled();
});
it('replaces an allocated video and its referenced poster without rewriting the document', async () => {
const assetId = await pool.put(new Blob(['video-old'], { type: 'video/mp4' }));
const posterAssetId = await pool.put(new Blob(['poster-old'], { type: 'image/jpeg' }));
mocks.document = documentWithMedia(assetId, 'video');
mocks.document.scenes[0].content.canvas.elements[0].poster = posterAssetId;
mocks.stageState = {
stage: structuredClone(mocks.document.stage),
scenes: structuredClone(mocks.document.scenes),
};
const before = structuredClone(mocks.document);
serveVideo('video-replaced');
useMediaGenerationStore.setState({
tasks: { [assetId]: failedTask(assetId, 'video') },
});
await retryMediaTask(assetId, {
elementId: 'video-1',
sceneId: 'scene-1',
slideId: 'slide-1',
});
expect(await resolvedText(assetId)).toBe('video-replaced');
expect(await resolvedText(posterAssetId)).toBe('poster-new');
expect(mocks.document).toEqual(before);
expect(mocks.mediaPut).toHaveBeenCalledWith(
expect.objectContaining({ id: `${stageId}:${assetId}`, poster: expect.any(Blob) }),
);
await assertOwnership();
});
it('upgrades a legacy retry through the allocating path', async () => {
mocks.document = documentWithMedia(placeholder, 'image');
const put = vi.spyOn(pool, 'put');
const replace = vi.spyOn(pool, 'replace');
serveImage('legacy-upgraded');
useMediaGenerationStore.setState({ tasks: { [placeholder]: failedTask(placeholder) } });
mocks.mediaRows.set(`${stageId}:${placeholder}`, {
id: `${stageId}:${placeholder}`,
blob: new Blob(),
error: 'legacy failure',
});
await retryMediaTask(placeholder, {
elementId: 'image-1',
sceneId: 'scene-1',
slideId: 'slide-1',
});
const assetId = mocks.document.scenes[0].content.canvas.elements[0].src as string;
expect(assetId).toMatch(/^ast_/);
expect(put).toHaveBeenCalled();
expect(replace).not.toHaveBeenCalled();
expect(mocks.mediaDelete).toHaveBeenCalledWith(`${stageId}:${placeholder}`);
expect(await resolvedText(assetId)).toBe('legacy-upgraded');
await assertOwnership();
});
it('forks a duplicated media ref on targeted regeneration', async () => {
const sharedAssetId = await pool.put(new Blob(['original-bytes'], { type: 'image/png' }));
mocks.document = documentWithMedia(sharedAssetId, 'image');
const copy = structuredClone(mocks.document.scenes[0]);
copy.id = 'scene-copy';
copy.order = 2;
copy.content.canvas.id = 'slide-copy';
copy.content.canvas.elements[0].id = 'image-copy';
mocks.document.scenes.push(copy);
mocks.stageState = {
stage: structuredClone(mocks.document.stage),
scenes: structuredClone(mocks.document.scenes),
};
useMediaGenerationStore.setState({
tasks: { [sharedAssetId]: failedTask(sharedAssetId) },
});
const sourceStatuses: Array<MediaTask['status'] | undefined> = [];
const unsubscribe = useMediaGenerationStore.subscribe((state) => {
sourceStatuses.push(state.tasks[sharedAssetId]?.status);
});
serveImage('copy-bytes');
mocks.mediaRows.set(`${stageId}:${sharedAssetId}`, {
id: `${stageId}:${sharedAssetId}`,
blob: new Blob(['original-bytes'], { type: 'image/png' }),
});
await retryMediaTask(sharedAssetId, {
elementId: 'image-copy',
sceneId: 'scene-copy',
slideId: 'slide-copy',
});
unsubscribe();
const [originalScene, copiedScene] = mocks.document.scenes;
const originalRef = originalScene.content.canvas.elements[0].src;
const copiedRef = copiedScene.content.canvas.elements[0].src;
expect(originalRef).toBe(sharedAssetId);
expect(copiedRef).toMatch(/^ast_/);
expect(copiedRef).not.toBe(sharedAssetId);
expect(await resolvedText(originalRef)).toBe('original-bytes');
expect(await resolvedText(copiedRef)).toBe('copy-bytes');
expect(mocks.mediaRows.has(`${stageId}:${sharedAssetId}`)).toBe(true);
expect(useMediaGenerationStore.getState().tasks[copiedRef]?.placeholderRef).toBeUndefined();
expect(useMediaGenerationStore.getState().tasks[sharedAssetId]).toMatchObject({
status: 'failed',
error: 'retry me',
retryCount: 0,
});
expect(sourceStatuses.length).toBeGreaterThan(0);
expect(sourceStatuses.every((status) => status === 'failed')).toBe(true);
expect(mocks.beforeDocumentWork).toHaveBeenCalledTimes(1);
await assertOwnership();
});
it('removes a failed fork from both key spaces after its retry succeeds', async () => {
const sharedAssetId = await pool.put(new Blob(['original-bytes'], { type: 'image/png' }));
mocks.document = documentWithMedia(sharedAssetId, 'image');
const copy = structuredClone(mocks.document.scenes[0]);
copy.id = 'scene-copy';
copy.order = 2;
copy.content.canvas.id = 'slide-copy';
copy.content.canvas.elements[0].id = 'image-copy';
mocks.document.scenes.push(copy);
mocks.stageState = {
stage: structuredClone(mocks.document.stage),
scenes: structuredClone(mocks.document.scenes),
};
mocks.mediaRows.set(`${stageId}:${sharedAssetId}`, {
id: `${stageId}:${sharedAssetId}`,
blob: new Blob(['original-bytes'], { type: 'image/png' }),
});
useMediaGenerationStore.setState({ tasks: { [sharedAssetId]: doneTask(sharedAssetId) } });
fetchMock.mockResolvedValueOnce(
new Response(
JSON.stringify({
success: false,
error: 'fork generation failed',
errorCode: 'UPSTREAM_TIMEOUT',
}),
{ headers: { 'Content-Type': 'application/json' } },
),
);
const target = {
elementId: 'image-copy',
sceneId: 'scene-copy',
slideId: 'slide-copy',
};
await retryMediaTask(sharedAssetId, target);
expect(mocks.mediaRows.get(`${stageId}:image-copy`)).toMatchObject({
error: 'fork generation failed',
});
expect(useMediaGenerationStore.getState().tasks['image-copy']?.status).toBe('failed');
serveImage('recovered-fork');
await retryMediaTask(sharedAssetId, target);
const recoveredRef = mocks.document.scenes[1].content.canvas.elements[0].src;
expect(recoveredRef).toMatch(/^ast_/);
expect(mocks.mediaRows.has(`${stageId}:image-copy`)).toBe(false);
expect(useMediaGenerationStore.getState().tasks['image-copy']).toBeUndefined();
expect(useMediaGenerationStore.getState().tasks[recoveredRef]).toMatchObject({
elementId: recoveredRef,
status: 'done',
});
const restored = buildRestoredMediaTasks(stageId, [
...mocks.mediaRows.values(),
] as unknown as MediaFileRecord[]);
expect(restored['image-copy']).toBeUndefined();
expect(restored[recoveredRef]?.status).toBe('done');
const reloaded = resolveSlideMediaState(
mocks.document.scenes[1].content.canvas as unknown as Slide,
stageId,
restored,
);
expect(reloaded.byElementId['image-copy']).toMatchObject({
task: { elementId: recoveredRef, status: 'done' },
resolution: { kind: 'url' },
});
});
it('restores a fork failure after its retry fails without an error code', async () => {
const sharedAssetId = await pool.put(new Blob(['original-bytes'], { type: 'image/png' }));
mocks.document = documentWithMedia(sharedAssetId, 'image');
const copy = structuredClone(mocks.document.scenes[0]);
copy.id = 'scene-copy';
copy.order = 2;
copy.content.canvas.id = 'slide-copy';
copy.content.canvas.elements[0].id = 'image-copy';
mocks.document.scenes.push(copy);
mocks.stageState = {
stage: structuredClone(mocks.document.stage),
scenes: structuredClone(mocks.document.scenes),
};
mocks.mediaRows.set(`${stageId}:${sharedAssetId}`, {
id: `${stageId}:${sharedAssetId}`,
blob: new Blob(['original-bytes'], { type: 'image/png' }),
});
useMediaGenerationStore.setState({ tasks: { [sharedAssetId]: doneTask(sharedAssetId) } });
fetchMock.mockResolvedValueOnce(
new Response(
JSON.stringify({
success: false,
error: 'fork generation failed',
errorCode: 'UPSTREAM_TIMEOUT',
}),
{ headers: { 'Content-Type': 'application/json' } },
),
);
const target = {
elementId: 'image-copy',
sceneId: 'scene-copy',
slideId: 'slide-copy',
};
await retryMediaTask(sharedAssetId, target);
expect(mocks.mediaRows.get(`${stageId}:image-copy`)).toMatchObject({
error: 'fork generation failed',
errorCode: 'UPSTREAM_TIMEOUT',
});
fetchMock.mockRejectedValueOnce(new Error('unstructured retry failure'));
await retryMediaTask(sharedAssetId, target);
const restored = buildRestoredMediaTasks(stageId, [
...mocks.mediaRows.values(),
] as unknown as MediaFileRecord[]);
expect(restored['image-copy']).toMatchObject({
status: 'failed',
error: 'fork generation failed',
errorCode: 'UPSTREAM_TIMEOUT',
});
const reloaded = resolveSlideMediaState(
mocks.document.scenes[1].content.canvas as unknown as Slide,
stageId,
restored,
{ assetUrls: { [sharedAssetId]: 'blob:source-pool' } },
);
expect(reloaded.byElementId['image-copy']).toMatchObject({
task: { elementId: 'image-copy', status: 'failed', error: 'fork generation failed' },
resolution: { kind: 'url', url: 'blob:source-pool', retryable: true },
});
});
it('scopes a shared retry to the scene and slide when element ids recur', async () => {
const sharedAssetId = await pool.put(new Blob(['original-bytes'], { type: 'image/png' }));
mocks.document = documentWithMedia(sharedAssetId, 'image');
mocks.document.scenes[0].content.canvas.elements[0].id = 'repeated-image';
const copy = structuredClone(mocks.document.scenes[0]);
copy.id = 'scene-copy';
copy.order = 2;
copy.content.canvas.id = 'slide-copy';
mocks.document.scenes.push(copy);
mocks.stageState = {
stage: structuredClone(mocks.document.stage),
scenes: structuredClone(mocks.document.scenes),
};
mocks.mediaRows.set(`${stageId}:${sharedAssetId}`, {
id: `${stageId}:${sharedAssetId}`,
blob: new Blob(['original-bytes'], { type: 'image/png' }),
});
useMediaGenerationStore.setState({ tasks: { [sharedAssetId]: doneTask(sharedAssetId) } });
serveImage('scoped-copy');
await retryMediaTask(sharedAssetId, {
elementId: 'repeated-image',
sceneId: 'scene-copy',
slideId: 'slide-copy',
});
expect(mocks.document.scenes[0].content.canvas.elements[0].src).toBe(sharedAssetId);
expect(mocks.document.scenes[1].content.canvas.elements[0].src).not.toBe(sharedAssetId);
expect(useMediaGenerationStore.getState().tasks[sharedAssetId]).toMatchObject({
status: 'done',
objectUrl: 'blob:source',
retryCount: 0,
});
await assertOwnership();
});
it('keeps sharers on source bytes while a targeted fork is generating', async () => {
const sharedAssetId = await pool.put(new Blob(['original-bytes'], { type: 'image/png' }));
mocks.document = documentWithMedia(sharedAssetId, 'image');
const copy = structuredClone(mocks.document.scenes[0]);
copy.id = 'scene-copy';
copy.order = 2;
copy.content.canvas.id = 'slide-copy';
copy.content.canvas.elements[0].id = 'image-copy';
mocks.document.scenes.push(copy);
mocks.stageState = {
stage: structuredClone(mocks.document.stage),
scenes: structuredClone(mocks.document.scenes),
};
useMediaGenerationStore.setState({ tasks: { [sharedAssetId]: doneTask(sharedAssetId) } });
let finishGeneration!: (response: Response) => void;
const generation = new Promise<Response>((resolve) => {
finishGeneration = resolve;
});
fetchMock.mockImplementation(async (input: string) => {
if (input === '/api/generate/image') return generation;
if (input === '/api/proxy-media') {
return new Response(new Blob(['fork-bytes'], { type: 'image/png' }), { status: 200 });
}
throw new Error(`Unexpected fetch: ${input}`);
});
const retrying = retryMediaTask(sharedAssetId, {
elementId: 'image-copy',
sceneId: 'scene-copy',
slideId: 'slide-copy',
});
await vi.waitFor(() => {
expect(useMediaGenerationStore.getState().tasks['image-copy']?.status).toBe('generating');
});
const tasks = useMediaGenerationStore.getState().tasks;
const source = resolveSlideMediaState(
mocks.document.scenes[0].content.canvas as unknown as Slide,
stageId,
tasks,
{ assetUrls: { [sharedAssetId]: 'blob:source-pool' } },
);
const target = resolveSlideMediaState(
mocks.document.scenes[1].content.canvas as unknown as Slide,
stageId,
tasks,
{ assetUrls: { [sharedAssetId]: 'blob:source-pool' } },
);
expect(tasks[sharedAssetId]).toMatchObject({ status: 'done', objectUrl: 'blob:source' });
expect(source.byElementId['image-1'].resolution).toEqual({
kind: 'url',
url: 'blob:source-pool',
});
expect(source.slide.elements[0]).toMatchObject({ src: 'blob:source-pool' });
expect(target.byElementId['image-copy'].resolution).toEqual({ kind: 'pending' });
expect(target.slide.elements[0]).toMatchObject({ src: '' });
finishGeneration(
new Response(
JSON.stringify({
success: true,
result: { url: 'https://media.test/image', width: 1024, height: 576 },
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
);
await retrying;
expect(useMediaGenerationStore.getState().tasks[sharedAssetId]).toMatchObject({
status: 'done',
objectUrl: 'blob:source',
retryCount: 0,
});
});
it('forks a shared ref owned by a scene whiteboard despite the scene slide id', async () => {
const sharedAssetId = await pool.put(new Blob(['original-bytes'], { type: 'image/png' }));
mocks.document = documentWithMedia(sharedAssetId, 'image');
const scene = mocks.document.scenes[0] as (typeof mocks.document.scenes)[number] & {
whiteboards: Array<(typeof mocks.document.scenes)[number]['content']['canvas']>;
};
scene.whiteboards = [
{
...structuredClone(mocks.document.scenes[0].content.canvas),
id: 'scene-whiteboard',
elements: [
{
...structuredClone(mocks.document.scenes[0].content.canvas.elements[0]),
id: 'whiteboard-image',
type: 'image',
src: sharedAssetId,
},
],
},
];
mocks.stageState = {
stage: structuredClone(mocks.document.stage),
scenes: structuredClone(mocks.document.scenes),
};
mocks.mediaRows.set(`${stageId}:${sharedAssetId}`, {
id: `${stageId}:${sharedAssetId}`,
blob: new Blob(['original-bytes'], { type: 'image/png' }),
});
useMediaGenerationStore.setState({ tasks: { [sharedAssetId]: failedTask(sharedAssetId) } });
serveImage('whiteboard-copy');
await retryMediaTask(sharedAssetId, {
elementId: 'whiteboard-image',
sceneId: 'scene-1',
slideId: 'slide-1',
});
expect(mocks.document.scenes[0].content.canvas.elements[0].src).toBe(sharedAssetId);
const savedScene = mocks.document.scenes[0] as typeof scene;
expect(savedScene.whiteboards[0].elements[0].src).not.toBe(sharedAssetId);
expect(useMediaGenerationStore.getState().tasks[sharedAssetId]?.status).toBe('failed');
});
it('forks a shared ref owned by the stage whiteboard from a scene context', async () => {
const sharedAssetId = await pool.put(new Blob(['original-bytes'], { type: 'image/png' }));
mocks.document = documentWithMedia(sharedAssetId, 'image');
const stage = mocks.document.stage as typeof mocks.document.stage & {
whiteboard: Array<(typeof mocks.document.scenes)[number]['content']['canvas']>;
};
stage.whiteboard = [
{
...structuredClone(mocks.document.scenes[0].content.canvas),
id: 'stage-whiteboard',
elements: [
{
...structuredClone(mocks.document.scenes[0].content.canvas.elements[0]),
id: 'stage-whiteboard-image',
type: 'image',
src: sharedAssetId,
},
],
},
];
mocks.stageState = {
stage: structuredClone(mocks.document.stage),
scenes: structuredClone(mocks.document.scenes),
};
mocks.mediaRows.set(`${stageId}:${sharedAssetId}`, {
id: `${stageId}:${sharedAssetId}`,
blob: new Blob(['original-bytes'], { type: 'image/png' }),
});
useMediaGenerationStore.setState({ tasks: { [sharedAssetId]: failedTask(sharedAssetId) } });
serveImage('stage-whiteboard-copy');
await retryMediaTask(sharedAssetId, {
elementId: 'stage-whiteboard-image',
sceneId: 'scene-1',
slideId: 'stage-whiteboard',
});
expect(mocks.document.scenes[0].content.canvas.elements[0].src).toBe(sharedAssetId);
const savedStage = mocks.document.stage as typeof stage;
expect(savedStage.whiteboard[0].elements[0].src).not.toBe(sharedAssetId);
expect(useMediaGenerationStore.getState().tasks[sharedAssetId]?.status).toBe('failed');
});
it('does not fall back to a stage-whiteboard element id when the target slide misses', async () => {
const sharedAssetId = await pool.put(new Blob(['original-bytes'], { type: 'image/png' }));
mocks.document = documentWithMedia(sharedAssetId, 'image');
const stage = mocks.document.stage as typeof mocks.document.stage & {
whiteboard: Array<(typeof mocks.document.scenes)[number]['content']['canvas']>;
};
stage.whiteboard = [
{
...structuredClone(mocks.document.scenes[0].content.canvas),
id: 'stage-whiteboard',
elements: [
{
...structuredClone(mocks.document.scenes[0].content.canvas.elements[0]),
id: 'stage-whiteboard-image',
type: 'image',
src: sharedAssetId,
},
],
},
];
mocks.stageState = {
stage: structuredClone(mocks.document.stage),
scenes: structuredClone(mocks.document.scenes),
};
useMediaGenerationStore.setState({ tasks: { [sharedAssetId]: failedTask(sharedAssetId) } });
serveImage('must-not-be-attached');
await retryMediaTask(sharedAssetId, {
elementId: 'stage-whiteboard-image',
sceneId: 'missing-scene',
slideId: 'missing-slide',
});
expect(stage.whiteboard[0].elements[0].src).toBe(sharedAssetId);
expect(useMediaGenerationStore.getState().tasks[sharedAssetId]?.status).toBe('failed');
expect(useMediaGenerationStore.getState().tasks['stage-whiteboard-image']).toMatchObject({
status: 'failed',
errorCode: 'TARGET_MISSING',
});
});
it('builds a safe retry target for a non-slide scene', () => {
expect(mediaRetryTarget('image-1', 'scene-1', { type: 'text', markdown: 'Notes' })).toEqual({
elementId: 'image-1',
sceneId: 'scene-1',
});
});
it('refuses to fork a shared ref without the owning slide instance', async () => {
const sharedAssetId = await pool.put(new Blob(['original-bytes'], { type: 'image/png' }));
mocks.document = documentWithMedia(sharedAssetId, 'image');
const copy = structuredClone(mocks.document.scenes[0]);
copy.id = 'scene-copy';
copy.order = 2;
copy.content.canvas.id = 'slide-copy';
mocks.document.scenes.push(copy);
mocks.stageState = {
stage: structuredClone(mocks.document.stage),
scenes: structuredClone(mocks.document.scenes),
};
mocks.mediaRows.set(`${stageId}:${sharedAssetId}`, {
id: `${stageId}:${sharedAssetId}`,
blob: new Blob(['original-bytes'], { type: 'image/png' }),
});
useMediaGenerationStore.setState({ tasks: { [sharedAssetId]: failedTask(sharedAssetId) } });
await retryMediaTask(sharedAssetId, {
elementId: mocks.document.scenes[0].content.canvas.elements[0].id,
sceneId: mocks.document.scenes[0].id,
});
expect(fetchMock).not.toHaveBeenCalled();
expect(mocks.document.scenes[0].content.canvas.elements[0].src).toBe(sharedAssetId);
expect(mocks.document.scenes[1].content.canvas.elements[0].src).toBe(sharedAssetId);
await assertOwnership();
});
it('rollback layer 4: protects first-commit bytes when later reconciliation fails', async () => {
mocks.document = documentWithMedia(placeholder, 'image');
serveImage('committed-before-reconciliation-failure');
let documentWrites = 0;
mocks.beforeDocumentWork.mockImplementation(() => {
documentWrites += 1;
if (documentWrites === 2) throw new Error('second reconciliation failed');
});
await generateMediaForOutlines(
[
{
id: 'outline-1',
type: 'slide',
title: 'Scene',
description: 'Scene',
keyPoints: ['image'],
order: 1,
mediaGenerations: [{ type: 'image', prompt: 'A new image', elementId: placeholder }],
},
],
stageId,
);
const committedRef = mocks.document.scenes[0].content.canvas.elements[0].src;
expect(committedRef).toMatch(/^ast_/);
expect(await resolvedText(committedRef)).toBe('committed-before-reconciliation-failure');
await assertOwnership();
});
it('protects document-committed refs when active-stage reconciliation throws', async () => {
mocks.document = documentWithMedia(placeholder, 'image');
mocks.stageState = {
stage: structuredClone(mocks.document.stage),
scenes: structuredClone(mocks.document.scenes),
};
mocks.stageSetState.mockImplementationOnce(() => {
throw new Error('active-stage reconciliation failed');
});
serveImage('committed-before-active-stage-failure');
await generateMediaForOutlines(
[
{
id: 'outline-1',
type: 'slide',
title: 'Scene',
description: 'Scene',
keyPoints: ['image'],
order: 1,
mediaGenerations: [{ type: 'image', prompt: 'A new image', elementId: placeholder }],
},
],
stageId,
);
const committedRef = mocks.document.scenes[0].content.canvas.elements[0].src;
expect(mocks.stageSetState).toHaveBeenCalledOnce();
expect(committedRef).toMatch(/^ast_/);
expect(await resolvedText(committedRef)).toBe('committed-before-active-stage-failure');
expect(mocks.mediaRows.has(`${stageId}:${committedRef}`)).toBe(true);
});
it('rollback layer 5: final rewrite failure leaves no task that can resurrect removed bytes', async () => {
const pendingDocument = documentWithMedia(placeholder, 'image');
const lateScene = structuredClone(pendingDocument.scenes[0]);
pendingDocument.scenes = [];
mocks.document = pendingDocument;
serveImage('removed-after-final-rewrite-failure');
const put = vi.spyOn(pool, 'put');
let documentWrites = 0;
mocks.beforeDocumentWork.mockImplementation(() => {
documentWrites += 1;
if (documentWrites === 2) throw new Error('second reconciliation failed');
});
await generateMediaForOutlines(
[
{
id: 'outline-1',
type: 'slide',
title: 'Scene',
description: 'Scene',
keyPoints: ['image'],
order: 1,
mediaGenerations: [{ type: 'image', prompt: 'A new image', elementId: placeholder }],
},
],
stageId,
);
const [removedRef] = await Promise.all(put.mock.results.map((result) => result.value));
expect(await pool.resolve(removedRef)).toBeNull();
expect(mocks.mediaRows.has(`${stageId}:${removedRef}`)).toBe(false);
expect(useMediaGenerationStore.getState().tasks).toEqual({
[placeholder]: expect.objectContaining({
elementId: placeholder,
status: 'failed',
error: 'second reconciliation failed',
}),
});
const reconciled = reconcileCompletedMediaForScene(
lateScene as unknown as Scene,
pendingDocument.stage as unknown as Stage,
useMediaGenerationStore.getState().tasks,
);
expect(reconciled.scene.content.type).toBe('slide');
if (reconciled.scene.content.type !== 'slide') throw new Error('Expected a slide scene');
const restoredElement = reconciled.scene.content.canvas.elements[0];
expect(restoredElement.type).toBe('image');
if (restoredElement.type !== 'image') throw new Error('Expected an image element');
expect(restoredElement.src).toBe(placeholder);
});
it('marks the targeted fork failed without mutating its shared source task', async () => {
const sharedAssetId = await pool.put(new Blob(['original-bytes'], { type: 'image/png' }));
mocks.document = documentWithMedia(sharedAssetId, 'image');
const copy = structuredClone(mocks.document.scenes[0]);
copy.id = 'scene-copy';
copy.order = 2;
copy.content.canvas.id = 'slide-copy';
copy.content.canvas.elements[0].id = 'image-copy';
mocks.document.scenes.push(copy);
mocks.stageState = {
stage: structuredClone(mocks.document.stage),
scenes: structuredClone(mocks.document.scenes),
};
useMediaGenerationStore.setState({ tasks: { [sharedAssetId]: doneTask(sharedAssetId) } });
const sourceStatuses: Array<MediaTask['status'] | undefined> = [];
const unsubscribe = useMediaGenerationStore.subscribe((state) => {
sourceStatuses.push(state.tasks[sharedAssetId]?.status);
});
fetchMock.mockRejectedValue(new Error('fork generation failed'));
await retryMediaTask(sharedAssetId, {
elementId: 'image-copy',
sceneId: 'scene-copy',
slideId: 'slide-copy',
});
unsubscribe();
expect(useMediaGenerationStore.getState().tasks).toEqual({
[sharedAssetId]: expect.objectContaining({
elementId: sharedAssetId,
status: 'done',
objectUrl: 'blob:source',
retryCount: 0,
}),
'image-copy': expect.objectContaining({
elementId: 'image-copy',
status: 'failed',
error: 'fork generation failed',
retryCount: 1,
}),
});
expect(sourceStatuses.length).toBeGreaterThan(0);
expect(sourceStatuses.every((status) => status === 'done')).toBe(true);
const failedTarget = resolveSlideMediaState(
mocks.document.scenes[1].content.canvas as unknown as Slide,
stageId,
useMediaGenerationStore.getState().tasks,
{ assetUrls: { [sharedAssetId]: 'blob:source-pool' } },
);
expect(failedTarget.byElementId['image-copy']).toMatchObject({
task: { elementId: 'image-copy', status: 'failed', error: 'fork generation failed' },
resolution: { kind: 'url', url: 'blob:source-pool', retryable: true },
});
expect(mocks.document.scenes[0].content.canvas.elements[0].src).toBe(sharedAssetId);
expect(mocks.document.scenes[1].content.canvas.elements[0].src).toBe(sharedAssetId);
});
it('falls back to task-based resume filtering when legacy document access is locked', async () => {
mocks.document = documentWithMedia(placeholder, 'image');
mocks.accessDocument.mockRejectedValueOnce(new Error('legacy lock unavailable'));
serveImage('generated-after-read-failure');
await generateMediaForOutlines(
[
{
id: 'outline-1',
type: 'slide',
title: 'Scene',
description: 'Scene',
keyPoints: ['image'],
order: 1,
mediaGenerations: [{ type: 'image', prompt: 'A new image', elementId: placeholder }],
},
],
stageId,
);
expect(fetchMock).toHaveBeenCalledWith('/api/generate/image', expect.any(Object));
expect(mocks.document.scenes[0].content.canvas.elements[0].src).toMatch(/^ast_/);
await assertOwnership();
});
it('persists a retryable failure when pool replacement succeeds before Dexie fails', async () => {
const assetId = await pool.put(new Blob(['image-old'], { type: 'image/png' }));
mocks.document = documentWithMedia(assetId, 'image');
useMediaGenerationStore.setState({ tasks: { [assetId]: failedTask(assetId) } });
serveImage('pool-new-dexie-missed');
mocks.mediaPut.mockRejectedValueOnce(new Error('Dexie write failed'));
mocks.mediaGet.mockResolvedValue({
id: `${stageId}:${assetId}`,
stageId,
type: 'image',
blob: new Blob(['image-old'], { type: 'image/png' }),
mimeType: 'image/png',
size: 9,
prompt: 'A new image',
params: '{}',
createdAt: 1,
});
const target = {
elementId: 'image-1',
sceneId: 'scene-1',
slideId: 'slide-1',
};
await retryMediaTask(assetId, target);
expect(await resolvedText(assetId)).toBe('pool-new-dexie-missed');
expect(useMediaGenerationStore.getState().tasks[assetId]).toMatchObject({
status: 'failed',
errorCode: 'MEDIA_COMPATIBILITY_STORE_LAGGED',
error: expect.stringContaining('compatibility media store lagged'),
});
expect(mocks.mediaPut).not.toHaveBeenCalledWith(
expect.objectContaining({ errorCode: 'MEDIA_COMPATIBILITY_STORE_LAGGED' }),
);
serveImage('stores-converged');
await retryMediaTask(assetId, target);
expect(await resolvedText(assetId)).toBe('stores-converged');
expect(useMediaGenerationStore.getState().tasks[assetId]?.status).toBe('done');
expect(mocks.mediaPut).toHaveBeenLastCalledWith(
expect.objectContaining({ id: `${stageId}:${assetId}`, blob: expect.any(Blob) }),
);
});
it('drops a document rewrite fenced by a stage deletion epoch change', async () => {
mocks.document = documentWithMedia(placeholder, 'image');
const before = structuredClone(mocks.document);
serveImage();
mocks.beforeDocumentWork.mockImplementationOnce(() => markStageDeleted(stageId));
try {
await generateMediaForOutlines(
[
{
id: 'outline-1',
type: 'slide',
title: 'Scene',
description: 'Scene',
keyPoints: ['image'],
order: 1,
mediaGenerations: [{ type: 'image', prompt: 'A new image', elementId: placeholder }],
},
],
stageId,
);
expect(mocks.document).toEqual(before);
} finally {
unmarkStageDeleted(stageId);
}
});
});