fix(assets): 复用生成素材身份并保留空缓存同步前的编辑 (#690)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
67e2e1ebbf
commit
544daa4b9f
11 files changed
+798
-14
No files matched your search
@@ -0,0 +1,264 @@
|
||||
// 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); });
|
||||
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Step 1b guard mutations; use the same cached Go/Node environment as the repro."""
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
env = dict(os.environ, CANVAS_TEST_GENERATION_BROWSER_RUNNER=str(root / "tools/test-gen-asset-browser.cjs"))
|
||||
|
||||
|
||||
def check(name, scenario, evidence, extra=None):
|
||||
result = subprocess.run(
|
||||
["go", "-C", "backend", "test", "./internal/app", "-run", f"^TestGeneratedAssetBrowserIdentity/{scenario}$", "-count=1", "-v"],
|
||||
cwd=root, env=dict(env, **(extra or {})), text=True, capture_output=True, timeout=120,
|
||||
)
|
||||
output = result.stdout + result.stderr
|
||||
if result.returncode == 0 or evidence not in output:
|
||||
raise AssertionError(f"{name}: mutation survived or failed for an unrelated reason\n{output}")
|
||||
print(f"KILLED {name}: {evidence}", flush=True)
|
||||
|
||||
|
||||
for name, scenario, evidence in [
|
||||
("adoption", "agent-live", "生成素材尚未加载"),
|
||||
("managed", "agent-without-id", "Missing expected rejection"),
|
||||
("repair", "agent-without-id", "修复不能为受管产物建立随机素材"),
|
||||
("lookup", "reopen-edited", "browser PUT count"),
|
||||
("skipPut", "reopen-edited", "browser PUT count"),
|
||||
("lookupFailure", "remote-unavailable", "Missing expected rejection"),
|
||||
("dropLocal", "edit-before-get", "local edit must survive sync and retry"),
|
||||
("dropRemote", "edit-disjoint", "field merge must preserve the remote-only edit"),
|
||||
("ignoreConflict", "edit-conflict", "both edited must surface the normal asset conflict"),
|
||||
("dropRemote", "reopen-edited", "browser PUT count"),
|
||||
("retryDropsLocal", "edit-during-get", "local edit must survive sync and retry"),
|
||||
("staleGet", "edit-during-get", "GET edit must stop stale adoption"),
|
||||
("categoryDefault", "edit-category", "generated category must be material"),
|
||||
("stableMedia", "edit-display-url", "素材远端版本已变化"),
|
||||
("clientMetadata", "edit-metadata", "素材远端版本已变化"),
|
||||
("storedMetadata", "edit-before-get", "stored metadata must survive: clientContext"),
|
||||
("storedMetadata", "edit-category", "stored metadata must survive: clientContext"),
|
||||
("localContext", "edit-disjoint", "stored metadata must survive: clientContext"),
|
||||
]:
|
||||
check(name, scenario, evidence, {"CANVAS_TEST_GENERATION_GUARD_MUTATION": name})
|
||||
|
||||
# Go mutation is restored even on failure; never run this alongside another Go test.
|
||||
file = root / "backend/internal/app/cloud_agent_media.go"
|
||||
original = file.read_bytes()
|
||||
needle = b'meta["assetId"] = assetID'
|
||||
assert original.count(needle) == 1
|
||||
try:
|
||||
file.write_bytes(original.replace(needle, b'// mutation: omit the registered asset identity'))
|
||||
check("writeback", "agent-reopen", "Agent writeback identity")
|
||||
finally:
|
||||
file.write_bytes(original)
|
||||
|
||||
file = root / "backend/internal/app/task_media_assets.go"
|
||||
original = file.read_bytes()
|
||||
for name, needle, replacement, scenario, evidence in [
|
||||
("imageTitle", '"image": "生成图片"', '"image": "生成作品"', "edit-before-get", "backend/frontend title defaults must agree"),
|
||||
("videoTitle", '"video": "生成视频"', '"video": "生成作品"', "edit-video", "backend/frontend title defaults must agree"),
|
||||
("audioTitle", '"audio": "生成音频"', '"audio": "生成作品"', "edit-audio", "backend/frontend title defaults must agree"),
|
||||
("sourceDefault", '"source": "生成任务", ', '', "edit-source", "backend/frontend source defaults must agree"),
|
||||
]:
|
||||
needle, replacement = needle.encode(), replacement.encode()
|
||||
assert original.count(needle) == 1
|
||||
try:
|
||||
file.write_bytes(original.replace(needle, replacement))
|
||||
check(name, scenario, evidence)
|
||||
finally:
|
||||
file.write_bytes(original)
|
||||
@@ -0,0 +1,135 @@
|
||||
// Run from web with a test/*.test.ts argument. Execute upstream suites with Node.
|
||||
// Assertions and
|
||||
// module mocks stay in the original suites; unsupported matchers fail closed.
|
||||
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(process.cwd(), '..');
|
||||
const web = createRequire(path.join(root, 'web/package.json'));
|
||||
const { transformSync } = web('rolldown/experimental');
|
||||
const babel = web('@babel/core');
|
||||
const paths = JSON.parse(fs.readFileSync('tsconfig.json', 'utf8')).compilerOptions.paths;
|
||||
const cache = new Map(), mocks = new Map(), tests = [];
|
||||
function resolve(name, parent) {
|
||||
for (const [alias, targets] of Object.entries(paths)) {
|
||||
if (alias.endsWith('*') ? name.startsWith(alias.slice(0, -1)) : name === alias) {
|
||||
name = path.resolve(targets[0].replace('*', name.slice(alias.length - 1)));
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!name.startsWith('.') && !path.isAbsolute(name)) return name;
|
||||
const base = path.resolve(path.dirname(parent), name);
|
||||
for (const ext of ['', '.ts', '.tsx', '.js', '.json', '/index.ts', '/index.tsx']) {
|
||||
if (fs.existsSync(base + ext) && fs.statSync(base + ext).isFile()) return base + ext;
|
||||
}
|
||||
throw Error('Cannot resolve ' + name + ' from ' + parent);
|
||||
}
|
||||
const stringPattern = Symbol('stringMatching');
|
||||
function resolveMatchers(value, expected) {
|
||||
if (expected?.[stringPattern]) {
|
||||
assert.equal(typeof value, 'string');
|
||||
assert.match(value, expected[stringPattern]);
|
||||
return value;
|
||||
}
|
||||
if (Array.isArray(expected)) return expected.map((item, index) => resolveMatchers(value?.[index], item));
|
||||
if (expected && Object.getPrototypeOf(expected) === Object.prototype) {
|
||||
return Object.fromEntries(Object.entries(expected).map(([key, item]) => [key, resolveMatchers(value?.[key], item)]));
|
||||
}
|
||||
return expected;
|
||||
}
|
||||
function expect(value, label = '') {
|
||||
const matchers = (negate = false) => {
|
||||
const check = (fn) => {
|
||||
let error;
|
||||
try { fn(); } catch (caught) { error = caught; }
|
||||
if (negate) assert(error, 'expect(...).not ' + label);
|
||||
else if (error) throw new Error('expect(...) ' + label + ': ' + error.message, { cause: error });
|
||||
};
|
||||
return {
|
||||
get not() { return matchers(!negate); },
|
||||
toBe: other => check(() => assert.equal(value, other)),
|
||||
toEqual: other => check(() => assert.deepEqual(value, resolveMatchers(value, other))),
|
||||
toBeNull: () => check(() => assert.equal(value, null)),
|
||||
toBeUndefined: () => check(() => assert.equal(value, undefined)),
|
||||
toBeDefined: () => check(() => assert.notEqual(value, undefined)),
|
||||
toHaveLength: other => check(() => assert.equal(value.length, other)),
|
||||
toHaveBeenCalled: () => check(() => assert(value.calls.length > 0)),
|
||||
toHaveBeenCalledTimes: other => check(() => assert.equal(value.calls.length, other)),
|
||||
toContain: other => check(() => assert(value.includes(other))),
|
||||
toBeLessThan: other => check(() => assert(value < other)),
|
||||
toBeGreaterThanOrEqual: other => check(() => assert(value >= other)),
|
||||
toMatchObject: other => check(() => assert.partialDeepStrictEqual(value, other)),
|
||||
toThrow: message => check(() => assert.throws(value, error => message === undefined || (message instanceof RegExp ? message.test(error.message) : error.message.includes(message)))),
|
||||
get rejects() { return { toThrow: async message => {
|
||||
let rejected = false;
|
||||
try { await value; } catch (error) { rejected = true; expect(() => { throw error; }, label).toThrow(message); }
|
||||
assert(rejected, 'expect(...).rejects ' + label);
|
||||
} }; },
|
||||
get resolves() { return new Proxy({}, { get: (_, method) => async (...args) => expect(await value)[method](...args) }); },
|
||||
};
|
||||
};
|
||||
return matchers();
|
||||
}
|
||||
const entry = path.resolve(process.argv[2]);
|
||||
expect.stringMatching = pattern => ({ [stringPattern]: pattern instanceof RegExp ? pattern : new RegExp(pattern) });
|
||||
let beforeEach = [], afterEach = [];
|
||||
const api = { expect, test: (name, fn) => {
|
||||
const setup = [...beforeEach];
|
||||
const teardown = [...afterEach];
|
||||
tests.push([name, async () => { try { for (const run of setup) await run(); await fn(); } finally { for (const run of teardown) await run(); } }]);
|
||||
}, spyOn(object, method) {
|
||||
const original = object[method];
|
||||
let implementation = original;
|
||||
const spy = Object.assign(function (...args) { spy.calls.push(args); return implementation.apply(this, args); }, { calls: [], mockImplementation(fn) { implementation = fn; return spy; }, mockResolvedValue(value) { implementation = async () => value; return spy; }, mockRestore() { object[method] = original; } });
|
||||
object[method] = spy;
|
||||
return spy;
|
||||
}, afterEach: fn => afterEach.push(fn), beforeEach: fn => beforeEach.push(fn), describe: (_name, run) => {
|
||||
const parent = beforeEach;
|
||||
beforeEach = [...parent];
|
||||
try { run(); } finally { beforeEach = parent; }
|
||||
}, mock: { module(name, factory) {
|
||||
mocks.set(resolve(name, entry), factory());
|
||||
} } };
|
||||
function compile(file) {
|
||||
const source = fs.readFileSync(file, 'utf8').replaceAll('import.meta.env', '({})');
|
||||
const result = transformSync(file, source, { jsx: { runtime: 'automatic' } });
|
||||
assert.equal(result.errors.length, 0, JSON.stringify(result.errors));
|
||||
return babel.transformSync(result.code, { filename: file, configFile: false, babelrc: false,
|
||||
plugins: [web.resolve('@babel/plugin-transform-modules-commonjs'), ({ types: t }) => ({ visitor: {
|
||||
CallExpression(node) {
|
||||
if (node.node.callee.type === 'Import') node.replaceWith(t.callExpression(
|
||||
t.memberExpression(t.callExpression(t.memberExpression(t.identifier('Promise'), t.identifier('resolve')), []), t.identifier('then')),
|
||||
[t.arrowFunctionExpression([], t.callExpression(t.identifier('require'), node.node.arguments))]));
|
||||
},
|
||||
} })],
|
||||
}).code;
|
||||
}
|
||||
function load(name, parent = entry) {
|
||||
if (name === 'bun:test' || name === 'node:test') return api;
|
||||
const file = resolve(name, parent);
|
||||
if (mocks.has(file)) return mocks.get(file);
|
||||
if (!path.isAbsolute(file)) return web(file);
|
||||
if (file.endsWith('.css')) return {};
|
||||
if (!/\.[cm]?[jt]sx?$/.test(file) || file.includes('/node_modules/') || file.endsWith('.mjs')) return createRequire(parent)(file);
|
||||
if (cache.has(file)) return cache.get(file).exports;
|
||||
const module = { exports: {} };
|
||||
cache.set(file, module);
|
||||
new Function('require', 'module', 'exports', '__filename', '__dirname', compile(file))(
|
||||
name => load(name, file), module, module.exports, file, path.dirname(file));
|
||||
return module.exports;
|
||||
}
|
||||
(async () => {
|
||||
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
|
||||
const module = { exports: {} };
|
||||
await new AsyncFunction('require', 'module', 'exports', '__filename', '__dirname', compile(entry))(
|
||||
name => load(name, entry), module, module.exports, entry, path.dirname(entry));
|
||||
assert(tests.length > 0, 'No tests registered');
|
||||
let failed = 0;
|
||||
for (const [name, fn] of tests) {
|
||||
try { await fn(); console.log('(pass) ' + name); }
|
||||
catch (error) { failed++; console.error('(fail) ' + name, error); }
|
||||
}
|
||||
console.log(`${tests.length - failed} pass\n ${failed} fail (Node)`);
|
||||
process.exitCode = failed ? 1 : 0;
|
||||
})().catch(error => { console.error(error); process.exitCode = 1; });
|
||||
Reference in new issue
Block a user