feat(插件): 发布意图提单 V2
This commit is contained in:
@@ -0,0 +1,308 @@
|
||||
#!/usr/bin/env node
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import {
|
||||
chmod,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
rm,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
attachmentManifest,
|
||||
contentDigest,
|
||||
createDraftIdentity,
|
||||
normalize,
|
||||
parseConfirmationToken,
|
||||
preview,
|
||||
verifySecret,
|
||||
} from "./intake-lib.mjs";
|
||||
|
||||
const scriptRoot = dirname(fileURLToPath(import.meta.url));
|
||||
const temporary = await mkdtemp(resolve(tmpdir(), "easyai-intake-v2-"));
|
||||
|
||||
try {
|
||||
const minimal = normalize({
|
||||
type: "需求",
|
||||
title: "在设置页展示版本号",
|
||||
problem_or_goal: "便于支持人员确认当前版本",
|
||||
expected_outcome: "设置页可看到当前前端版本号",
|
||||
});
|
||||
assert.equal(minimal.priority, "P2");
|
||||
assert.deepEqual(minimal.acceptance_criteria, []);
|
||||
assert.deepEqual(minimal.impact_scope, []);
|
||||
assert.deepEqual(preview(minimal), {
|
||||
类型: "需求",
|
||||
标题: "在设置页展示版本号",
|
||||
背景与目标: "便于支持人员确认当前版本",
|
||||
期望结果: "设置页可看到当前前端版本号",
|
||||
优先级: "P2",
|
||||
});
|
||||
|
||||
const incompleteBug = normalize({
|
||||
type: "Bug",
|
||||
title: "保存后页面未更新",
|
||||
background: "负责人观察到保存结果没有出现在列表",
|
||||
expected_result: "保存成功后列表显示新数据",
|
||||
});
|
||||
assert.equal(incompleteBug.environment, undefined);
|
||||
assert.deepEqual(incompleteBug.reproduction_steps, undefined);
|
||||
|
||||
assert.throws(
|
||||
() => normalize({ type: "需求", title: "x", background: "y" }),
|
||||
/期望结果缺失/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
normalize({
|
||||
...minimalInput(),
|
||||
impact_scope: ["全栈", "前端"],
|
||||
}),
|
||||
/不能再同时选择/,
|
||||
);
|
||||
assert.doesNotThrow(() =>
|
||||
normalize({ ...minimalInput(), impact_scope: ["部署"] }),
|
||||
);
|
||||
assert.throws(
|
||||
() => normalize({ ...minimalInput(), desired_at: "2026-02-30" }),
|
||||
/不是有效日期时间/,
|
||||
);
|
||||
assert.throws(
|
||||
() => normalize({ ...minimalInput(), attachments: ["../secret.txt"] }),
|
||||
/不安全的附件路径/,
|
||||
);
|
||||
assert.throws(
|
||||
() => normalize({ ...minimalInput(), task_id: "EAI-9999" }),
|
||||
/禁止或未知字段/,
|
||||
);
|
||||
|
||||
const firstIdentity = createDraftIdentity("ou_owner");
|
||||
const secondIdentity = createDraftIdentity("ou_owner");
|
||||
assert.notEqual(firstIdentity.draft_id, secondIdentity.draft_id);
|
||||
assert.notEqual(firstIdentity.submission_key, secondIdentity.submission_key);
|
||||
const parsed = parseConfirmationToken(firstIdentity.confirmation_token);
|
||||
assert.equal(parsed.draftId, firstIdentity.draft_id);
|
||||
assert.equal(
|
||||
verifySecret(parsed.secret, firstIdentity.confirmation_token_hash),
|
||||
true,
|
||||
);
|
||||
|
||||
await writeFile(resolve(temporary, "evidence.txt"), "first");
|
||||
const attachmentValue = normalize({
|
||||
...minimalInput(),
|
||||
attachments: ["evidence.txt"],
|
||||
});
|
||||
const firstManifest = await attachmentManifest(
|
||||
attachmentValue.attachments,
|
||||
temporary,
|
||||
);
|
||||
const firstDigest = contentDigest(attachmentValue, "ou_owner", firstManifest);
|
||||
await writeFile(resolve(temporary, "evidence.txt"), "second");
|
||||
const secondManifest = await attachmentManifest(
|
||||
attachmentValue.attachments,
|
||||
temporary,
|
||||
);
|
||||
assert.notEqual(
|
||||
firstDigest,
|
||||
contentDigest(attachmentValue, "ou_owner", secondManifest),
|
||||
);
|
||||
|
||||
const fakeCli = resolve(temporary, "fake-lark-cli.mjs");
|
||||
await writeFile(fakeCli, fakeLarkCli(), { mode: 0o700 });
|
||||
await chmod(fakeCli, 0o700);
|
||||
const intakeHome = resolve(temporary, "home");
|
||||
const env = {
|
||||
...process.env,
|
||||
EASYAI_INTAKE_HOME: intakeHome,
|
||||
EASYAI_LARK_CLI: fakeCli,
|
||||
};
|
||||
|
||||
const prepared = runJson(
|
||||
"prepare-intake.mjs",
|
||||
[],
|
||||
{ ...minimalInput(), constraints: ["必须复用既有组件"] },
|
||||
env,
|
||||
);
|
||||
assert.equal(prepared.status_code, "prepared");
|
||||
assert.match(
|
||||
prepared.confirmation_token,
|
||||
new RegExp(`^${prepared.draft_id}\\.`),
|
||||
);
|
||||
const draft = JSON.parse(
|
||||
await readFile(
|
||||
resolve(
|
||||
intakeHome,
|
||||
".easyai-intake",
|
||||
"drafts",
|
||||
`${prepared.draft_id}.json`,
|
||||
),
|
||||
"utf8",
|
||||
),
|
||||
);
|
||||
assert.equal(draft.owner_open_id, "ou_owner");
|
||||
assert.equal(draft.status_code, "prepared");
|
||||
|
||||
const dryRun = runJson(
|
||||
"submit-intake.mjs",
|
||||
["--confirm", prepared.confirmation_token, "--dry-run"],
|
||||
undefined,
|
||||
env,
|
||||
);
|
||||
assert.equal(dryRun.dry_run, true);
|
||||
|
||||
const submitted = runJson(
|
||||
"submit-intake.mjs",
|
||||
["--confirm", prepared.confirmation_token],
|
||||
undefined,
|
||||
env,
|
||||
);
|
||||
assert.equal(submitted.status_code, "submitted");
|
||||
const duplicate = runJson(
|
||||
"submit-intake.mjs",
|
||||
["--confirm", prepared.confirmation_token],
|
||||
undefined,
|
||||
env,
|
||||
);
|
||||
assert.equal(duplicate.duplicate, true);
|
||||
|
||||
const unknownDraft = runJson(
|
||||
"prepare-intake.mjs",
|
||||
[],
|
||||
{ ...minimalInput(), title: "未知结果恢复" },
|
||||
env,
|
||||
);
|
||||
const failed = runJson(
|
||||
"submit-intake.mjs",
|
||||
["--confirm", unknownDraft.confirmation_token],
|
||||
undefined,
|
||||
{ ...env, EASYAI_FAKE_FAIL_FORM: "1" },
|
||||
1,
|
||||
);
|
||||
assert.equal(failed.status_code, "unknown");
|
||||
const blockedRetry = runJson(
|
||||
"submit-intake.mjs",
|
||||
["--confirm", unknownDraft.confirmation_token],
|
||||
undefined,
|
||||
env,
|
||||
1,
|
||||
);
|
||||
assert.match(blockedRetry.error, /--retry-unknown/);
|
||||
const recovered = runJson(
|
||||
"submit-intake.mjs",
|
||||
["--confirm", unknownDraft.confirmation_token, "--retry-unknown"],
|
||||
undefined,
|
||||
env,
|
||||
);
|
||||
assert.equal(recovered.status_code, "submitted");
|
||||
|
||||
const lockedDraft = runJson(
|
||||
"prepare-intake.mjs",
|
||||
[],
|
||||
{ ...minimalInput(), title: "并发锁测试" },
|
||||
env,
|
||||
);
|
||||
await mkdir(
|
||||
resolve(intakeHome, ".easyai-intake", "locks", lockedDraft.draft_id),
|
||||
{ recursive: true },
|
||||
);
|
||||
const locked = runJson(
|
||||
"submit-intake.mjs",
|
||||
["--confirm", lockedDraft.confirmation_token],
|
||||
undefined,
|
||||
env,
|
||||
1,
|
||||
);
|
||||
assert.match(locked.error, /正在提交/);
|
||||
|
||||
console.log("intake v2 tests passed");
|
||||
} finally {
|
||||
await rm(temporary, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function minimalInput() {
|
||||
return {
|
||||
type: "需求",
|
||||
title: "在设置页展示版本号",
|
||||
background: "便于支持人员确认当前版本",
|
||||
expected_result: "设置页可看到当前前端版本号",
|
||||
};
|
||||
}
|
||||
|
||||
function runJson(script, args, input, env, expectedStatus = 0) {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[resolve(scriptRoot, script), ...args],
|
||||
{
|
||||
cwd: temporary,
|
||||
env,
|
||||
encoding: "utf8",
|
||||
input: input === undefined ? undefined : JSON.stringify(input),
|
||||
},
|
||||
);
|
||||
assert.equal(
|
||||
result.status,
|
||||
expectedStatus,
|
||||
`${script} status=${result.status}\nstdout=${result.stdout}\nstderr=${result.stderr}`,
|
||||
);
|
||||
return JSON.parse(
|
||||
(expectedStatus === 0 ? result.stdout : result.stderr).trim(),
|
||||
);
|
||||
}
|
||||
|
||||
function fakeLarkCli() {
|
||||
return `#!/usr/bin/env node
|
||||
const args = process.argv.slice(2);
|
||||
const ok = (data) => console.log(JSON.stringify({ ok: true, data }));
|
||||
if (args[0] === "auth") {
|
||||
ok({ identities: { user: { verified: true, openId: "ou_owner" } } });
|
||||
} else if (args.includes("+form-detail")) {
|
||||
const question = (title, type, required = false, options = []) => ({
|
||||
title, type, required, options: options.map((name) => ({ name })),
|
||||
});
|
||||
ok({
|
||||
base_token: "Ed21b9VwNaZfiesxWgDc67wXnoc",
|
||||
questions: [
|
||||
question("类型", "select", true, ["需求", "Bug"]),
|
||||
question("标题", "text", true),
|
||||
question("需求背景与目标", "text", true),
|
||||
question("期望结果", "text", true),
|
||||
question("优先级", "select", true, ["P0", "P1", "P2", "P3"]),
|
||||
question("产品负责人", "user", true),
|
||||
question("提交幂等键", "text", true),
|
||||
question("提交来源", "select", true, ["Codex Skill"]),
|
||||
question("验收标准", "text"),
|
||||
question("发生环境", "text"),
|
||||
question("复现步骤", "text"),
|
||||
question("实际结果", "text"),
|
||||
question("影响范围", "select", false, ["前端", "后端", "全栈", "部署"]),
|
||||
question("约束与非目标", "text"),
|
||||
question("期望完成时间", "datetime"),
|
||||
question("参考资料", "text"),
|
||||
question("附件", "attachment"),
|
||||
],
|
||||
});
|
||||
} else if (args.includes("+record-search")) {
|
||||
ok({ data: [], record_id_list: [] });
|
||||
} else if (args.includes("+form-submit")) {
|
||||
if (process.env.EASYAI_FAKE_FAIL_FORM === "1") {
|
||||
console.error(JSON.stringify({ ok: false, error: { message: "network interrupted" } }));
|
||||
process.exit(1);
|
||||
}
|
||||
ok({ record: { record_id: "rec_test" } });
|
||||
} else if (args.includes("+record-upsert")) {
|
||||
const payload = JSON.parse(args[args.indexOf("--json") + 1] ?? "{}");
|
||||
if (!/^[a-f0-9]{64}$/.test(payload["原始意图指纹"] ?? "")) {
|
||||
console.error(JSON.stringify({ ok: false, error: { message: "missing owner intent fingerprint" } }));
|
||||
process.exit(1);
|
||||
}
|
||||
ok({ record_id: "rec_test" });
|
||||
} else {
|
||||
console.error(JSON.stringify({ ok: false, error: { message: "unexpected command: " + args.join(" ") } }));
|
||||
process.exit(1);
|
||||
}
|
||||
`;
|
||||
}
|
||||
Reference in New Issue
Block a user