b49bf9c984
将旧布局上限统一提升至2MiB并接入revision CAS保存 新增画布图层、生成对话框和迁移状态结构化存储 新增migration operator保护的回填、激活和回滚脚本 新增历史图片映射及音频资源恢复的定向dry-run/apply流程 补齐前端保存队列、OpenAPI、Bindings、发布归档与回归测试
478 lines
14 KiB
TypeScript
478 lines
14 KiB
TypeScript
import {
|
|
chmod,
|
|
mkdir,
|
|
mkdtemp,
|
|
rm,
|
|
symlink,
|
|
writeFile,
|
|
} from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import path from 'node:path';
|
|
|
|
import { afterEach, describe, expect, it } from 'vitest';
|
|
|
|
import {
|
|
buildProcedureInput,
|
|
executeRepairPlan,
|
|
parseOptions,
|
|
readRepairPlan,
|
|
runCli,
|
|
validateRepairPlan,
|
|
} from './spacetime-repair-editor-canvas-resources.mjs';
|
|
|
|
const SHA_A = 'a'.repeat(64);
|
|
const SHA_B = 'b'.repeat(64);
|
|
const PLAN_SHA = 'c'.repeat(64);
|
|
const temporaryRoots: string[] = [];
|
|
|
|
afterEach(async () => {
|
|
process.exitCode = undefined;
|
|
await Promise.all(
|
|
temporaryRoots
|
|
.splice(0)
|
|
.map((root) => rm(root, { recursive: true, force: true })),
|
|
);
|
|
});
|
|
|
|
function action(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
action_kind: 'reuse_project_resource',
|
|
layer_id: 'private-layer-id',
|
|
expected_resource_id: 'missing-private-resource-id',
|
|
replacement_resource_id: 'replacement-private-resource-id',
|
|
expected_layout_source_resource_id: 'source-private-resource-id',
|
|
asset_object_id: null,
|
|
asset_kind: null,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function canvas(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
canvas_id: 'private-canvas-id',
|
|
project_id: 'private-project-id',
|
|
owner_user_id: 'private-owner-id',
|
|
expected_revision: 7,
|
|
expected_canvas_layout_sha256: SHA_A,
|
|
expected_project_layout_sha256: SHA_B,
|
|
actions: [action()],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function planValue(canvases = [canvas()]) {
|
|
return {
|
|
schema_version: 1,
|
|
expected_canvas_count: canvases.length,
|
|
expected_action_count: canvases.reduce(
|
|
(sum, item) =>
|
|
sum + (Array.isArray(item.actions) ? item.actions.length : 0),
|
|
0,
|
|
),
|
|
canvases,
|
|
};
|
|
}
|
|
|
|
function executionOptions(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
apply: false,
|
|
confirmPlanSha256: '',
|
|
database: 'genarrative-prod',
|
|
passthrough: [],
|
|
planFile: '/outside/repair-plan.json',
|
|
server: 'http://127.0.0.1:3101',
|
|
serverUrl: '',
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function procedureResult(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
ok: true,
|
|
repair: {
|
|
dry_run: true,
|
|
already_repaired: false,
|
|
matched_layer_count: 1,
|
|
remapped_layer_count: 1,
|
|
restored_resource_count: 0,
|
|
removed_source_resource_id_count: 1,
|
|
revision: 7,
|
|
},
|
|
error_message: null,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe('spacetime editor canvas resource repair options', () => {
|
|
it('defaults to dry-run and requires an explicit apply flag', () => {
|
|
const options = parseOptions([
|
|
'--database',
|
|
'genarrative-prod',
|
|
'--server-url',
|
|
'http://127.0.0.1:3101',
|
|
'--plan-file',
|
|
'/tmp/repair-plan.json',
|
|
]);
|
|
|
|
expect(options.apply).toBe(false);
|
|
expect(options.confirmPlanSha256).toBe('');
|
|
expect(options.planFile).toBe('/tmp/repair-plan.json');
|
|
});
|
|
|
|
it('records explicit apply and lowercases the confirmed plan hash', () => {
|
|
const options = parseOptions([
|
|
'--apply',
|
|
'--confirm-plan-sha256',
|
|
SHA_A.toUpperCase(),
|
|
]);
|
|
|
|
expect(options.apply).toBe(true);
|
|
expect(options.confirmPlanSha256).toBe(SHA_A);
|
|
});
|
|
|
|
it('rejects anonymous procedure calls', () => {
|
|
expect(() => parseOptions(['--anonymous'])).toThrow('未知参数');
|
|
});
|
|
});
|
|
|
|
describe('spacetime editor canvas resource repair plan', () => {
|
|
it('validates and normalizes the two supported action kinds', () => {
|
|
const plan = validateRepairPlan(
|
|
planValue([
|
|
canvas({
|
|
actions: [
|
|
action(),
|
|
action({
|
|
action_kind: 'restore_project_resource',
|
|
layer_id: 'private-audio-layer-id',
|
|
expected_resource_id: 'missing-private-audio-resource-id',
|
|
replacement_resource_id: null,
|
|
expected_layout_source_resource_id: null,
|
|
asset_object_id: 'private-asset-object-id',
|
|
asset_kind: 'background-music',
|
|
}),
|
|
],
|
|
}),
|
|
]),
|
|
);
|
|
|
|
expect(plan.expected_action_count).toBe(2);
|
|
expect(plan.canvases[0].actions).toMatchObject([
|
|
{ action_kind: 'reuse_project_resource' },
|
|
{
|
|
action_kind: 'restore_project_resource',
|
|
asset_kind: 'background-music',
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('rejects count mismatches, duplicate layers, and invalid kind-specific fields', () => {
|
|
expect(() =>
|
|
validateRepairPlan({ ...planValue(), expected_action_count: 2 }),
|
|
).toThrow('expected_action_count');
|
|
expect(() =>
|
|
validateRepairPlan(
|
|
planValue([
|
|
canvas({
|
|
actions: [action(), action({ replacement_resource_id: 'other' })],
|
|
}),
|
|
]),
|
|
),
|
|
).toThrow('layer_id 在同一画布内重复');
|
|
expect(() =>
|
|
validateRepairPlan(
|
|
planValue([
|
|
canvas({
|
|
actions: [
|
|
action({
|
|
action_kind: 'restore_project_resource',
|
|
replacement_resource_id: null,
|
|
expected_layout_source_resource_id: null,
|
|
asset_object_id: 'private-asset-object-id',
|
|
asset_kind: 'image',
|
|
}),
|
|
],
|
|
}),
|
|
]),
|
|
),
|
|
).toThrow('asset_kind 只支持');
|
|
});
|
|
|
|
it('reads only an external current-user 0600 regular plan and returns its hash', async () => {
|
|
const root = await makeTemporaryRoot();
|
|
const planPath = path.join(root, 'repair-plan.json');
|
|
await writeFile(planPath, `${JSON.stringify(planValue())}\n`, 'utf8');
|
|
await chmod(planPath, 0o600);
|
|
|
|
const result = await readRepairPlan(planPath);
|
|
|
|
expect(result.plan.expected_canvas_count).toBe(1);
|
|
expect(result.planSha256).toMatch(/^[0-9a-f]{64}$/u);
|
|
});
|
|
|
|
it('rejects permissive modes, repository paths, and symlink path components', async () => {
|
|
const root = await makeTemporaryRoot();
|
|
const planPath = path.join(root, 'repair-plan.json');
|
|
await writeFile(planPath, JSON.stringify(planValue()), 'utf8');
|
|
await chmod(planPath, 0o644);
|
|
await expect(readRepairPlan(planPath)).rejects.toThrow('0600');
|
|
|
|
await chmod(planPath, 0o600);
|
|
await expect(readRepairPlan(planPath, { repoRoot: root })).rejects.toThrow(
|
|
'必须位于仓库外',
|
|
);
|
|
|
|
const realDirectory = path.join(root, 'real');
|
|
const linkedDirectory = path.join(root, 'linked');
|
|
await mkdir(realDirectory);
|
|
const linkedPlanPath = path.join(realDirectory, 'linked-plan.json');
|
|
await writeFile(linkedPlanPath, JSON.stringify(planValue()), 'utf8');
|
|
await chmod(linkedPlanPath, 0o600);
|
|
await symlink(realDirectory, linkedDirectory);
|
|
await expect(
|
|
readRepairPlan(path.join(linkedDirectory, 'linked-plan.json')),
|
|
).rejects.toThrow('路径链不能包含符号链接');
|
|
});
|
|
});
|
|
|
|
describe('spacetime editor canvas resource repair execution', () => {
|
|
it('splits the two action kinds into typed procedure inputs', () => {
|
|
const plan = validateRepairPlan(
|
|
planValue([
|
|
canvas({
|
|
actions: [
|
|
action(),
|
|
action({
|
|
action_kind: 'restore_project_resource',
|
|
layer_id: 'private-audio-layer-id',
|
|
expected_resource_id: 'missing-private-audio-resource-id',
|
|
replacement_resource_id: null,
|
|
expected_layout_source_resource_id: null,
|
|
asset_object_id: 'private-asset-object-id',
|
|
asset_kind: 'sound-effect',
|
|
}),
|
|
],
|
|
}),
|
|
]),
|
|
);
|
|
|
|
const input = buildProcedureInput(plan.canvases[0], 123, true);
|
|
|
|
expect(input.remaps).toEqual([
|
|
{
|
|
layer_id: 'private-layer-id',
|
|
expected_missing_resource_id: 'missing-private-resource-id',
|
|
replacement_resource_id: 'replacement-private-resource-id',
|
|
expected_layout_source_resource_id: 'source-private-resource-id',
|
|
},
|
|
]);
|
|
expect(input.restores).toEqual([
|
|
{
|
|
layer_id: 'private-audio-layer-id',
|
|
resource_id: 'missing-private-audio-resource-id',
|
|
asset_object_id: 'private-asset-object-id',
|
|
asset_kind: 'sound-effect',
|
|
},
|
|
]);
|
|
expect(input.canvas_id).toBe('private-canvas-id');
|
|
expect(input.updated_at_micros).toBe(123);
|
|
expect(input.dry_run).toBe(true);
|
|
});
|
|
|
|
it('calls one procedure per canvas and emits only anonymous aggregate output', async () => {
|
|
const plan = validateRepairPlan(
|
|
planValue([
|
|
canvas(),
|
|
canvas({
|
|
canvas_id: 'second-private-canvas-id',
|
|
project_id: 'second-private-project-id',
|
|
actions: [
|
|
action({
|
|
layer_id: 'second-private-layer-id',
|
|
expected_resource_id: 'second-private-resource-id',
|
|
replacement_resource_id: 'second-private-replacement-id',
|
|
}),
|
|
],
|
|
}),
|
|
]),
|
|
);
|
|
const calls: unknown[] = [];
|
|
|
|
const summary = await executeRepairPlan({
|
|
options: executionOptions(),
|
|
plan,
|
|
planSha256: PLAN_SHA,
|
|
updatedAtMicros: 123,
|
|
callProcedure: async (options, procedureName, input) => {
|
|
calls.push({ options, procedureName, input });
|
|
return procedureResult();
|
|
},
|
|
});
|
|
|
|
expect(calls).toHaveLength(2);
|
|
expect(calls).toMatchObject([
|
|
{ procedureName: 'repair_editor_canvas_resources_and_return' },
|
|
{ procedureName: 'repair_editor_canvas_resources_and_return' },
|
|
]);
|
|
expect(summary).toMatchObject({
|
|
ok: true,
|
|
dry_run: true,
|
|
canvas_count: 2,
|
|
action_count: 2,
|
|
processed_canvas_count: 2,
|
|
matched_layer_count: 2,
|
|
updated_layer_count: 2,
|
|
inserted_resource_count: 0,
|
|
});
|
|
const publicOutput = JSON.stringify(summary);
|
|
for (const privateValue of [
|
|
'private-project-id',
|
|
'private-layer-id',
|
|
'missing-private-resource-id',
|
|
'replacement-private-resource-id',
|
|
]) {
|
|
expect(publicOutput).not.toContain(privateValue);
|
|
}
|
|
});
|
|
|
|
it('requires apply to match the dry-run plan hash', async () => {
|
|
const plan = validateRepairPlan(planValue());
|
|
const callProcedure = async () => procedureResult();
|
|
|
|
await expect(
|
|
executeRepairPlan({
|
|
options: executionOptions({ apply: true, confirmPlanSha256: SHA_A }),
|
|
plan,
|
|
planSha256: PLAN_SHA,
|
|
callProcedure,
|
|
}),
|
|
).rejects.toThrow('与当前 plan 不一致');
|
|
});
|
|
|
|
it('stops apply at the first failed canvas without exposing procedure messages', async () => {
|
|
const plan = validateRepairPlan(
|
|
planValue([
|
|
canvas(),
|
|
canvas({
|
|
canvas_id: 'second-private-canvas-id',
|
|
project_id: 'second-private-project-id',
|
|
actions: [action({ layer_id: 'second-private-layer-id' })],
|
|
}),
|
|
]),
|
|
);
|
|
let callCount = 0;
|
|
|
|
await expect(
|
|
executeRepairPlan({
|
|
options: executionOptions({
|
|
apply: true,
|
|
confirmPlanSha256: PLAN_SHA,
|
|
}),
|
|
plan,
|
|
planSha256: PLAN_SHA,
|
|
callProcedure: async () => {
|
|
callCount += 1;
|
|
return procedureResult({
|
|
ok: false,
|
|
repair: null,
|
|
error_message:
|
|
'contains private-project-id and private-resource-id',
|
|
});
|
|
},
|
|
}),
|
|
).rejects.toThrow(
|
|
'第 1 个画布修复失败,error_code=repair_rejected;此前成功处理 0 个画布',
|
|
);
|
|
expect(callCount).toBe(1);
|
|
});
|
|
|
|
it('continues dry-run validation and reports only ordinal-safe failure codes', async () => {
|
|
const plan = validateRepairPlan(
|
|
planValue([
|
|
canvas(),
|
|
canvas({
|
|
canvas_id: 'second-private-canvas-id',
|
|
project_id: 'second-private-project-id',
|
|
actions: [action({ layer_id: 'second-private-layer-id' })],
|
|
}),
|
|
]),
|
|
);
|
|
let callCount = 0;
|
|
|
|
const summary = await executeRepairPlan({
|
|
options: executionOptions(),
|
|
plan,
|
|
planSha256: PLAN_SHA,
|
|
callProcedure: async () => {
|
|
callCount += 1;
|
|
return callCount === 1
|
|
? procedureResult({
|
|
ok: false,
|
|
repair: null,
|
|
error_message: 'contains private-project-id',
|
|
})
|
|
: procedureResult();
|
|
},
|
|
});
|
|
|
|
expect(callCount).toBe(2);
|
|
expect(summary).toMatchObject({
|
|
ok: false,
|
|
processed_canvas_count: 1,
|
|
failures: [{ canvas_ordinal: 1, error_code: 'repair_rejected' }],
|
|
});
|
|
expect(JSON.stringify(summary)).not.toContain('private-project-id');
|
|
});
|
|
|
|
it('automatically dry-runs the same plan after apply and requires already repaired', async () => {
|
|
const plan = validateRepairPlan(planValue());
|
|
const originalLog = console.log;
|
|
console.log = () => {};
|
|
let callCount = 0;
|
|
try {
|
|
const summary = await runCli(
|
|
[
|
|
'--database',
|
|
'genarrative-prod',
|
|
'--server-url',
|
|
'http://127.0.0.1:3101',
|
|
'--plan-file',
|
|
'/outside/repair-plan.json',
|
|
'--apply',
|
|
'--confirm-plan-sha256',
|
|
PLAN_SHA,
|
|
],
|
|
{
|
|
readPlan: async () => ({ plan, planSha256: PLAN_SHA }),
|
|
callProcedure: async (_options, _procedureName, input) => {
|
|
callCount += 1;
|
|
const dryRun = input.dry_run === true;
|
|
return procedureResult({
|
|
repair: {
|
|
...procedureResult().repair,
|
|
dry_run: dryRun,
|
|
already_repaired: dryRun,
|
|
remapped_layer_count: dryRun ? 0 : 1,
|
|
removed_source_resource_id_count: dryRun ? 0 : 1,
|
|
revision: 8,
|
|
},
|
|
});
|
|
},
|
|
updatedAtMicros: 123,
|
|
},
|
|
);
|
|
|
|
expect(callCount).toBe(2);
|
|
expect(summary).toMatchObject({ ok: true, dry_run: false });
|
|
} finally {
|
|
console.log = originalLog;
|
|
}
|
|
});
|
|
});
|
|
|
|
async function makeTemporaryRoot() {
|
|
const root = await mkdtemp(path.join(tmpdir(), 'editor-canvas-repair-'));
|
|
temporaryRoots.push(root);
|
|
return root;
|
|
}
|