mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
chore: release v1.30.0
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"}) 不一致`,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user