chore: release v1.30.0

This commit is contained in:
coso
2026-05-08 01:05:41 +08:00
parent 15b52e5674
commit 431dc22113
255 changed files with 46141 additions and 20772 deletions
+74 -331
View File
@@ -1,6 +1,10 @@
#!/usr/bin/env node
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import process from "node:process";
import { chromium } from "playwright";
const DEFAULTS = {
appUrl: "http://127.0.0.1:1420/",
@@ -10,18 +14,9 @@ const DEFAULTS = {
intervalMs: 1_000,
};
const INVOKE_TIMEOUT_CEILING_MS = 180_000;
const INVOKE_RETRY_COUNT = 10;
const INVOKE_RETRY_DELAY_MS = 1_000;
const BROWSER_ACTION_RETRY_COUNT = 6;
const BROWSER_ACTION_RETRY_DELAY_MS = 1_000;
const BROWSER_SESSION_RECOVERY_LIMIT = 2;
const POST_HEALTH_SETTLE_MS = 1_500;
const POST_LAUNCH_SETTLE_MS = 1_500;
const DEFAULT_ACTION_TIMEOUT_MS = 45_000;
const ONBOARDING_VERSION = "1.1.0";
const PROMPT_TEXT = "请回复一句:smoke harness";
const SMOKE_PROFILE_KEY = "smoke-agent-runtime-tool-surface-page";
const WORKSPACE_HARNESS_DEBUG_OVERRIDE_KEY =
"lime:debug:workspace-harness-enabled:v1";
const RUNTIME_TOOL_AVAILABILITY_OVERRIDE = {
@@ -142,80 +137,6 @@ function logStage(label) {
console.log(`[smoke:agent-runtime-tool-surface-page] stage=${label}`);
}
function deepClone(value) {
return JSON.parse(JSON.stringify(value));
}
function isTransientInvokeError(error) {
return (
error?.name === "TimeoutError" ||
(error instanceof TypeError && error.message === "fetch failed")
);
}
async function invoke(options, cmd, args) {
const invokeTimeoutMs = Math.min(options.timeoutMs, INVOKE_TIMEOUT_CEILING_MS);
const requestInit = {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({ cmd, args }),
signal: AbortSignal.timeout(invokeTimeoutMs),
};
for (let attempt = 1; attempt <= INVOKE_RETRY_COUNT; attempt += 1) {
try {
const response = await fetch(options.invokeUrl, requestInit);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const payload = await response.json();
if (payload?.error) {
throw new Error(String(payload.error));
}
return payload?.result;
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
if (!isTransientInvokeError(error) || attempt >= INVOKE_RETRY_COUNT) {
if (error?.name === "TimeoutError") {
throw new Error(
`[smoke:agent-runtime-tool-surface-page] ${cmd} 超时,${invokeTimeoutMs}ms 内未收到 DevBridge 响应`,
);
}
throw new Error(
`[smoke:agent-runtime-tool-surface-page] ${cmd} 请求失败: ${detail}`,
);
}
console.warn(
`[smoke:agent-runtime-tool-surface-page] ${cmd} 第 ${attempt} 次请求失败,${INVOKE_RETRY_DELAY_MS}ms 后重试: ${detail}`,
);
await sleep(INVOKE_RETRY_DELAY_MS);
}
}
throw new Error(
`[smoke:agent-runtime-tool-surface-page] ${cmd} 请求失败: unknown error`,
);
}
async function closeSmokeProfileSession(options, profileKey, label) {
try {
await invoke(options, "close_chrome_profile_session", {
profile_key: profileKey,
});
} catch (error) {
console.warn(
`[smoke:agent-runtime-tool-surface-page] ${label}: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
}
async function waitForHealth(options) {
const startedAt = Date.now();
let lastError = null;
@@ -537,136 +458,6 @@ function buildRuntimeSummaryCheckScript() {
})()`;
}
function extractJavascriptValue(actionResult) {
return (
actionResult?.data?.result ??
actionResult?.data?.value ??
actionResult?.data?.result?.result ??
actionResult?.data?.result?.value ??
actionResult?.data?.result ??
null
);
}
function isRetryableBrowserActionFailure(detail) {
return (
typeof detail === "string" &&
(detail.includes("CDP 调试端口不可用") ||
detail.includes("没有可用的 Chrome 会话") ||
detail.includes("未找到 profile_key="))
);
}
async function launchSmokeBrowserSession(options, profileKey) {
const launchResponse = await invoke(options, "launch_browser_session", {
request: {
profile_key: profileKey,
url: options.appUrl,
headless: true,
open_window: false,
// 真实 Lime 页面在 cdp_direct + frames/both 下会持续产出 frame 流,
// 这里会把后续 Runtime.evaluate 挤到超时;页面 smoke 只需要事件流即可。
stream_mode: "events",
},
});
const sessionId = launchResponse?.session?.session_id ?? null;
assert(
typeof sessionId === "string" && sessionId.trim(),
"launch_browser_session 未返回 session.session_id",
);
await sleep(POST_LAUNCH_SETTLE_MS);
return sessionId;
}
async function runBrowserAction(
options,
profileKey,
action,
args = {},
label = action,
recovery,
) {
for (let attempt = 1; attempt <= BROWSER_ACTION_RETRY_COUNT; attempt += 1) {
const result = await invoke(options, "browser_execute_action", {
request: {
profile_key: profileKey,
backend: "cdp_direct",
action,
args,
timeout_ms: DEFAULT_ACTION_TIMEOUT_MS,
},
});
if (result?.success === true) {
return result;
}
const detail = String(result?.error || JSON.stringify(result ?? null));
if (
isRetryableBrowserActionFailure(detail) &&
attempt < BROWSER_ACTION_RETRY_COUNT
) {
if (recovery && recovery.count < BROWSER_SESSION_RECOVERY_LIMIT) {
recovery.count += 1;
console.warn(
`[smoke:agent-runtime-tool-surface-page] browser_execute_action(${label}) 丢失托管 Chrome 会话,尝试第 ${recovery.count} 次重启: ${detail}`,
);
recovery.sessionId = await launchSmokeBrowserSession(
options,
profileKey,
);
continue;
}
console.warn(
`[smoke:agent-runtime-tool-surface-page] browser_execute_action(${label}) 第 ${attempt} 次失败,${BROWSER_ACTION_RETRY_DELAY_MS}ms 后重试: ${detail}`,
);
await sleep(BROWSER_ACTION_RETRY_DELAY_MS);
continue;
}
throw new Error(
`[smoke:agent-runtime-tool-surface-page] browser_execute_action(${label}) 失败: ${detail}`,
);
}
throw new Error(
`[smoke:agent-runtime-tool-surface-page] browser_execute_action(${label}) 失败: unknown error`,
);
}
async function runJavascript(
options,
profileKey,
expression,
label = "javascript",
recovery,
) {
const result = await runBrowserAction(
options,
profileKey,
"javascript",
{
expression,
return_by_value: true,
},
`javascript:${label}`,
recovery,
);
return extractJavascriptValue(result);
}
async function readPageMarkdown(options, profileKey, recovery) {
const result = await runBrowserAction(
options,
profileKey,
"read_page",
{},
"read_page",
recovery,
);
return String(result?.data?.markdown || "");
}
async function waitForCheck(options, label, check) {
const startedAt = Date.now();
let lastValue = null;
@@ -686,6 +477,46 @@ async function waitForCheck(options, label, check) {
);
}
async function launchPlaywrightContext(userDataDir) {
const launchOptions = {
headless: true,
viewport: { width: 1440, height: 960 },
};
try {
return await chromium.launchPersistentContext(userDataDir, {
...launchOptions,
channel: "chrome",
});
} catch (chromeError) {
console.warn(
`[smoke:agent-runtime-tool-surface-page] Chrome channel 启动失败,尝试 Playwright 自带 Chromium: ${
chromeError instanceof Error ? chromeError.message : String(chromeError)
}`,
);
return chromium.launchPersistentContext(userDataDir, launchOptions);
}
}
async function evaluateScript(page, expression) {
return page.evaluate(expression);
}
async function readPageText(page) {
return page.evaluate(() => {
const text = document.body?.innerText || "";
const fieldText = Array.from(document.querySelectorAll("textarea, input"))
.map((element) =>
element instanceof HTMLTextAreaElement ||
element instanceof HTMLInputElement
? element.value
: "",
)
.join("\n");
return `${text}\n${fieldText}`;
});
}
async function main() {
if (typeof fetch !== "function") {
throw new Error("当前 Node 运行时不支持 fetch,请使用 Node 18+");
@@ -695,34 +526,22 @@ async function main() {
logStage("wait-health");
await waitForHealth(options);
await sleep(POST_HEALTH_SETTLE_MS);
const profileKey = SMOKE_PROFILE_KEY;
const browserRecovery = {
count: 0,
sessionId: null,
};
const userDataDir = fs.mkdtempSync(
path.join(os.tmpdir(), `lime-runtime-tool-surface-page-${process.pid}-`),
);
let context = null;
try {
logStage("cleanup-old-profile");
await closeSmokeProfileSession(
options,
profileKey,
"预清理旧 smoke profile 失败",
);
logStage("launch-browser-session");
browserRecovery.sessionId = await launchSmokeBrowserSession(
options,
profileKey,
);
logStage("launch-playwright-page");
context = await launchPlaywrightContext(userDataDir);
const page = context.pages()[0] ?? (await context.newPage());
await page.goto(options.appUrl, { waitUntil: "domcontentloaded" });
logStage("wait-page-storage-ready");
await waitForCheck(options, "Lime 首页 origin 可访问", async () => {
const value = await runJavascript(
options,
profileKey,
const value = await evaluateScript(
page,
buildPageStorageReadyScript(options.appUrl),
"wait-page-storage-ready",
browserRecovery,
);
return {
ok: value?.ok === true,
@@ -731,31 +550,15 @@ async function main() {
});
logStage("bootstrap-harness-storage");
await runJavascript(
options,
profileKey,
buildHarnessBootstrapScript(),
"bootstrap-harness-storage",
browserRecovery,
);
await evaluateScript(page, buildHarnessBootstrapScript());
logStage("refresh-page");
await runBrowserAction(
options,
profileKey,
"refresh_page",
{},
"refresh_page",
browserRecovery,
);
await page.reload({ waitUntil: "domcontentloaded" });
logStage("wait-empty-state");
await waitForCheck(options, "首页空态加载", async () => {
const text = await runJavascript(
options,
profileKey,
const text = await evaluateScript(
page,
'document.body ? document.body.innerText : ""',
"wait-empty-state-text",
browserRecovery,
);
return {
ok:
@@ -768,13 +571,7 @@ async function main() {
logStage("fill-prompt");
await waitForCheck(options, "首页输入框出现", async () => {
const value = await runJavascript(
options,
profileKey,
buildComposerReadyScript(),
"wait-composer-ready",
browserRecovery,
);
const value = await evaluateScript(page, buildComposerReadyScript());
return {
ok: value?.ok === true,
value,
@@ -785,12 +582,9 @@ async function main() {
options,
"首页输入框可写入",
async () => {
const value = await runJavascript(
options,
profileKey,
const value = await evaluateScript(
page,
buildFillPromptScript(PROMPT_TEXT),
"fill-prompt",
browserRecovery,
);
return {
ok: value?.ok === true,
@@ -805,13 +599,7 @@ async function main() {
logStage("wait-send-ready");
const sendReady = await waitForCheck(options, "发送按钮可用", async () => {
const value = await runJavascript(
options,
profileKey,
buildSendReadyScript(),
"wait-send-ready",
browserRecovery,
);
const value = await evaluateScript(page, buildSendReadyScript());
return {
ok: value?.ok === true,
value,
@@ -823,13 +611,7 @@ async function main() {
);
logStage("click-send");
const submitted = await runJavascript(
options,
profileKey,
buildClickSendScript(),
"click-send",
browserRecovery,
);
const submitted = await evaluateScript(page, buildClickSendScript());
assert(
submitted?.ok === true,
`提交输入失败: ${JSON.stringify(submitted ?? null)}`,
@@ -837,12 +619,9 @@ async function main() {
logStage("wait-harness-button");
await waitForCheck(options, "Harness 按钮出现", async () => {
const value = await runJavascript(
options,
profileKey,
const value = await evaluateScript(
page,
buildWorkbenchButtonCheckScript(),
"wait-harness-button",
browserRecovery,
);
return {
ok: value?.hasButton === true,
@@ -851,13 +630,7 @@ async function main() {
});
logStage("open-harness");
const openWorkbench = await runJavascript(
options,
profileKey,
buildOpenWorkbenchScript(),
"open-harness",
browserRecovery,
);
const openWorkbench = await evaluateScript(page, buildOpenWorkbenchScript());
assert(
openWorkbench?.ok === true,
`打开 Harness 失败: ${JSON.stringify(openWorkbench ?? null)}`,
@@ -868,12 +641,9 @@ async function main() {
options,
"Runtime 能力摘要出现",
async () => {
const value = await runJavascript(
options,
profileKey,
const value = await evaluateScript(
page,
buildRuntimeSummaryCheckScript(),
"check-runtime-summary",
browserRecovery,
);
const hasAllRequired = REQUIRED_RUNTIME_SUMMARY_FLAGS.every(
(key) => value?.[key] === true,
@@ -887,49 +657,22 @@ async function main() {
},
);
logStage("read-page-markdown");
const pageMarkdown = await readPageMarkdown(
options,
profileKey,
browserRecovery,
);
logStage("read-page-text");
const pageText = await readPageText(page);
for (const warning of FORBIDDEN_PAGE_WARNINGS) {
assert(
!pageMarkdown.includes(warning),
!pageText.includes(warning),
`真实页面仍出现不应存在的页级告警: ${warning}`,
);
}
console.log(
`[smoke:agent-runtime-tool-surface-page] 通过 session=${browserRecovery.sessionId} profile=${profileKey}`,
);
console.log("[smoke:agent-runtime-tool-surface-page] 通过");
console.log(
`[smoke:agent-runtime-tool-surface-page] summary=${JSON.stringify(summaryFlags)}`,
);
} finally {
if (browserRecovery.sessionId) {
logStage("close-cdp-session");
try {
await invoke(options, "close_cdp_session", {
request: {
session_id: browserRecovery.sessionId,
},
});
} catch (error) {
console.warn(
`[smoke:agent-runtime-tool-surface-page] 清理浏览器会话失败: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
}
logStage("close-profile-session");
await closeSmokeProfileSession(
options,
profileKey,
"关闭 smoke profile 失败",
);
await context?.close().catch(() => undefined);
fs.rmSync(userDataDir, { recursive: true, force: true });
}
}
+13
View File
@@ -19,11 +19,18 @@ const tauriHeadlessConfigPath = path.join(
"tauri.conf.headless.json",
);
const packageJsonPath = path.join(repoRoot, "package.json");
const cliNpmPackageJsonPath = path.join(
repoRoot,
"packages",
"lime-cli-npm",
"package.json",
);
const cargo = readCargoVersions(cargoTomlPath);
const tauriConfig = readJson(tauriConfigPath);
const tauriHeadlessConfig = readJson(tauriHeadlessConfigPath);
const packageJson = readJson(packageJsonPath);
const cliNpmPackageJson = readJson(cliNpmPackageJsonPath);
const sourceVersion = cargo.workspaceVersion;
const issues = [];
@@ -44,6 +51,12 @@ if ((packageJson.version ?? null) !== sourceVersion) {
);
}
if ((cliNpmPackageJson.version ?? null) !== sourceVersion) {
issues.push(
`packages/lime-cli-npm/package.json version (${cliNpmPackageJson.version ?? "missing"}) 与 workspace.version (${sourceVersion ?? "missing"}) 不一致`,
);
}
if ((tauriConfig.version ?? null) !== sourceVersion) {
issues.push(
`src-tauri/tauri.conf.json version (${tauriConfig.version ?? "missing"}) 与 workspace.version (${sourceVersion ?? "missing"}) 不一致`,
+374
View File
@@ -4,6 +4,7 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import process from "node:process";
import zlib from "node:zlib";
import { chromium } from "playwright";
const DEFAULTS = {
@@ -12,10 +13,138 @@ const DEFAULTS = {
invokeUrl: "http://127.0.0.1:3030/invoke",
timeoutMs: 180_000,
intervalMs: 1_000,
analyzer: "default",
projectRoundtrip: true,
};
const ACTION_TIMEOUT_MS = 45_000;
const POST_HEALTH_SETTLE_MS = 1_000;
const WORKER_REFINED_TEXT = "WORKER REFINED TEXT";
const WORKER_OCR_TEXT = "WORKER OCR TEXT";
const WORKER_REFINED_SUBJECT_META = "subject / 置信度 93%";
const WORKER_DEFAULT_SUBJECT_META = "subject / 置信度 94%";
const WORKER_MATTING_SUBJECT_META = "subject / 置信度 94%";
const WORKER_OCR_PRIORITY_TEXT =
"OCR priority: Smoke failing OCR provider -> Smoke empty OCR provider -> Smoke OCR priority browser Worker provider";
const WORKER_CLEAN_PLATE_SOURCE =
"背景修补来源:Simple browser clean plate provider / simple_neighbor_inpaint_v1";
const WORKER_MODEL_SLOT_TEXT = "WORKER OCR TEXT";
const WORKER_MODEL_SLOT_SUBJECT_META = "subject / 置信度 94%";
const WORKER_MODEL_SLOT_CLEAN_PLATE_SOURCE =
"背景修补来源:Simple browser clean plate provider / simple_neighbor_inpaint_v1";
const WORKER_MODEL_SLOT_JSON_EXECUTOR_FIXTURE =
"Analyzer model slots provider JSON executor fixture";
const WORKER_MODEL_SLOT_NATIVE_OCR_JSON_EXECUTOR_FIXTURE =
"Analyzer model slots native OCR JSON executor fixture";
const WORKER_MODEL_SLOT_NATIVE_OCR_PRIORITY =
"OCR priority: Tauri native OCR -> Worker OCR provider via model slot JSON executor";
const WORKER_MODEL_SLOT_EXPORT_RELATIVE_PATH =
".lime/layered-designs/design-canvas-smoke.layered-design";
const ANALYZER_MODES = new Set([
"default",
"worker",
"worker-refined",
"worker-matting",
"worker-ocr",
"worker-ocr-priority",
"worker-clean-plate",
"worker-model-slots",
"worker-model-slots-native-ocr",
"native",
]);
const ANALYZER_BADGE_TEXT = {
default: "默认 analyzer",
native: "Native analyzer 已启用",
worker: "Worker analyzer 已启用",
"worker-refined": "Worker refined analyzer 已启用",
"worker-matting": "Worker subject matting analyzer 已启用",
"worker-ocr": "Worker OCR analyzer 已启用",
"worker-ocr-priority": "Worker OCR priority analyzer 已启用",
"worker-clean-plate": "Worker clean plate analyzer 已启用",
"worker-model-slots": "Worker model slots analyzer 已启用",
"worker-model-slots-native-ocr":
"Worker model slots native OCR analyzer 已启用",
};
const ANALYZER_RESULT_TEXT = {
default: "Worker local heuristic analyzer",
native: "Tauri native heuristic analyzer",
worker: "Worker local heuristic analyzer",
"worker-refined": "Worker local heuristic analyzer",
"worker-matting": "Worker local heuristic analyzer",
"worker-ocr": "Worker local heuristic analyzer",
"worker-ocr-priority": "Worker local heuristic analyzer",
"worker-clean-plate": "Worker local heuristic analyzer",
"worker-model-slots": "Worker local heuristic analyzer",
"worker-model-slots-native-ocr": "Worker local heuristic analyzer",
};
const ANALYZER_EXTRA_CHECK = {
default: {
subjectCandidateName: /[☑☐]\s*主体候选/,
subjectMeta: WORKER_DEFAULT_SUBJECT_META,
cleanPlateSource: WORKER_CLEAN_PLATE_SOURCE,
},
worker: {
subjectCandidateName: /[☑☐]\s*主体候选/,
subjectMeta: WORKER_DEFAULT_SUBJECT_META,
cleanPlateSource: WORKER_CLEAN_PLATE_SOURCE,
},
"worker-refined": {
subjectCandidateName: /[☑☐]\s*主体候选/,
subjectMeta: WORKER_REFINED_SUBJECT_META,
textCandidateName: /[☑☐]\s*标题文字候选/,
text: WORKER_REFINED_TEXT,
},
"worker-matting": {
subjectCandidateName: /[☑☐]\s*主体候选/,
subjectMeta: WORKER_MATTING_SUBJECT_META,
},
"worker-ocr": {
textCandidateName: /[☑☐]\s*标题文字候选/,
text: WORKER_OCR_TEXT,
},
"worker-ocr-priority": {
priorityText: WORKER_OCR_PRIORITY_TEXT,
textCandidateName: /[☑☐]\s*标题文字候选/,
text: WORKER_OCR_TEXT,
},
"worker-clean-plate": {
cleanPlateSource: WORKER_CLEAN_PLATE_SOURCE,
},
"worker-model-slots": {
fixtureText: WORKER_MODEL_SLOT_JSON_EXECUTOR_FIXTURE,
subjectCandidateName: /[☑☐]\s*主体候选/,
subjectMeta: WORKER_MODEL_SLOT_SUBJECT_META,
textCandidateName: /[☑☐]\s*标题文字候选/,
text: WORKER_MODEL_SLOT_TEXT,
cleanPlateSource: WORKER_MODEL_SLOT_CLEAN_PLATE_SOURCE,
capabilityText: "3 项 / 均生产可用",
modelSlotExecutionTexts: [
"模型执行",
"主体抠图:smoke-subject-matting-slot-v1 / attempt 1/1 / succeeded",
"背景修补:smoke-clean-plate-slot-v1 / attempt 1/1 / succeeded",
"OCR TextLayer:smoke-ocr-slot-v1 / attempt 1/1 / succeeded",
],
},
"worker-model-slots-native-ocr": {
fixtureText: WORKER_MODEL_SLOT_NATIVE_OCR_JSON_EXECUTOR_FIXTURE,
priorityText: WORKER_MODEL_SLOT_NATIVE_OCR_PRIORITY,
subjectCandidateName: /[☑☐]\s*主体候选/,
subjectMeta: WORKER_MODEL_SLOT_SUBJECT_META,
textCandidateName: /[☑☐]\s*标题文字候选/,
text: WORKER_MODEL_SLOT_TEXT,
cleanPlateSource: WORKER_MODEL_SLOT_CLEAN_PLATE_SOURCE,
capabilityText: "3 项 / 均生产可用",
modelSlotExecutionTexts: [
"模型执行",
"主体抠图:smoke-subject-matting-slot-v1 / attempt 1/1 / succeeded",
"背景修补:smoke-clean-plate-slot-v1 / attempt 1/1 / succeeded",
"OCR TextLayer:smoke-ocr-slot-v1 / attempt 1/1 / succeeded",
],
},
};
function printHelp() {
console.log(`
@@ -34,6 +163,9 @@ Lime Design Canvas Smoke
--invoke-url <url> DevBridge invoke 地址,默认 http://127.0.0.1:3030/invoke
--timeout-ms <ms> 总超时,默认 180000
--interval-ms <ms> 轮询间隔,默认 1000
--analyzer <mode> analyzer 注入模式:default / worker / worker-refined / worker-matting / worker-ocr / worker-ocr-priority / worker-clean-plate / worker-model-slots / worker-model-slots-native-ocr / native,默认 default(产品默认 worker-first)
--project-roundtrip 上传拆层前验证 prompt seed 工程保存与重新打开(默认开启)
--skip-project-roundtrip 跳过工程保存/重新打开,仅用于定位非持久化链路问题
-h, --help 显示帮助
`);
}
@@ -74,10 +206,26 @@ function parseArgs(argv) {
continue;
}
if (arg === "--analyzer" && argv[index + 1]) {
options.analyzer = String(argv[index + 1]).trim();
index += 1;
continue;
}
if (arg === "--help" || arg === "-h") {
printHelp();
process.exit(0);
}
if (arg === "--project-roundtrip") {
options.projectRoundtrip = true;
continue;
}
if (arg === "--skip-project-roundtrip") {
options.projectRoundtrip = false;
continue;
}
}
if (!Number.isFinite(options.timeoutMs) || options.timeoutMs < 30_000) {
@@ -89,6 +237,11 @@ function parseArgs(argv) {
if (!options.appUrl || !options.healthUrl || !options.invokeUrl) {
throw new Error("--app-url、--health-url、--invoke-url 均不能为空");
}
if (!ANALYZER_MODES.has(options.analyzer)) {
throw new Error(
"--analyzer 必须是 worker、worker-refined、worker-matting、worker-ocr、worker-ocr-priority、worker-clean-plate、worker-model-slots、worker-model-slots-native-ocr、native 或 default",
);
}
return options;
}
@@ -103,6 +256,68 @@ function assert(condition, message) {
}
}
function crc32(buffer) {
let crc = 0xffffffff;
for (const byte of buffer) {
crc ^= byte;
for (let bit = 0; bit < 8; bit += 1) {
crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0);
}
}
return (crc ^ 0xffffffff) >>> 0;
}
function pngChunk(type, data) {
const typeBuffer = Buffer.from(type, "ascii");
const length = Buffer.alloc(4);
length.writeUInt32BE(data.length, 0);
const checksum = Buffer.alloc(4);
checksum.writeUInt32BE(crc32(Buffer.concat([typeBuffer, data])), 0);
return Buffer.concat([length, typeBuffer, data, checksum]);
}
function createSmokeFlatImagePngBuffer(width = 360, height = 560) {
const header = Buffer.alloc(13);
header.writeUInt32BE(width, 0);
header.writeUInt32BE(height, 4);
header[8] = 8; // bit depth
header[9] = 6; // RGBA
const rowSize = width * 4 + 1;
const raw = Buffer.alloc(rowSize * height);
for (let y = 0; y < height; y += 1) {
const rowOffset = y * rowSize;
raw[rowOffset] = 0;
for (let x = 0; x < width; x += 1) {
const offset = rowOffset + 1 + x * 4;
const inHeadline = y > height * 0.06 && y < height * 0.2;
const inSubject =
x > width * 0.18 &&
x < width * 0.82 &&
y > height * 0.24 &&
y < height * 0.82;
const inAccent = x > width * 0.68 && y > height * 0.7;
raw[offset] = inSubject ? 38 : inHeadline ? 18 : inAccent ? 245 : 238;
raw[offset + 1] = inSubject ? 86 : inHeadline ? 24 : inAccent ? 163 : 242;
raw[offset + 2] = inSubject ? 142 : inHeadline ? 42 : inAccent ? 72 : 234;
raw[offset + 3] = 255;
}
}
return Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
pngChunk("IHDR", header),
pngChunk("IDAT", zlib.deflateSync(raw)),
pngChunk("IEND", Buffer.alloc(0)),
]);
}
function logStage(label) {
console.log(`[smoke:design-canvas] stage=${label}`);
}
@@ -200,10 +415,54 @@ async function resolveDefaultWorkspace(options) {
};
}
async function assertWorkerModelSlotsManifest(page, workspace) {
assert(workspace.rootPath, "worker-model-slots manifest 断言缺少 workspace rootPath");
const output = await page.evaluate(
async ({ projectRootPath, exportDirectoryRelativePath }) => {
const { readLayeredDesignProjectExport } = await import(
"/src/lib/api/layeredDesignProject.ts"
);
return readLayeredDesignProjectExport({
projectRootPath,
exportDirectoryRelativePath,
});
},
{
projectRootPath: workspace.rootPath,
exportDirectoryRelativePath: WORKER_MODEL_SLOT_EXPORT_RELATIVE_PATH,
},
);
const manifestJson = pickStringField(output, "manifestJson", "manifest_json");
assert(manifestJson, "worker-model-slots 工程导出缺少 manifestJson");
const manifest = JSON.parse(manifestJson);
const slots = Array.isArray(manifest?.analyzerModelSlots)
? manifest.analyzerModelSlots
: [];
const slotIds = slots.map((slot) => String(slot?.config?.id ?? ""));
assert(
slotIds.includes("smoke-subject-matting-slot") &&
slotIds.includes("smoke-clean-plate-slot") &&
slotIds.includes("smoke-ocr-slot"),
`worker-model-slots manifest 缺少 analyzerModelSlots: ${JSON.stringify(
slotIds,
)}`,
);
assert(
slots.every((slot) => slot?.readiness?.valid === true),
`worker-model-slots manifest readiness 未全部通过: ${JSON.stringify(
slots.map((slot) => slot?.readiness),
)}`,
);
}
function buildSmokeUrl(options, workspace) {
const url = new URL("/design-canvas-smoke", options.appUrl);
url.searchParams.set("projectRootPath", workspace.rootPath);
url.searchParams.set("projectId", workspace.projectId);
url.searchParams.set("analyzer", options.analyzer);
return url.toString();
}
@@ -277,6 +536,11 @@ async function runPageFlow(options, smokeUrl) {
await waitForText(page, "smoke 标题", "canvas:design 专属 GUI Smoke");
await waitForText(page, "artifact 类型", "canvas:design");
await waitForText(page, "事实源标记", "LayeredDesignDocument");
await waitForText(
page,
"analyzer 标记",
ANALYZER_BADGE_TEXT[options.analyzer],
);
await waitForText(page, "画布标题", "Smoke 图层设计海报");
await waitForText(page, "图层栏", "图层");
await waitForText(page, "属性栏", "属性");
@@ -301,6 +565,116 @@ async function runPageFlow(options, smokeUrl) {
timeout: ACTION_TIMEOUT_MS,
});
if (options.projectRoundtrip) {
logStage("project-roundtrip-save-open");
await page.getByRole("button", { name: "导出设计工程", exact: true }).click({
timeout: ACTION_TIMEOUT_MS,
});
await waitForText(page, "工程目录保存结果", "已保存图层设计工程");
await waitForText(page, "工程目录保存路径", "design-canvas-smoke.layered-design");
if (
options.analyzer === "worker-model-slots" ||
options.analyzer === "worker-model-slots-native-ocr"
) {
const smokeUrlObject = new URL(smokeUrl);
await assertWorkerModelSlotsManifest(page, {
rootPath: smokeUrlObject.searchParams.get("projectRootPath") ?? "",
});
}
await page.getByRole("button", { name: "打开最近工程", exact: true }).click({
timeout: ACTION_TIMEOUT_MS,
});
await waitForText(page, "工程目录恢复结果", "已打开图层设计工程");
await waitForText(page, "恢复后画布标题", "Smoke 图层设计海报");
await waitForText(page, "恢复后图层栏", "主标题");
}
logStage("upload-flat-image-extraction");
await page
.locator('[data-testid="design-canvas-flat-image-input"]')
.setInputFiles({
name: "smoke-flat-image.png",
mimeType: "image/png",
buffer: createSmokeFlatImagePngBuffer(),
});
await waitForText(page, "上传扁平图结果", "已载入扁平图 draft");
const expectedAnalyzerResult = ANALYZER_RESULT_TEXT[options.analyzer];
if (expectedAnalyzerResult) {
await waitForText(
page,
"analyzer 执行结果",
expectedAnalyzerResult,
);
}
const extraCheck = ANALYZER_EXTRA_CHECK[options.analyzer];
if (extraCheck) {
if (extraCheck.fixtureText) {
await waitForText(
page,
"Worker model slots JSON executor fixture",
extraCheck.fixtureText,
);
}
if (extraCheck.priorityText) {
await waitForText(
page,
"Worker OCR priority provider 标记",
extraCheck.priorityText,
);
}
if (extraCheck.subjectCandidateName && extraCheck.subjectMeta) {
await page
.getByRole("button", { name: extraCheck.subjectCandidateName })
.click({ timeout: ACTION_TIMEOUT_MS });
await waitForText(
page,
"Worker subject matting 输出",
extraCheck.subjectMeta,
);
}
if (extraCheck.textCandidateName && extraCheck.text) {
await page
.getByRole("button", { name: extraCheck.textCandidateName })
.click({ timeout: ACTION_TIMEOUT_MS });
await waitForText(
page,
"Worker refined TextLayer 输出",
extraCheck.text,
);
}
if (extraCheck.cleanPlateSource) {
await waitForText(
page,
"Worker clean plate provider 来源",
extraCheck.cleanPlateSource,
);
}
if (extraCheck.capabilityText) {
await waitForText(
page,
"Worker model slots capability 矩阵",
extraCheck.capabilityText,
);
}
if (extraCheck.modelSlotExecutionTexts) {
for (const text of extraCheck.modelSlotExecutionTexts) {
await waitForText(page, "Worker model slots 执行证据", text);
}
}
}
await waitForText(page, "拆层确认面板", "拆层确认");
await waitForText(page, "进入编辑入口", "进入图层编辑");
await waitForText(page, "候选图层", "候选图层");
await page.getByRole("button", { name: "进入图层编辑", exact: true }).click({
timeout: ACTION_TIMEOUT_MS,
});
await page
.getByRole("button", { name: "进入图层编辑", exact: true })
.waitFor({ state: "hidden", timeout: ACTION_TIMEOUT_MS });
await waitForText(page, "确认后属性面板", "位置与尺寸");
if (consoleErrors.length > 0) {
throw new Error(
`[smoke:design-canvas] 页面存在 ${consoleErrors.length} 条 console error: ${JSON.stringify(
+10 -3
View File
@@ -350,6 +350,13 @@ async function clickScopedButton(page, { scope, text, ariaLabel, index = 0 }) {
}
}
async function openKnowledgePageFromMainNav(page) {
await clickScopedButton(page, {
scope: '[data-testid="app-sidebar-main-nav"]',
ariaLabel: "项目资料",
});
}
async function waitForKnowledgePack(options, label, matcher) {
const startedAt = Date.now();
let lastError = null;
@@ -557,7 +564,7 @@ async function runPlaywrightGuiFlow(options) {
await clickPageControl(page, { ariaLabel: "关闭文件管理器" });
logStage("open-knowledge-page");
await clickPageControl(page, { ariaLabel: "项目资料" });
await openKnowledgePageFromMainNav(page);
logStage("wait-knowledge-overview");
await waitForPageText(
@@ -606,7 +613,7 @@ async function runPlaywrightGuiFlow(options) {
}
logStage("return-knowledge-before-agent-result");
await clickPageControl(page, { ariaLabel: "项目资料" });
await openKnowledgePageFromMainNav(page);
logStage("prepare-agent-result");
await seedAgentResultForKnowledgeCapture(page, options);
@@ -636,7 +643,7 @@ async function runPlaywrightGuiFlow(options) {
);
logStage("return-knowledge-page");
await clickPageControl(page, { ariaLabel: "项目资料" });
await openKnowledgePageFromMainNav(page);
logStage("wait-captured-agent-result");
await waitForPageText(
+31 -20
View File
@@ -1,7 +1,8 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { execFileSync } from "node:child_process";
import process from "node:process";
import { execFile } from "node:child_process";
import { afterEach, describe, expect, it } from "vitest";
const repoRoot = process.cwd();
@@ -29,8 +30,10 @@ function writeText(filePath: string, value: string) {
fs.writeFileSync(filePath, value, "utf8");
}
function sleepMs(durationMs: number) {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, durationMs);
function delayMs(durationMs: number) {
return new Promise<void>((resolve) => {
setTimeout(resolve, durationMs);
});
}
function createHarnessManifest(caseDir: string) {
@@ -137,16 +140,24 @@ function createReplayFixture(tempRoot: string) {
}
function runNodeScript(scriptRelativePath: string, args: string[]) {
const output = execFileSync(
process.execPath,
[path.join(repoRoot, scriptRelativePath), ...args],
{
cwd: repoRoot,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
},
);
return JSON.parse(output);
return new Promise<any>((resolve, reject) => {
execFile(
process.execPath,
[path.join(repoRoot, scriptRelativePath), ...args],
{
cwd: repoRoot,
encoding: "utf8",
},
(error, stdout) => {
if (error) {
reject(error);
return;
}
resolve(JSON.parse(stdout));
},
);
});
}
afterEach(() => {
@@ -159,7 +170,7 @@ afterEach(() => {
});
describe("Harness eval history window", () => {
it("history-record 应记录并裁剪历史窗口,trend 应复用该目录", () => {
it("history-record 应记录并裁剪历史窗口,trend 应复用该目录", async () => {
const tempRoot = createTempRoot();
const caseDir = createReplayFixture(tempRoot);
const manifestPath = path.join(tempRoot, "manifest.json");
@@ -167,7 +178,7 @@ describe("Harness eval history window", () => {
writeJson(manifestPath, createHarnessManifest(caseDir));
runNodeScript("scripts/harness-eval-history-record.mjs", [
await runNodeScript("scripts/harness-eval-history-record.mjs", [
"--format",
"json",
"--manifest",
@@ -177,8 +188,8 @@ describe("Harness eval history window", () => {
"--retain",
"2",
]);
sleepMs(10);
runNodeScript("scripts/harness-eval-history-record.mjs", [
await delayMs(10);
await runNodeScript("scripts/harness-eval-history-record.mjs", [
"--format",
"json",
"--manifest",
@@ -188,8 +199,8 @@ describe("Harness eval history window", () => {
"--retain",
"2",
]);
sleepMs(10);
runNodeScript("scripts/harness-eval-history-record.mjs", [
await delayMs(10);
await runNodeScript("scripts/harness-eval-history-record.mjs", [
"--format",
"json",
"--manifest",
@@ -206,7 +217,7 @@ describe("Harness eval history window", () => {
.sort();
expect(historyFiles).toHaveLength(2);
const report = runNodeScript("scripts/harness-eval-trend-report.mjs", [
const report = await runNodeScript("scripts/harness-eval-trend-report.mjs", [
"--format",
"json",
"--manifest",
@@ -0,0 +1,239 @@
export function buildReadonlyHttpApiGeneratedFiles({
includeFixture = true,
includeExpectedOutput = true,
includeFixtureInput = true,
includeSessionAuthorization = true,
includeCredentialReference = true,
includeExecutionPreflight = true,
includeDryRunEntry = true,
includeDryRunExpectedOutputBinding = true,
includeDryRunMismatch = false,
includeNetworkedDryRun = false,
includeCredentialHeader = false,
} = {}) {
const files = [
{
relativePath: "SKILL.md",
content: [
"---",
"name: 只读 HTTP API 每日报告",
"description: 把公开只读 HTTP API 响应整理成 Markdown 趋势摘要。",
"---",
"",
"# 只读 HTTP API 每日报告",
"",
"## 何时使用",
"当用户需要读取公开只读 API 并生成趋势报告时使用。",
"",
"## 输入",
"- endpoint: 只读 API 地址。",
"- fixture_path: P6 dry-run 必须提供的本地 fixture 路径。",
"",
"## 执行步骤",
"1. 仅允许 GET / fixture dry-run。",
"2. 不发送 POST / PUT / PATCH / DELETE。",
"3. 基于响应或 fixture 生成 Markdown 趋势摘要。",
"",
"## 输出",
"- markdown_report: Markdown 趋势摘要。",
"",
"## 权限边界",
"只读 HTTP API 访问;不保存 token,不发布,不删除,不写外部系统。",
].join("\n"),
},
{
relativePath: "contract/input.schema.json",
content: JSON.stringify(
{
type: "object",
required: ["endpoint"],
properties: {
endpoint: { type: "string", format: "uri" },
...(includeFixtureInput ? { fixture_path: { type: "string" } } : {}),
},
additionalProperties: false,
},
null,
2,
),
},
{
relativePath: "contract/output.schema.json",
content: JSON.stringify(
{
type: "object",
required: ["markdown_report"],
properties: {
markdown_report: { type: "string" },
evidence_notes: { type: "array", items: { type: "string" } },
},
additionalProperties: false,
},
null,
2,
),
},
{
relativePath: "examples/input.sample.json",
content: JSON.stringify(
includeFixtureInput
? {
endpoint: "https://api.example.test/metrics",
fixture_path: "tests/fixture.json",
}
: {
endpoint: "https://api.example.test/metrics",
},
null,
2,
),
},
{
relativePath: "scripts/README.md",
content: [
"# 只读 HTTP API wrapper",
"",
"P6 第一刀不发真实 HTTP 请求;只验证 GET / read-only API draft 的权限 gate。",
"接入真实网络前必须继续使用 fixture dry-run、用户配置、session 授权和 evidence 记录。",
].join("\n"),
},
];
if (includeFixture) {
files.splice(4, 0, {
relativePath: "tests/fixture.json",
content: JSON.stringify(
{
metrics: [
{ label: "workflow", value: 42 },
{ label: "approval", value: 18 },
{ label: "audit", value: 12 },
],
},
null,
2,
),
});
}
if (includeExpectedOutput) {
files.splice(includeFixture ? 5 : 4, 0, {
relativePath: "tests/expected-output.json",
content: JSON.stringify(
{
markdown_report: "# 趋势摘要\n\n- workflow: 42\n- approval: 18\n- audit: 12",
evidence_notes: ["fixture dry-run only"],
},
null,
2,
),
});
}
if (includeSessionAuthorization) {
const insertIndex = files.findIndex((file) => file.relativePath === "scripts/README.md");
files.splice(insertIndex === -1 ? files.length : insertIndex, 0, {
relativePath: "policy/readonly-http-session.json",
content: JSON.stringify(
{
mode: "session_required",
access: "read-only",
allowed_methods: ["GET"],
credential_policy: "no_generated_credentials",
credential_source: "user_session_config",
...(includeCredentialReference
? {
credential_reference: {
scope: "session",
source: "user_session_config",
required: false,
reference_id: "readonly_api_session",
},
}
: {}),
...(includeExecutionPreflight
? {
execution_preflight: {
mode: "approval_request",
endpoint_source: "runtime_input",
method: "GET",
...(includeCredentialReference
? { credential_reference_id: "readonly_api_session" }
: {}),
evidence_schema: [
"request_url_hash",
"request_method",
"response_status",
"response_sha256",
"executed_at",
],
},
}
: {}),
evidence: [
"request_url_hash",
"response_status",
"response_sha256",
"fixture_fallback",
],
},
null,
2,
),
});
}
if (includeDryRunEntry) {
files.push({
relativePath: "scripts/dry-run.mjs",
content: includeNetworkedDryRun
? 'await fetch("https://api.example.test/metrics", { method: "GET" });'
: [
"import fs from 'node:fs';",
"const input = JSON.parse(fs.readFileSync('examples/input.sample.json', 'utf8'));",
"const fixture = JSON.parse(fs.readFileSync(input.fixture_path, 'utf8'));",
...(includeDryRunExpectedOutputBinding
? ["const expected = JSON.parse(fs.readFileSync('tests/expected-output.json', 'utf8'));"]
: []),
"const metrics = Array.isArray(fixture.metrics) ? fixture.metrics : [];",
"const markdown = ['# 趋势摘要', '', ...metrics.map((item) => `- ${item.label}: ${item.value}`)].join('\\n');",
includeDryRunMismatch
? "const actual = { markdown_report: '# 趋势摘要\\n\\n- mismatch: 0', evidence_notes: ['fixture dry-run only'] };"
: "const actual = { markdown_report: markdown, evidence_notes: ['fixture dry-run only'] };",
...(includeDryRunExpectedOutputBinding
? [
"if (actual.markdown_report !== expected.markdown_report) throw new Error('dry-run output mismatch');",
]
: []),
"console.log(JSON.stringify(actual));",
].join("\n"),
});
}
if (includeCredentialHeader) {
files.push({
relativePath: "scripts/client.ts",
content:
'await fetch(endpoint, { method: "GET", headers: { Authorization: `Bearer ${token}` } });',
});
}
return files;
}
export function buildReadonlyHttpApiCreateRequest(
workspaceRoot,
permissionSummary,
options = {},
) {
return {
workspaceRoot,
name: "只读 HTTP API 每日报告",
description: "把公开只读 HTTP API 响应整理成 Markdown 趋势摘要。",
userGoal: "每天 9 点读取公开只读 API 或 fixture,生成 Markdown 趋势摘要。",
sourceKind: "api",
sourceRefs: ["docs/exec-plans/skill-forge-readonly-http-api-p7-plan.md"],
permissionSummary,
generatedFiles: buildReadonlyHttpApiGeneratedFiles(options),
};
}
@@ -0,0 +1,159 @@
import { describe, expect, it } from "vitest";
import {
buildReadonlyHttpApiCreateRequest,
buildReadonlyHttpApiGeneratedFiles,
} from "./readonly-http-api-draft-template.mjs";
function contentByPath(files: Array<{ relativePath: string; content: string }>) {
return new Map(files.map((file) => [file.relativePath, file.content]));
}
describe("readonly-http-api-draft-template", () => {
it("默认生成 P6 verification 可接受的只读 HTTP/API 草案文件", () => {
const files = buildReadonlyHttpApiGeneratedFiles();
const contents = contentByPath(files);
expect([...contents.keys()]).toEqual([
"SKILL.md",
"contract/input.schema.json",
"contract/output.schema.json",
"examples/input.sample.json",
"tests/fixture.json",
"tests/expected-output.json",
"policy/readonly-http-session.json",
"scripts/README.md",
"scripts/dry-run.mjs",
]);
const inputSchema = JSON.parse(
contents.get("contract/input.schema.json") || "{}",
);
const sampleInput = JSON.parse(
contents.get("examples/input.sample.json") || "{}",
);
expect(inputSchema.properties.fixture_path.type).toBe("string");
expect(sampleInput.fixture_path).toBe("tests/fixture.json");
expect(contents.get("tests/expected-output.json")).toContain(
"fixture dry-run only",
);
const sessionPolicy = JSON.parse(
contents.get("policy/readonly-http-session.json") || "{}",
);
expect(sessionPolicy).toMatchObject({
mode: "session_required",
access: "read-only",
allowed_methods: ["GET"],
credential_policy: "no_generated_credentials",
credential_reference: {
scope: "session",
source: "user_session_config",
required: false,
reference_id: "readonly_api_session",
},
execution_preflight: {
mode: "approval_request",
endpoint_source: "runtime_input",
method: "GET",
credential_reference_id: "readonly_api_session",
},
});
expect(sessionPolicy.execution_preflight.evidence_schema).toContain(
"response_sha256",
);
expect(sessionPolicy.evidence).toContain("response_sha256");
expect(contents.get("scripts/dry-run.mjs")).toContain(
"tests/expected-output.json",
);
expect(contents.get("scripts/dry-run.mjs")).toContain("console.log");
expect(contents.get("scripts/dry-run.mjs")).not.toContain("fetch(");
expect(contents.get("scripts/dry-run.mjs")).not.toContain("https://");
expect(contents.get("SKILL.md")).toContain("不保存 token");
});
it("生成 capability_draft_create 请求时固定 api source 与只读权限输入", () => {
const request = buildReadonlyHttpApiCreateRequest("/tmp/work", [
"Level 0 只读发现",
"允许只读 HTTP API GET 请求,不做外部写操作",
]);
expect(request).toMatchObject({
workspaceRoot: "/tmp/work",
sourceKind: "api",
sourceRefs: ["docs/exec-plans/skill-forge-readonly-http-api-p7-plan.md"],
permissionSummary: [
"Level 0 只读发现",
"允许只读 HTTP API GET 请求,不做外部写操作",
],
});
expect(request.generatedFiles.map((file) => file.relativePath)).toContain(
"scripts/dry-run.mjs",
);
});
it("负向样例开关能稳定构造各个 P6 gate 的失败草案", () => {
const missingFixtureInput = contentByPath(
buildReadonlyHttpApiGeneratedFiles({ includeFixtureInput: false }),
);
expect(
missingFixtureInput.get("contract/input.schema.json"),
).not.toContain("fixture_path");
expect(missingFixtureInput.get("examples/input.sample.json")).not.toContain(
"fixture_path",
);
expect(
buildReadonlyHttpApiGeneratedFiles({ includeExpectedOutput: false }).some(
(file) => file.relativePath === "tests/expected-output.json",
),
).toBe(false);
expect(
buildReadonlyHttpApiGeneratedFiles({ includeDryRunEntry: false }).some(
(file) => file.relativePath === "scripts/dry-run.mjs",
),
).toBe(false);
expect(
buildReadonlyHttpApiGeneratedFiles({
includeSessionAuthorization: false,
}).some((file) => file.relativePath === "policy/readonly-http-session.json"),
).toBe(false);
expect(
contentByPath(
buildReadonlyHttpApiGeneratedFiles({
includeCredentialReference: false,
}),
)
.get("policy/readonly-http-session.json")
?.includes("credential_reference"),
).toBe(false);
expect(
contentByPath(
buildReadonlyHttpApiGeneratedFiles({
includeExecutionPreflight: false,
}),
)
.get("policy/readonly-http-session.json")
?.includes("execution_preflight"),
).toBe(false);
expect(
contentByPath(
buildReadonlyHttpApiGeneratedFiles({
includeDryRunExpectedOutputBinding: false,
}),
).get("scripts/dry-run.mjs"),
).not.toContain("tests/expected-output.json");
expect(
contentByPath(
buildReadonlyHttpApiGeneratedFiles({ includeNetworkedDryRun: true }),
).get("scripts/dry-run.mjs"),
).toContain("fetch(");
expect(
contentByPath(
buildReadonlyHttpApiGeneratedFiles({ includeDryRunMismatch: true }),
).get("scripts/dry-run.mjs"),
).toContain("mismatch: 0");
expect(
contentByPath(
buildReadonlyHttpApiGeneratedFiles({ includeCredentialHeader: true }),
).get("scripts/client.ts"),
).toContain("Authorization");
});
});
+420
View File
@@ -0,0 +1,420 @@
#!/usr/bin/env node
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import process from "node:process";
const DEFAULTS = {
healthUrl: "http://127.0.0.1:3030/health",
invokeUrl: "http://127.0.0.1:3030/invoke",
timeoutMs: 120_000,
intervalMs: 1_000,
workspaceRoot: "",
cleanup: false,
json: false,
};
function printHelp() {
console.log(`
Prompt-to-Artifact Smoke
用途:
用临时 workspace 走通 read-only Capability Draft -> verification -> registration -> discovery -> binding readiness。
该 smoke 只验证 P5 第一刀的 prompt-to-artifact 前半链,不执行外部 CLI、不联网、不创建长期自动化。
用法:
node scripts/prompt-to-artifact-smoke.mjs [选项]
选项:
--health-url <url> DevBridge 健康检查地址,默认 http://127.0.0.1:3030/health
--invoke-url <url> DevBridge invoke 地址,默认 http://127.0.0.1:3030/invoke
--timeout-ms <ms> 等待 DevBridge 超时,默认 120000
--interval-ms <ms> 轮询间隔,默认 1000
--workspace-root <dir> 指定 smoke workspace;默认创建临时目录
--cleanup 成功或失败后删除本脚本创建的临时 workspace
--json 只输出 JSON summary
-h, --help 显示帮助
示例:
node scripts/prompt-to-artifact-smoke.mjs --timeout-ms 120000
`);
}
function parseArgs(argv) {
const options = { ...DEFAULTS };
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--health-url" && argv[index + 1]) {
options.healthUrl = String(argv[++index]).trim();
continue;
}
if (arg === "--invoke-url" && argv[index + 1]) {
options.invokeUrl = String(argv[++index]).trim();
continue;
}
if (arg === "--timeout-ms" && argv[index + 1]) {
options.timeoutMs = Number(argv[++index]);
continue;
}
if (arg === "--interval-ms" && argv[index + 1]) {
options.intervalMs = Number(argv[++index]);
continue;
}
if (arg === "--workspace-root" && argv[index + 1]) {
options.workspaceRoot = path.resolve(String(argv[++index]).trim());
continue;
}
if (arg === "--cleanup") {
options.cleanup = true;
continue;
}
if (arg === "--json") {
options.json = true;
continue;
}
if (arg === "--help" || arg === "-h") {
printHelp();
process.exit(0);
}
throw new Error(`未知参数: ${arg}`);
}
if (!Number.isFinite(options.timeoutMs) || options.timeoutMs < 1_000) {
throw new Error("--timeout-ms 必须是 >= 1000 的数字");
}
if (!Number.isFinite(options.intervalMs) || options.intervalMs < 100) {
throw new Error("--interval-ms 必须是 >= 100 的数字");
}
return options;
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
function pickString(target, ...keys) {
for (const key of keys) {
const value = target?.[key];
if (typeof value === "string" && value.trim()) {
return value.trim();
}
}
return "";
}
function pickArray(target, ...keys) {
for (const key of keys) {
const value = target?.[key];
if (Array.isArray(value)) {
return value;
}
}
return [];
}
async function checkHealth(url) {
const response = await fetch(url, { method: "GET" });
const text = await response.text();
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return text ? JSON.parse(text) : null;
}
async function waitForHealth(options) {
const startedAt = Date.now();
let lastError = null;
while (Date.now() - startedAt < options.timeoutMs) {
try {
const payload = await checkHealth(options.healthUrl);
if (!options.json) {
console.log(`[prompt-to-artifact:p5-smoke] DevBridge 已就绪 (${Date.now() - startedAt}ms)`);
}
return payload;
} catch (error) {
lastError = error;
await sleep(options.intervalMs);
}
}
throw new Error(
`DevBridge 未就绪,请先启动 npm run tauri:dev:headless。最后错误: ${
lastError instanceof Error ? lastError.message : String(lastError)
}`,
);
}
async function invoke(invokeUrl, cmd, args = {}) {
const response = await fetch(invokeUrl, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ cmd, args }),
});
if (!response.ok) {
throw new Error(`${cmd} HTTP ${response.status}: ${response.statusText}`);
}
const payload = await response.json();
if (payload?.error) {
throw new Error(`${cmd} failed: ${payload.error}`);
}
return payload?.result;
}
function buildGeneratedFiles() {
return [
{
relativePath: "SKILL.md",
content: [
"---",
"name: 只读 CLI 每日报告",
"description: 把只读 CLI 或 fixture 输出整理成 Markdown 趋势摘要。",
"---",
"",
"# 只读 CLI 每日报告",
"",
"## 何时使用",
"当用户需要把本地只读 CLI 输出、日志片段或 fixture 数据整理为每日趋势报告时使用。",
"",
"## 输入",
"- topic: 报告主题。",
"- fixture_path: 可选,只读 fixture 路径。",
"",
"## 执行步骤",
"1. 读取用户提供的只读 CLI 输出或 fixture 内容。",
"2. 提炼趋势、异常、代表样例和下一步建议。",
"3. 生成 Markdown 报告。",
"",
"## 输出",
"- markdown_report: Markdown 趋势摘要。",
"",
"## 权限边界",
"只读读取用户提供内容;不安装依赖,不联网,不发布,不删除文件。",
].join("\n"),
},
{
relativePath: "contract/input.schema.json",
content: JSON.stringify(
{
type: "object",
required: ["topic"],
properties: {
topic: { type: "string" },
fixture_path: { type: "string" },
},
additionalProperties: false,
},
null,
2,
),
},
{
relativePath: "contract/output.schema.json",
content: JSON.stringify(
{
type: "object",
required: ["markdown_report"],
properties: {
markdown_report: { type: "string" },
evidence_notes: { type: "array", items: { type: "string" } },
},
additionalProperties: false,
},
null,
2,
),
},
{
relativePath: "examples/input.sample.json",
content: JSON.stringify(
{
topic: "AI Agent adoption",
fixture_path: "tests/fixture.json",
},
null,
2,
),
},
{
relativePath: "tests/fixture.json",
content: JSON.stringify(
{
rows: [
{ label: "Agent workflow", mentions: 42 },
{ label: "Human approval", mentions: 18 },
{ label: "Evidence audit", mentions: 12 },
],
},
null,
2,
),
},
{
relativePath: "scripts/README.md",
content: [
"# Wrapper 说明",
"",
"P5 第一刀不执行真实 CLI。后续接入真实只读 CLI 时,必须继续保持只读参数、fixture dry-run 和人工确认边界。",
].join("\n"),
},
];
}
async function prepareWorkspace(options) {
if (options.workspaceRoot) {
await fs.mkdir(options.workspaceRoot, { recursive: true });
return { workspaceRoot: options.workspaceRoot, createdTemp: false };
}
const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "lime-prompt-artifact-p5-"));
return { workspaceRoot, createdTemp: true };
}
async function runSmoke(options) {
await waitForHealth(options);
const workspace = await prepareWorkspace(options);
const startedAt = new Date().toISOString();
try {
const createRequest = {
workspaceRoot: workspace.workspaceRoot,
name: "只读 CLI 每日报告",
description: "把只读 CLI 或 fixture 输出整理成 Markdown 趋势摘要。",
userGoal:
"每天 9 点读取只读 CLI 或 fixture 输出,生成 Markdown 趋势摘要,失败时提示我检查配置。",
sourceKind: "cli",
sourceRefs: ["docs/exec-plans/skill-forge-prompt-to-artifact-p5-plan.md"],
permissionSummary: ["Level 0 只读发现", "Level 1 draft-scoped write"],
generatedFiles: buildGeneratedFiles(),
};
const draft = await invoke(options.invokeUrl, "capability_draft_create", {
request: createRequest,
});
const draftId = pickString(draft, "draftId", "draft_id");
assert(draftId, "capability_draft_create 未返回 draftId");
const verification = await invoke(options.invokeUrl, "capability_draft_verify", {
request: { workspaceRoot: workspace.workspaceRoot, draftId },
});
const verificationStatus = pickString(
verification?.draft,
"verificationStatus",
"verification_status",
);
assert(
verificationStatus === "verified_pending_registration",
`verification 未进入 pending registration: ${verificationStatus}`,
);
const failedChecks = pickArray(verification?.report, "checks").filter(
(check) => pickString(check, "status") === "failed",
);
assert(failedChecks.length === 0, `verification 存在失败项: ${JSON.stringify(failedChecks)}`);
const registration = await invoke(options.invokeUrl, "capability_draft_register", {
request: { workspaceRoot: workspace.workspaceRoot, draftId },
});
const registeredSkillDirectory = pickString(
registration?.registration,
"registeredSkillDirectory",
"registered_skill_directory",
);
assert(registeredSkillDirectory, "registration 未返回 registeredSkillDirectory");
const registeredSkills = await invoke(
options.invokeUrl,
"capability_draft_list_registered_skills",
{ request: { workspaceRoot: workspace.workspaceRoot } },
);
assert(Array.isArray(registeredSkills), "registered skills 返回不是数组");
const registeredSkill = registeredSkills.find(
(skill) =>
pickString(skill, "registeredSkillDirectory", "registered_skill_directory") ===
registeredSkillDirectory || pickString(skill, "name") === "只读 CLI 每日报告",
);
assert(registeredSkill, "registered discovery 未找到刚注册的 skill");
assert(
registeredSkill.launchEnabled === false || registeredSkill.launch_enabled === false,
"registered discovery 不应默认 launchEnabled=true",
);
const bindingSnapshot = await invoke(
options.invokeUrl,
"agent_runtime_list_workspace_skill_bindings",
{
request: {
workspaceRoot: workspace.workspaceRoot,
caller: "assistant",
workbench: true,
},
},
);
const bindings = pickArray(bindingSnapshot, "bindings");
const binding = bindings.find(
(item) => pickString(item, "registered_skill_directory", "registeredSkillDirectory") === registeredSkillDirectory,
);
assert(binding, "runtime binding readiness 未找到刚注册的 skill");
assert(
pickString(binding, "binding_status", "bindingStatus") === "ready_for_manual_enable",
`binding 未 ready_for_manual_enable: ${pickString(binding, "binding_status", "bindingStatus")}`,
);
assert(binding.launch_enabled === false || binding.launchEnabled === false, "binding 不应默认 launch enabled");
assert(binding.tool_runtime_visible === false || binding.toolRuntimeVisible === false, "binding 不应默认 tool runtime visible");
const summary = {
status: "passed",
startedAt,
finishedAt: new Date().toISOString(),
workspaceRoot: workspace.workspaceRoot,
draftId,
verificationStatus,
verificationReportId: pickString(
verification?.report,
"reportId",
"report_id",
),
registeredSkillDirectory,
registeredSkillName: pickString(registeredSkill, "name"),
bindingStatus: pickString(binding, "binding_status", "bindingStatus"),
nextGate: pickString(binding, "next_gate", "nextGate"),
cleanup: options.cleanup && workspace.createdTemp,
};
if (!options.json) {
console.log("[prompt-to-artifact:p5-smoke] 通过");
console.log(JSON.stringify(summary, null, 2));
} else {
console.log(JSON.stringify(summary));
}
return { summary, workspace };
} catch (error) {
error.workspaceRoot = workspace.workspaceRoot;
throw error;
} finally {
if (options.cleanup && workspace.createdTemp) {
await fs.rm(workspace.workspaceRoot, { recursive: true, force: true });
}
}
}
async function main() {
if (typeof fetch !== "function") {
throw new Error("当前 Node 运行时不支持 fetch,请使用 Node 18+");
}
const options = parseArgs(process.argv.slice(2));
await runSmoke(options);
}
main().catch((error) => {
const detail = error instanceof Error ? error.message : String(error);
console.error(`[prompt-to-artifact:p5-smoke] 失败: ${detail}`);
if (error?.workspaceRoot) {
console.error(`[prompt-to-artifact:p5-smoke] workspaceRoot: ${error.workspaceRoot}`);
}
process.exit(1);
});
+1
View File
@@ -63,6 +63,7 @@ const HARNESS_CLEANUP_CONTRACT_FILES = new Set([
const INTEGRITY_FILES = new Set([
"package.json",
"packages/lime-cli-npm/package.json",
"src-tauri/Cargo.toml",
"src-tauri/tauri.conf.json",
"src-tauri/tauri.conf.headless.json",
+616
View File
@@ -0,0 +1,616 @@
#!/usr/bin/env node
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import process from "node:process";
import { buildReadonlyHttpApiCreateRequest } from "./lib/readonly-http-api-draft-template.mjs";
const DEFAULTS = {
healthUrl: "http://127.0.0.1:3030/health",
invokeUrl: "http://127.0.0.1:3030/invoke",
timeoutMs: 120_000,
intervalMs: 1_000,
workspaceRoot: "",
cleanup: false,
json: false,
};
function printHelp() {
console.log(`
Read-Only HTTP API Capability Draft Smoke
用途:
用临时 workspace 验证 P6 第一刀:只读 HTTP API draft 必须声明网络只读权限。
该 smoke 只走 capability_draft_create -> capability_draft_verify,不发真实 HTTP 请求、不注册、不进入 runtime。
用法:
node scripts/readonly-http-api-smoke.mjs [选项]
选项:
--health-url <url> DevBridge 健康检查地址,默认 http://127.0.0.1:3030/health
--invoke-url <url> DevBridge invoke 地址,默认 http://127.0.0.1:3030/invoke
--timeout-ms <ms> 等待 DevBridge 超时,默认 120000
--interval-ms <ms> 轮询间隔,默认 1000
--workspace-root <dir> 指定 smoke workspace;默认创建临时目录
--cleanup 成功或失败后删除本脚本创建的临时 workspace
--json 只输出 JSON summary
-h, --help 显示帮助
`);
}
function parseArgs(argv) {
const options = { ...DEFAULTS };
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--health-url" && argv[index + 1]) {
options.healthUrl = String(argv[++index]).trim();
continue;
}
if (arg === "--invoke-url" && argv[index + 1]) {
options.invokeUrl = String(argv[++index]).trim();
continue;
}
if (arg === "--timeout-ms" && argv[index + 1]) {
options.timeoutMs = Number(argv[++index]);
continue;
}
if (arg === "--interval-ms" && argv[index + 1]) {
options.intervalMs = Number(argv[++index]);
continue;
}
if (arg === "--workspace-root" && argv[index + 1]) {
options.workspaceRoot = path.resolve(String(argv[++index]).trim());
continue;
}
if (arg === "--cleanup") {
options.cleanup = true;
continue;
}
if (arg === "--json") {
options.json = true;
continue;
}
if (arg === "--help" || arg === "-h") {
printHelp();
process.exit(0);
}
throw new Error(`未知参数: ${arg}`);
}
if (!Number.isFinite(options.timeoutMs) || options.timeoutMs < 1_000) {
throw new Error("--timeout-ms 必须是 >= 1000 的数字");
}
if (!Number.isFinite(options.intervalMs) || options.intervalMs < 100) {
throw new Error("--interval-ms 必须是 >= 100 的数字");
}
return options;
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
function pickString(target, ...keys) {
for (const key of keys) {
const value = target?.[key];
if (typeof value === "string" && value.trim()) {
return value.trim();
}
}
return "";
}
function pickArray(target, ...keys) {
for (const key of keys) {
const value = target?.[key];
if (Array.isArray(value)) {
return value;
}
}
return [];
}
function collectEvidenceKeys(check) {
return pickArray(check, "evidence")
.map((item) => pickString(item, "key"))
.filter(Boolean);
}
async function checkHealth(url) {
const response = await fetch(url, { method: "GET" });
const text = await response.text();
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return text ? JSON.parse(text) : null;
}
async function waitForHealth(options) {
const startedAt = Date.now();
let lastError = null;
while (Date.now() - startedAt < options.timeoutMs) {
try {
const payload = await checkHealth(options.healthUrl);
if (!options.json) {
console.log(`[readonly-http-api:p6-smoke] DevBridge 已就绪 (${Date.now() - startedAt}ms)`);
}
return payload;
} catch (error) {
lastError = error;
await sleep(options.intervalMs);
}
}
throw new Error(
`DevBridge 未就绪,请先启动 npm run tauri:dev:headless。最后错误: ${
lastError instanceof Error ? lastError.message : String(lastError)
}`,
);
}
async function invoke(invokeUrl, cmd, args = {}) {
const response = await fetch(invokeUrl, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ cmd, args }),
});
if (!response.ok) {
throw new Error(`${cmd} HTTP ${response.status}: ${response.statusText}`);
}
const payload = await response.json();
if (payload?.error) {
throw new Error(`${cmd} failed: ${payload.error}`);
}
return payload?.result;
}
function buildCreateRequest(workspaceRoot, permissionSummary, options = {}) {
return buildReadonlyHttpApiCreateRequest(
workspaceRoot,
permissionSummary,
options,
);
}
async function prepareWorkspace(options) {
if (options.workspaceRoot) {
await fs.mkdir(options.workspaceRoot, { recursive: true });
return { workspaceRoot: options.workspaceRoot, createdTemp: false };
}
const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "lime-readonly-http-api-p6-"));
return { workspaceRoot, createdTemp: true };
}
async function createAndVerify(options, workspaceRoot, permissionSummary, draftOptions = {}) {
const draft = await invoke(options.invokeUrl, "capability_draft_create", {
request: buildCreateRequest(workspaceRoot, permissionSummary, draftOptions),
});
const draftId = pickString(draft, "draftId", "draft_id");
assert(draftId, "capability_draft_create 未返回 draftId");
const verification = await invoke(options.invokeUrl, "capability_draft_verify", {
request: { workspaceRoot, draftId },
});
const verificationStatus = pickString(
verification?.draft,
"verificationStatus",
"verification_status",
);
const checks = pickArray(verification?.report, "checks");
const failedChecks = checks.filter((check) => pickString(check, "status") === "failed");
return { draftId, verificationStatus, checks, failedChecks };
}
async function runSmoke(options) {
await waitForHealth(options);
const workspace = await prepareWorkspace(options);
const startedAt = new Date().toISOString();
try {
const positive = await createAndVerify(options, workspace.workspaceRoot, [
"Level 0 只读发现",
"允许只读 HTTP API GET 请求,不做外部写操作",
]);
assert(
positive.verificationStatus === "verified_pending_registration",
`正向 draft 未进入 pending registration: ${positive.verificationStatus}`,
);
assert(
positive.failedChecks.length === 0,
`正向 draft 存在失败项: ${JSON.stringify(positive.failedChecks)}`,
);
const positiveDryRunExecutionCheck = positive.checks.find(
(check) => pickString(check, "id") === "readonly_http_fixture_dry_run_execute",
);
assert(
positiveDryRunExecutionCheck,
"正向 draft 未返回 readonly_http_fixture_dry_run_execute check",
);
const positiveDryRunExecuteEvidenceKeys = collectEvidenceKeys(positiveDryRunExecutionCheck);
for (const key of [
"scriptPath",
"expectedOutputPath",
"durationMs",
"actualSha256",
"expectedSha256",
"stdoutPreview",
]) {
assert(
positiveDryRunExecuteEvidenceKeys.includes(key),
`正向 dry-run execute 缺少 evidence key: ${key}`,
);
}
const positivePreflightCheck = positive.checks.find(
(check) => pickString(check, "id") === "readonly_http_execution_preflight",
);
assert(
positivePreflightCheck,
"正向 draft 未返回 readonly_http_execution_preflight check",
);
const positivePreflightEvidenceKeys = collectEvidenceKeys(positivePreflightCheck);
for (const key of [
"preflightMode",
"endpointSource",
"method",
"credentialReferenceId",
"evidenceSchema",
]) {
assert(
positivePreflightEvidenceKeys.includes(key),
`正向 execution preflight 缺少 evidence key: ${key}`,
);
}
const negative = await createAndVerify(options, workspace.workspaceRoot, ["Level 0 只读发现"]);
assert(
negative.verificationStatus === "verification_failed",
`负向 draft 未失败: ${negative.verificationStatus}`,
);
const riskCheck = negative.failedChecks.find(
(check) => pickString(check, "id") === "static_risk_scan",
);
assert(riskCheck, "负向 draft 未命中 static_risk_scan");
assert(
String(riskCheck.message ?? "").includes("网络只读权限"),
`负向 draft 风险信息不包含网络只读权限: ${String(riskCheck.message ?? "")}`,
);
const missingFixture = await createAndVerify(
options,
workspace.workspaceRoot,
["Level 0 只读发现", "允许只读 HTTP API GET 请求,不做外部写操作"],
{ includeFixture: false },
);
assert(
missingFixture.verificationStatus === "verification_failed",
`缺 fixture draft 未失败: ${missingFixture.verificationStatus}`,
);
const fixtureCheck = missingFixture.failedChecks.find(
(check) => pickString(check, "id") === "readonly_http_fixture",
);
assert(fixtureCheck, "缺 fixture draft 未命中 readonly_http_fixture");
assert(
String(fixtureCheck.message ?? "").includes("fixture"),
`缺 fixture draft 风险信息不包含 fixture: ${String(fixtureCheck.message ?? "")}`,
);
const missingExpectedOutput = await createAndVerify(
options,
workspace.workspaceRoot,
["Level 0 只读发现", "允许只读 HTTP API GET 请求,不做外部写操作"],
{ includeExpectedOutput: false },
);
assert(
missingExpectedOutput.verificationStatus === "verification_failed",
`缺 expected output draft 未失败: ${missingExpectedOutput.verificationStatus}`,
);
const expectedOutputCheck = missingExpectedOutput.failedChecks.find(
(check) => pickString(check, "id") === "readonly_http_expected_output",
);
assert(expectedOutputCheck, "缺 expected output draft 未命中 readonly_http_expected_output");
assert(
String(expectedOutputCheck.message ?? "").includes("expected output"),
`缺 expected output draft 风险信息不包含 expected output: ${String(
expectedOutputCheck.message ?? "",
)}`,
);
const missingFixtureInput = await createAndVerify(
options,
workspace.workspaceRoot,
["Level 0 只读发现", "允许只读 HTTP API GET 请求,不做外部写操作"],
{ includeFixtureInput: false },
);
assert(
missingFixtureInput.verificationStatus === "verification_failed",
`缺 fixture input draft 未失败: ${missingFixtureInput.verificationStatus}`,
);
const fixtureInputCheck = missingFixtureInput.failedChecks.find(
(check) => pickString(check, "id") === "readonly_http_fixture_input",
);
assert(fixtureInputCheck, "缺 fixture input draft 未命中 readonly_http_fixture_input");
assert(
String(fixtureInputCheck.message ?? "").includes("fixture"),
`缺 fixture input draft 风险信息不包含 fixture: ${String(
fixtureInputCheck.message ?? "",
)}`,
);
const missingDryRunEntry = await createAndVerify(
options,
workspace.workspaceRoot,
["Level 0 只读发现", "允许只读 HTTP API GET 请求,不做外部写操作"],
{ includeDryRunEntry: false },
);
assert(
missingDryRunEntry.verificationStatus === "verification_failed",
`缺 fixture dry-run 入口 draft 未失败: ${missingDryRunEntry.verificationStatus}`,
);
const dryRunCheck = missingDryRunEntry.failedChecks.find(
(check) => pickString(check, "id") === "readonly_http_fixture_dry_run",
);
assert(dryRunCheck, "缺 fixture dry-run 入口 draft 未命中 readonly_http_fixture_dry_run");
assert(
String(dryRunCheck.message ?? "").includes("dry-run"),
`缺 fixture dry-run 入口 draft 风险信息不包含 dry-run: ${String(
dryRunCheck.message ?? "",
)}`,
);
const missingDryRunExpectedOutputBinding = await createAndVerify(
options,
workspace.workspaceRoot,
["Level 0 只读发现", "允许只读 HTTP API GET 请求,不做外部写操作"],
{ includeDryRunExpectedOutputBinding: false },
);
assert(
missingDryRunExpectedOutputBinding.verificationStatus === "verification_failed",
`dry-run 未绑定 expected output draft 未失败: ${missingDryRunExpectedOutputBinding.verificationStatus}`,
);
const dryRunExpectedOutputBindingCheck = missingDryRunExpectedOutputBinding.failedChecks.find(
(check) => pickString(check, "id") === "readonly_http_fixture_dry_run_expected_output",
);
assert(
dryRunExpectedOutputBindingCheck,
"dry-run 未绑定 expected output draft 未命中 readonly_http_fixture_dry_run_expected_output",
);
assert(
String(dryRunExpectedOutputBindingCheck.message ?? "").includes("expected output"),
`dry-run 未绑定 expected output draft 风险信息不包含 expected output: ${String(
dryRunExpectedOutputBindingCheck.message ?? "",
)}`,
);
const mismatchedDryRun = await createAndVerify(
options,
workspace.workspaceRoot,
["Level 0 只读发现", "允许只读 HTTP API GET 请求,不做外部写操作"],
{ includeDryRunMismatch: true },
);
assert(
mismatchedDryRun.verificationStatus === "verification_failed",
`dry-run actual/expected 不一致 draft 未失败: ${mismatchedDryRun.verificationStatus}`,
);
const dryRunExecutionCheck = mismatchedDryRun.failedChecks.find(
(check) => pickString(check, "id") === "readonly_http_fixture_dry_run_execute",
);
assert(
dryRunExecutionCheck,
"dry-run actual/expected 不一致 draft 未命中 readonly_http_fixture_dry_run_execute",
);
assert(
String(dryRunExecutionCheck.message ?? "").includes("dry-run"),
`dry-run actual/expected 不一致 draft 风险信息不包含 dry-run: ${String(
dryRunExecutionCheck.message ?? "",
)}`,
);
const networkedDryRun = await createAndVerify(
options,
workspace.workspaceRoot,
["Level 0 只读发现", "允许只读 HTTP API GET 请求,不做外部写操作"],
{ includeNetworkedDryRun: true },
);
assert(
networkedDryRun.verificationStatus === "verification_failed",
`真实联网 dry-run draft 未失败: ${networkedDryRun.verificationStatus}`,
);
const dryRunOfflineCheck = networkedDryRun.failedChecks.find(
(check) => pickString(check, "id") === "readonly_http_fixture_dry_run_offline",
);
assert(dryRunOfflineCheck, "真实联网 dry-run draft 未命中 readonly_http_fixture_dry_run_offline");
assert(
String(dryRunOfflineCheck.message ?? "").includes("真实联网"),
`真实联网 dry-run draft 风险信息不包含真实联网: ${String(
dryRunOfflineCheck.message ?? "",
)}`,
);
const credentialDraft = await createAndVerify(
options,
workspace.workspaceRoot,
["Level 0 只读发现", "允许只读 HTTP API GET 请求,不做外部写操作"],
{ includeCredentialHeader: true },
);
assert(
credentialDraft.verificationStatus === "verification_failed",
`凭证字段 draft 未失败: ${credentialDraft.verificationStatus}`,
);
const credentialCheck = credentialDraft.failedChecks.find(
(check) => pickString(check, "id") === "readonly_http_no_credentials",
);
assert(credentialCheck, "凭证字段 draft 未命中 readonly_http_no_credentials");
assert(
String(credentialCheck.message ?? "").includes("凭证"),
`凭证字段 draft 风险信息不包含凭证: ${String(credentialCheck.message ?? "")}`,
);
const missingSessionAuthorization = await createAndVerify(
options,
workspace.workspaceRoot,
["Level 0 只读发现", "允许只读 HTTP API GET 请求,不做外部写操作"],
{ includeSessionAuthorization: false },
);
assert(
missingSessionAuthorization.verificationStatus === "verification_failed",
`缺 session authorization policy draft 未失败: ${missingSessionAuthorization.verificationStatus}`,
);
const sessionAuthorizationCheck = missingSessionAuthorization.failedChecks.find(
(check) => pickString(check, "id") === "readonly_http_session_authorization",
);
assert(
sessionAuthorizationCheck,
"缺 session authorization policy draft 未命中 readonly_http_session_authorization",
);
assert(
String(sessionAuthorizationCheck.message ?? "").includes("authorization") ||
String(sessionAuthorizationCheck.message ?? "").includes("授权"),
`缺 session authorization policy draft 风险信息不包含授权: ${String(
sessionAuthorizationCheck.message ?? "",
)}`,
);
const missingCredentialReference = await createAndVerify(
options,
workspace.workspaceRoot,
["Level 0 只读发现", "允许只读 HTTP API GET 请求,不做外部写操作"],
{ includeCredentialReference: false },
);
assert(
missingCredentialReference.verificationStatus === "verification_failed",
`缺 credential_reference draft 未失败: ${missingCredentialReference.verificationStatus}`,
);
const credentialReferenceCheck = missingCredentialReference.failedChecks.find(
(check) => pickString(check, "id") === "readonly_http_credential_reference",
);
assert(
credentialReferenceCheck,
"缺 credential_reference draft 未命中 readonly_http_credential_reference",
);
assert(
String(credentialReferenceCheck.message ?? "").includes("credential_reference") ||
String(credentialReferenceCheck.message ?? "").includes("凭证引用"),
`缺 credential_reference draft 风险信息不包含凭证引用: ${String(
credentialReferenceCheck.message ?? "",
)}`,
);
const missingExecutionPreflight = await createAndVerify(
options,
workspace.workspaceRoot,
["Level 0 只读发现", "允许只读 HTTP API GET 请求,不做外部写操作"],
{ includeExecutionPreflight: false },
);
assert(
missingExecutionPreflight.verificationStatus === "verification_failed",
`缺 execution_preflight draft 未失败: ${missingExecutionPreflight.verificationStatus}`,
);
const executionPreflightCheck = missingExecutionPreflight.failedChecks.find(
(check) => pickString(check, "id") === "readonly_http_execution_preflight",
);
assert(
executionPreflightCheck,
"缺 execution_preflight draft 未命中 readonly_http_execution_preflight",
);
assert(
String(executionPreflightCheck.message ?? "").includes("execution_preflight") ||
String(executionPreflightCheck.message ?? "").includes("preflight"),
`缺 execution_preflight draft 风险信息不包含 preflight: ${String(
executionPreflightCheck.message ?? "",
)}`,
);
const summary = {
status: "passed",
startedAt,
finishedAt: new Date().toISOString(),
workspaceRoot: workspace.workspaceRoot,
positiveDraftId: positive.draftId,
positiveVerificationStatus: positive.verificationStatus,
positiveDryRunExecuteEvidenceKeys,
positivePreflightEvidenceKeys,
negativeDraftId: negative.draftId,
negativeVerificationStatus: negative.verificationStatus,
negativeFailedCheck: pickString(riskCheck, "id"),
missingFixtureDraftId: missingFixture.draftId,
missingFixtureVerificationStatus: missingFixture.verificationStatus,
missingFixtureFailedCheck: pickString(fixtureCheck, "id"),
missingExpectedOutputDraftId: missingExpectedOutput.draftId,
missingExpectedOutputVerificationStatus: missingExpectedOutput.verificationStatus,
missingExpectedOutputFailedCheck: pickString(expectedOutputCheck, "id"),
missingFixtureInputDraftId: missingFixtureInput.draftId,
missingFixtureInputVerificationStatus: missingFixtureInput.verificationStatus,
missingFixtureInputFailedCheck: pickString(fixtureInputCheck, "id"),
missingDryRunEntryDraftId: missingDryRunEntry.draftId,
missingDryRunEntryVerificationStatus: missingDryRunEntry.verificationStatus,
missingDryRunEntryFailedCheck: pickString(dryRunCheck, "id"),
missingDryRunExpectedOutputBindingDraftId: missingDryRunExpectedOutputBinding.draftId,
missingDryRunExpectedOutputBindingVerificationStatus:
missingDryRunExpectedOutputBinding.verificationStatus,
missingDryRunExpectedOutputBindingFailedCheck: pickString(
dryRunExpectedOutputBindingCheck,
"id",
),
mismatchedDryRunDraftId: mismatchedDryRun.draftId,
mismatchedDryRunVerificationStatus: mismatchedDryRun.verificationStatus,
mismatchedDryRunFailedCheck: pickString(dryRunExecutionCheck, "id"),
networkedDryRunDraftId: networkedDryRun.draftId,
networkedDryRunVerificationStatus: networkedDryRun.verificationStatus,
networkedDryRunFailedCheck: pickString(dryRunOfflineCheck, "id"),
credentialDraftId: credentialDraft.draftId,
credentialVerificationStatus: credentialDraft.verificationStatus,
credentialFailedCheck: pickString(credentialCheck, "id"),
missingSessionAuthorizationDraftId: missingSessionAuthorization.draftId,
missingSessionAuthorizationVerificationStatus:
missingSessionAuthorization.verificationStatus,
missingSessionAuthorizationFailedCheck: pickString(sessionAuthorizationCheck, "id"),
missingCredentialReferenceDraftId: missingCredentialReference.draftId,
missingCredentialReferenceVerificationStatus:
missingCredentialReference.verificationStatus,
missingCredentialReferenceFailedCheck: pickString(credentialReferenceCheck, "id"),
missingExecutionPreflightDraftId: missingExecutionPreflight.draftId,
missingExecutionPreflightVerificationStatus:
missingExecutionPreflight.verificationStatus,
missingExecutionPreflightFailedCheck: pickString(executionPreflightCheck, "id"),
cleanup: options.cleanup && workspace.createdTemp,
};
if (options.cleanup && workspace.createdTemp) {
await fs.rm(workspace.workspaceRoot, { recursive: true, force: true });
}
return summary;
} catch (error) {
error.workspaceRoot = workspace.workspaceRoot;
if (options.cleanup && workspace.createdTemp) {
await fs.rm(workspace.workspaceRoot, { recursive: true, force: true });
}
throw error;
}
}
async function main() {
const options = parseArgs(process.argv.slice(2));
try {
const summary = await runSmoke(options);
if (options.json) {
console.log(JSON.stringify(summary));
} else {
console.log("[readonly-http-api:p6-smoke] 通过");
console.log(JSON.stringify(summary, null, 2));
}
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
console.error(`[readonly-http-api:p6-smoke] 失败: ${detail}`);
if (error?.workspaceRoot) {
console.error(`[readonly-http-api:p6-smoke] workspaceRoot: ${error.workspaceRoot}`);
}
process.exit(1);
}
}
await main();
+22 -1
View File
@@ -19,6 +19,7 @@ const batchSize =
: 16;
const serialTestFiles = new Set([
"scripts/lib/harness-eval-history-record.test.ts",
"scripts/lib/harness-eval-history-window.test.ts",
"src/components/agent/chat/index.test.tsx",
"src/components/agent/chat/hooks/useAsterAgentChat.test.tsx",
"src/components/workspace/WorkbenchPage.test.tsx",
@@ -28,6 +29,21 @@ const serialTestFiles = new Set([
"src/components/agent/chat/components/TeamWorkspaceBoard.test.tsx",
"src/components/settings-v2/system/automation/index.test.tsx",
]);
const ignoredTestPathSegments = [
"/node_modules/",
"/tmp/lime-pnpm-frozen-node_modules/",
];
function normalizeTestPath(file) {
return path.resolve(file).replaceAll("\\", "/");
}
function shouldIgnoreCollectedTestFile(file) {
const normalized = normalizeTestPath(file);
return ignoredTestPathSegments.some((segment) =>
normalized.includes(segment),
);
}
function runVitest(args, label) {
if (label) {
@@ -83,7 +99,12 @@ function collectTestFiles() {
const parsed = JSON.parse(result.stdout || "[]");
return parsed
.map((entry) => (typeof entry === "string" ? entry : entry?.file))
.filter((entry) => typeof entry === "string" && entry.length > 0);
.filter(
(entry) =>
typeof entry === "string" &&
entry.length > 0 &&
!shouldIgnoreCollectedTestFile(entry),
);
}
function chunkFiles(files, size) {