Files
ttstd-infinite-canvas/tools/test-gen-asset-browser.cjs
2026-10-06 09:24:06 +08:00

265 lines
19 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Go 完成交易的 JSON 从 stdin 进入;只替换 API、媒体读取与浏览器存储边界。
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { createRequire } = require('node:module');
const root = path.resolve(__dirname, '..');
const web = createRequire(path.join(root, 'web/package.json'));
const { transformSync } = web('rolldown/experimental');
const babel = web('@babel/core');
const input = JSON.parse(fs.readFileSync(0, 'utf8'));
const durable = new Map(), local = new Map(), tails = new Map();
const storage = {
config() {},
async getItem(key) { return durable.get(key) ?? null; },
async setItem(key, value) { durable.set(key, value); return value; },
async removeItem(key) { durable.delete(key); },
createInstance() { return storage; },
async keys() { return [...durable.keys()]; },
};
global.window = {
localStorage: { getItem: key => local.get(key) ?? null, setItem: (key, value) => local.set(key, value), removeItem: key => local.delete(key) },
setTimeout, clearTimeout, addEventListener() {}, removeEventListener() {},
};
Object.defineProperty(global, 'navigator', { configurable: true, value: { locks: {
async request(key, callback) {
const previous = tails.get(key) ?? Promise.resolve();
let release;
const next = new Promise(resolve => { release = resolve; });
tails.set(key, previous.then(() => next));
await previous;
try { return await callback(); } finally { release(); }
},
} } });
global.fetch = async () => { throw Error('Unexpected network request'); };
const remoteAssets = new Map((input.remoteAssets || [input.asset]).map(asset => [asset.id, asset]));
if (input.scenario === 'remote-absent') remoteAssets.clear();
const uploadedAssets = [];
let duringAssetGet;
let remoteProject;
class ApiError extends Error {
constructor(message, options = {}) { super(message); Object.assign(this, options); }
}
const mocks = {
localforage: { __esModule: true, default: storage },
'@/services/api/request': { ApiError, apiBaseURL: '/api', http: new Proxy({}, { get: (_, name) => () => { throw Error(`Unexpected HTTP ${name}`); } }) },
'@/services/api/user-data': {
getRemoteAsset: async id => {
if (duringAssetGet) {
const edit = duringAssetGet;
duringAssetGet = undefined;
await edit();
}
if (input.scenario === 'remote-unavailable') throw new ApiError('Unavailable', { status: 503 });
if (!remoteAssets.has(id)) throw new ApiError('Not found', { status: 404 });
return { asset: structuredClone(remoteAssets.get(id)) };
},
getRemoteAssetsByIds: async ids => ({ assets: ids.flatMap(id => remoteAssets.has(id) ? [remoteAssets.get(id)] : []) }),
getRemoteCanvasProject: async () => ({ project: structuredClone(remoteProject) }),
upsertRemoteAsset: async asset => {
uploadedAssets.push(structuredClone(asset));
remoteAssets.set(asset.id, structuredClone(asset));
return { asset };
},
upsertRemoteCanvasProject: async project => {
remoteProject = { ...project, revision: (project.revision || 0) + 1 };
return { project: remoteProject };
},
},
'@/services/image-storage': { resolveImageUrl: async (key) => `https://display.example/${key}?signature=first` },
'@/services/file-storage': { resolveMediaUrl: async (_key, url) => url },
'@/services/resource-blob-cache': { getCachedResourceBlob: async () => new Blob(['fixture']) },
};
const modules = new Map();
function load(file) {
if (modules.has(file)) return modules.get(file).exports;
const module = { exports: {} };
modules.set(file, module);
let source = fs.readFileSync(file, 'utf8');
const mutations = {
adoption: ['/services/project-asset-sync.ts', 'if (!asset && options.node.metadata?.assetId)', 'if (false)'],
managed: ['/services/project-asset-sync.ts', 'if ((options.taskId || options.node.metadata?.taskId) && options.node.metadata?.storageKey?.startsWith("resource:"))', 'if (false)'],
lookup: ['/services/user-data-sync.ts', 'if (!baseline) {\n // 空缓存', 'if (false) {\n // 空缓存'],
skipPut: ['/services/user-data-sync.ts', 'if (adoptedAssetIds.has(dirty.id)) continue;', ''],
lookupFailure: ['/services/user-data-sync.ts', 'if (error instanceof ApiError && error.status === 404) continue;', 'if (error instanceof ApiError) continue;'],
repair: ['/services/canvas-asset-repair.ts', 'if (node.metadata?.taskId && node.metadata.storageKey?.startsWith("resource:")) return undefined;', ''],
dropLocal: ['/services/user-data-sync.ts', 'const mergedContent = mergeThreeWayValue(content(source), defaults ? content(defaults) : undefined, remoteContent) as Asset;', 'const mergedContent = remoteContent;'],
dropRemote: ['/services/user-data-sync.ts', 'const mergedContent = mergeThreeWayValue(content(source), defaults ? content(defaults) : undefined, remoteContent) as Asset;', 'const mergedContent = content(source);'],
ignoreConflict: ['/services/user-data-sync.ts', 'throw assetRemoteVersionConflict();', 'merged = asset;'],
retryDropsLocal: ['/services/user-data-sync.ts', 'const mergedContent = mergeThreeWayValue(content(source), defaults ? content(defaults) : undefined, remoteContent) as Asset;', 'const mergedContent = remoteContent;'],
categoryDefault: ['/services/project-asset-sync.ts', 'category: "material",', ''],
stableMedia: ['/services/user-data-sync.ts', 'if (resourceId) {', 'if (false) {'],
clientMetadata: ['/services/user-data-sync.ts', 'delete metadata[key];', 'void metadata[key];'],
storedMetadata: ['/services/user-data-sync.ts', 'merged = applyEdits(asset, source, remoteContent, mergedContent) as Asset;', 'merged = mergedContent;'],
localContext: ['/services/user-data-sync.ts', 'metadata[key] = source.metadata[key];', 'void source.metadata[key];'],
staleGet: ['/services/user-data-sync.ts', 'if (!sameEntitySnapshot(current, source)) throw new Error("素材仍在编辑,请重新同步");', ''],
};
const mutation = mutations[process.env.CANVAS_TEST_GENERATION_GUARD_MUTATION];
if (mutation && file.endsWith(mutation[0])) {
assert(source.includes(mutation[1]), `Mutation target missing: ${mutation[0]}`);
source = source.replace(mutation[1], mutation[2]);
}
// 只用于负对照:改变前端身份,证明真实双写会被数据库笔数断言发现。
if (process.env.CANVAS_TEST_GENERATION_ID_MUTATION === '1' && file.endsWith('/stores/use-asset-store.ts')) {
assert(source.includes('new TextEncoder().encode(effectKey)'));
source = source.replace('new TextEncoder().encode(effectKey)', 'new TextEncoder().encode("different:" + effectKey)');
}
const js = transformSync(file, source, { jsx: { runtime: 'automatic' } }).code;
const code = babel.transformSync(js, { configFile: false, babelrc: false, plugins: [web('@babel/plugin-transform-modules-commonjs')] }).code;
function req(name) {
if (name in mocks) return mocks[name];
if (name.startsWith('@/') || name.startsWith('.')) {
const base = name.startsWith('@/') ? path.join(root, 'web/src', name.slice(2)) : path.resolve(path.dirname(file), name);
const resolved = ['', '.ts', '.tsx', '/index.ts'].map(ext => base + ext).find(candidate => fs.existsSync(candidate) && fs.statSync(candidate).isFile());
if (!resolved) throw Error(`Cannot resolve ${name} from ${file}`);
// 延迟读取无关模块,实际调用的业务函数仍执行原始源码。
return new Proxy({}, { get: (_, key) => key === '__esModule' ? true : load(resolved)[key] });
}
return web(name);
}
new Function('require', 'module', 'exports', code)(req, module, module.exports);
return module.exports;
}
const src = file => load(path.join(root, 'web/src', file));
async function run() {
src('lib/user-scope.ts').setActiveUserScope('media-user');
const assets = src('stores/use-asset-store.ts');
const canvases = src('stores/canvas/use-canvas-store.ts');
await Promise.all([assets.useAssetStore.persist.rehydrate(), canvases.useCanvasStore.persist.rehydrate()]);
const pipeline = src('services/project-asset-sync.ts');
const sync = src('services/user-data-sync.ts');
const repair = src('services/canvas-asset-repair.ts');
input.task.clientContext = { ...input.task.clientContext, conversationId: 'conversation', messageId: 'message', batchIndex: 0 };
const node = input.project?.nodes[0] || { id: 'image-node', type: input.asset.kind, title: '图片', position: { x: 0, y: 0 }, width: 320, height: 240, metadata: { taskId: input.task.id, status: 'loading' } };
if (input.scenario === 'agent-without-id') delete node.metadata.assetId;
remoteProject = input.project || { id: 'image-canvas', title: '画布', revision: 1, createdAt: input.task.createdAt, updatedAt: input.task.createdAt, nodes: [node], connections: [], chatSessions: [], activeChatId: null, viewport: { x: 0, y: 0, k: 1 }, directorScenes: [] };
if (input.scenario === 'open') {
canvases.useCanvasStore.setState({ projects: [structuredClone(remoteProject)] });
await canvases.flushCanvasStorePersistence();
}
await sync.initializeRemoteUserDataSession('media-user');
if (input.scenario === 'agent-live') await sync.refreshCanvasAfterAgent(remoteProject.id);
else if (input.scenario !== 'open') await sync.loadCanvasProjectForEditing(remoteProject.id);
if (input.scenario === 'reopen-synced') await sync.loadAssetsForUse([input.asset.id]);
// Agent 已完成节点直接自动保存,不先运行 loading 节点的 materializer。
let repairCreated = input.project ? 0 : repair.repairMissingCanvasAssets(undefined, true).createdAssets;
const nodesRef = { current: canvases.useCanvasStore.getState().projects[0].nodes };
const setNodes = update => { nodesRef.current = typeof update === 'function' ? update(nodesRef.current) : update; };
const consume = async task => pipeline.consumeGenerationTaskNode(task, node.id, 0, args => src('services/canvas-generation-consumer.ts').applyCanvasGenerationTaskNodeEffect({
projectId: remoteProject.id, nodeId: node.id, ...args, nodesRef, setNodes,
}));
if (!input.project) await consume(input.task);
if (input.scenario.startsWith('edit-')) {
// consume 已走真实 addGenerationAsset;仅在 API / storage 边界制造交错。
const id = assets.useAssetStore.getState().assets[0].id;
const generated = assets.useAssetStore.getState().assets[0];
assert.equal(generated.title, input.asset.title, 'backend/frontend title defaults must agree');
assert.equal(generated.category, 'material', 'generated category must be material');
assert.equal(generated.source, input.asset.source, 'backend/frontend source defaults must agree');
const localPatch = input.scenario === 'edit-category' ? { category: 'other' }
: input.scenario === 'edit-source' ? { source: '我的來源' }
: { title: '使用者剛改的標題', tags: ['保留我的編輯'] };
localPatch.metadata = { ...generated.metadata, clientContext: { messageId: 'local-context-message', conversationId: 'local-context-conversation' } };
if (input.scenario === 'edit-display-url') {
localPatch.data = { ...generated.data, dataUrl: 'https://display.example/renewed?signature=second' };
localPatch.coverUrl = localPatch.data.dataUrl;
}
if (input.scenario === 'edit-metadata') localPatch.metadata = { ...generated.metadata, conversationId: 'renewed', clientContext: { messageId: 'new-message' } };
const remoteBefore = structuredClone(remoteAssets.get(id));
if (input.scenario === 'edit-display-url') {
remoteBefore.data.dataUrl = 'https://display.example/remote?signature=stored';
remoteBefore.coverUrl = remoteBefore.data.dataUrl;
remoteAssets.set(id, structuredClone(remoteBefore));
}
const edit = () => assets.useAssetStore.getState().updateAsset(id, localPatch);
if (input.scenario === 'edit-during-get') {
duringAssetGet = async () => {
edit();
// GET 不能持有 persist lock,否则此处会死锁。
await assets.flushAssetStorePersistence();
};
await assert.rejects(sync.saveRemoteUserDataNow(), /素材仍在编辑/, 'GET edit must stop stale adoption');
assert.equal(uploadedAssets.length, 0);
} else {
edit();
}
if (input.scenario === 'edit-disjoint') {
assets.useAssetStore.getState().updateAsset(id, { tags: ['生成'], coverUrl: 'https://example.test/custom-cover.png' });
remoteBefore.tags = ['遠端標籤'];
remoteBefore.metadata.projectIds = ['remote-project'];
remoteAssets.set(id, structuredClone(remoteBefore));
}
if (input.scenario.endsWith('conflict')) {
if (input.scenario === 'edit-resource-conflict') {
assets.useAssetStore.getState().updateAsset(id, { data: { ...generated.data, storageKey: 'resource:local-replacement' } });
remoteBefore.data.storageKey = 'resource:remote-replacement';
} else remoteBefore.title = '遠端改過的標題';
remoteAssets.set(id, structuredClone(remoteBefore));
for (let retry = 0; retry < 2; retry++) {
await assert.rejects(sync.saveRemoteUserDataNow(), /素材远端版本已变化/, 'both edited must surface the normal asset conflict');
assert.equal(assets.useAssetStore.getState().assets[0].title, localPatch.title, 'conflict must preserve local edits');
assert.deepEqual(remoteAssets.get(id), remoteBefore, 'conflict must preserve remote edits');
assert.equal(uploadedAssets.length, 0, 'conflict must not PUT');
}
} else {
const editedAt = assets.useAssetStore.getState().assets[0].updatedAt;
await sync.saveRemoteUserDataNow();
const localAsset = assets.useAssetStore.getState().assets[0];
assert.equal(localAsset.title, localPatch.title ?? generated.title, 'local edit must survive sync and retry');
assert.equal(localAsset.category, localPatch.category ?? 'material', 'category edit must survive sync');
assert.equal(localAsset.source, localPatch.source ?? '生成任务', 'source edit must survive sync');
if (input.scenario === 'edit-display-url') {
assert.equal(localAsset.data.dataUrl, remoteBefore.data.dataUrl, 'comparison must preserve the stored remote media URL');
assert.equal(localAsset.coverUrl, remoteBefore.coverUrl, 'comparison must preserve the stored remote cover URL');
}
for (const key of ['clientContext', 'conversationId', 'messageId', 'batchIndex']) {
const expected = remoteBefore.metadata[key] ?? localPatch.metadata[key];
assert.deepEqual(localAsset.metadata[key], expected, `stored metadata must survive: ${key}`);
assert.deepEqual(remoteAssets.get(id).metadata[key], expected, `remote metadata must survive PUT: ${key}`);
}
assert.equal(localAsset.updatedAt, editedAt, 'merged edits must retain their update timestamp');
assert.deepEqual(localAsset.tags, input.scenario === 'edit-disjoint' ? ['遠端標籤'] : localPatch.tags ?? generated.tags, 'field merge must preserve the remote-only edit');
if (input.scenario === 'edit-disjoint') {
assert.deepEqual(localAsset.metadata.projectIds, ['remote-project'], 'business metadata must still merge');
assert.equal(localAsset.coverUrl, 'https://example.test/custom-cover.png', 'custom covers must still merge');
}
assert.equal(uploadedAssets.length, 1, 'merged local edits must be saved');
const expectedPut = input.scenario === 'edit-display-url'
? { ...localAsset, data: { ...localAsset.data, dataUrl: input.asset.data.dataUrl } }
: localAsset;
assert.deepEqual(uploadedAssets[0], expectedPut, 'PUT must contain the merged content');
await sync.saveRemoteUserDataNow();
assert.equal(uploadedAssets.length, 1, 'acknowledged merge must not loop');
}
await assets.flushAssetStorePersistence();
process.stdout.write(JSON.stringify({ assets: uploadedAssets, nodeAssetId: id, repairCreated: 0, localAsset: assets.useAssetStore.getState().assets[0] }));
return;
}
if (input.scenario === 'agent-without-id' || input.scenario === 'remote-unavailable') {
if (input.scenario === 'agent-without-id') assert.equal(repair.repairMissingCanvasAssets(undefined, true).createdAssets, 0, '修复不能为受管产物建立随机素材');
await assert.rejects(pipeline.ensureCanvasNodeAsset({ canvasId: remoteProject.id, node: nodesRef.current[0], source: 'canvas-generation', taskId: input.task.id }), input.scenario === 'remote-unavailable' ? /Unavailable/ : /生成素材尚未加载/);
assert.equal(uploadedAssets.length, 0, '读取失败不能创建素材');
process.stdout.write(JSON.stringify({ assets: [], nodeAssetId: input.asset.id, repairCreated: 0 }));
return;
}
const saved = await pipeline.ensureCanvasNodeAsset({ canvasId: remoteProject.id, node: nodesRef.current[0], source: 'canvas-generation', taskId: input.task.id });
if (!input.project) assert.equal(saved.created, false, '自动保存应重用 materialize 记录');
if (input.project) {
nodesRef.current[0].metadata.assetId = saved.assetId;
canvases.useCanvasStore.getState().updateProject(remoteProject.id, { nodes: nodesRef.current });
}
repairCreated += repair.repairMissingCanvasAssets(undefined, true).createdAssets;
if (!input.project) await consume(input.task);
await sync.saveRemoteUserDataNow();
const writes = uploadedAssets.length;
await sync.saveRemoteUserDataNow();
assert.equal(uploadedAssets.length, writes, 'context-only adoption must not cause another PUT');
await Promise.all([assets.flushAssetStorePersistence(), canvases.flushCanvasStorePersistence()]);
assert.equal(assets.useAssetStore.getState().assets.length, 1);
assert.equal(nodesRef.current[0].metadata.assetId, saved.assetId);
assert.equal(nodesRef.current[0].metadata.storageKey, input.asset.data.storageKey);
process.stdout.write(JSON.stringify({ assets: uploadedAssets, nodeAssetId: saved.assetId, repairCreated, localAsset: assets.useAssetStore.getState().assets[0] }));
}
run().then(() => process.exit(0), error => { console.error(error); process.exit(1); });