chore: prepare v1.28.0 release

This commit is contained in:
coso
2026-05-06 00:44:10 +08:00
parent 98ddf332d7
commit 21b6495cc5
274 changed files with 38533 additions and 3613 deletions
+17
View File
@@ -361,6 +361,9 @@ function main() {
const runtimeGatewayCommands = new Set(
agentCommandCatalog.runtimeGatewayCommands ?? [],
);
const capabilityDraftCommands = new Set(
agentCommandCatalog.capabilityDraftCommands ?? [],
);
const deferredCommands = new Set(knownDeferredRegistrationReasons.keys());
@@ -390,6 +393,12 @@ function main() {
!registeredCommands.has(command) && !deferredCommands.has(command),
),
);
const capabilityDraftMissingRegistrations = new Set(
[...capabilityDraftCommands].filter(
(command) =>
!registeredCommands.has(command) && !deferredCommands.has(command),
),
);
console.log("[command-contracts] frontend commands:", frontendCommands.size);
console.log(
@@ -456,6 +465,14 @@ function main() {
);
}
if (capabilityDraftMissingRegistrations.size > 0) {
hasError = true;
printCommandGroup(
"capability draft 命令缺少 Rust 注册",
capabilityDraftMissingRegistrations,
);
}
if (hasError) {
process.exitCode = 1;
return;
+308 -39
View File
@@ -10,6 +10,12 @@ const CAPABILITY_MATRIX_PATH =
const ARTIFACT_GRAPH_PATH = "src/lib/governance/modalityArtifactGraph.json";
const EXECUTION_PROFILE_PATH =
"src/lib/governance/modalityExecutionProfiles.json";
const TASK_INDEX_PRESENTATION_PATH =
"src/lib/agentRuntime/modalityTaskIndexPresentation.ts";
const HARNESS_TASK_INDEX_SECTION_PATH =
"src/components/agent/chat/components/HarnessTaskIndexSection.tsx";
const HARNESS_STATUS_PANEL_PATH =
"src/components/agent/chat/components/HarnessStatusPanel.tsx";
const REQUIRED_DOCS = [
"docs/roadmap/warp/runtime-fact-map.md",
"docs/roadmap/warp/contract-schema.md",
@@ -20,6 +26,11 @@ const REQUIRED_DOCS = [
"docs/roadmap/warp/task-index-inventory.md",
"docs/roadmap/warp/evolution-guide.md",
];
const REQUIRED_TASK_INDEX_PRESENTATION_EXPORTS = [
"buildModalityTaskIndexFacets",
"buildModalityTaskIndexRows",
"filterModalityTaskIndexRows",
];
const LIFECYCLES = new Set(["current", "compat", "deprecated", "dead"]);
const MODALITIES = new Set([
@@ -132,6 +143,30 @@ const REQUIRED_ARTIFACT_INDEX_FIELDS = new Set([
"created_at",
"updated_at",
]);
const MEDIA_TASK_ARTIFACT_KINDS_REQUIRING_PHASE8_FIELDS = new Set([
"image_task",
"image_output",
"audio_task",
"audio_output",
"transcript",
]);
const REQUIRED_MEDIA_TASK_PHASE8_INDEX_FIELDS = new Set([
"entry_key",
"thread_id",
"turn_id",
"content_id",
"modality",
"skill_id",
"model_id",
"cost_state",
"limit_state",
"estimated_cost_class",
"limit_event_kind",
"quota_low",
"executor_kind",
"executor_binding_key",
"limecore_policy_snapshot_status",
]);
const ENTRY_KINDS = new Set([
"command",
"button_action",
@@ -158,7 +193,11 @@ const PHASE7_REQUIRED_ENTRY_BINDINGS = new Map([
function collectUniqueObjects(errors, collection, keyName, label) {
const result = new Map();
pushIf(errors, !isNonEmptyArray(collection), `${label} must be a non-empty array`);
pushIf(
errors,
!isNonEmptyArray(collection),
`${label} must be a non-empty array`,
);
if (!Array.isArray(collection)) {
return result;
}
@@ -179,7 +218,11 @@ function collectUniqueObjects(errors, collection, keyName, label) {
if (!isNonEmptyString(key)) {
return;
}
pushIf(errors, result.has(key), `${prefix}.${keyName} is duplicated: ${key}`);
pushIf(
errors,
result.has(key),
`${prefix}.${keyName} is duplicated: ${key}`,
);
result.set(key, item);
});
@@ -209,7 +252,13 @@ function pushIf(errors, condition, message) {
}
}
function validateEnumArray(errors, contractKey, fieldName, values, allowedValues) {
function validateEnumArray(
errors,
contractKey,
fieldName,
values,
allowedValues,
) {
pushIf(
errors,
!isNonEmptyArray(values),
@@ -228,9 +277,17 @@ function validateEnumArray(errors, contractKey, fieldName, values, allowedValues
}
}
function validateStringArray(errors, contractKey, fieldName, values, options = {}) {
function validateStringArray(
errors,
contractKey,
fieldName,
values,
options = {},
) {
const { allowEmpty = false } = options;
const invalidArray = allowEmpty ? !Array.isArray(values) : !isNonEmptyArray(values);
const invalidArray = allowEmpty
? !Array.isArray(values)
: !isNonEmptyArray(values);
pushIf(
errors,
invalidArray,
@@ -435,7 +492,10 @@ function validatePhase7EntryBindingCoverage(errors, registry) {
}
}
for (const [contractKey, requiredEntryKeys] of PHASE7_REQUIRED_ENTRY_BINDINGS) {
for (const [
contractKey,
requiredEntryKeys,
] of PHASE7_REQUIRED_ENTRY_BINDINGS) {
const contract = registry.contracts.find(
(candidate) =>
isPlainObject(candidate) && candidate.contract_key === contractKey,
@@ -544,13 +604,9 @@ function validateCapabilityMatrix(matrix) {
role.capability_keys,
new Set(capabilityMap.keys()),
);
validateStringArray(
errors,
slot,
"fallback_slots",
role.fallback_slots,
{ allowEmpty: true },
);
validateStringArray(errors, slot, "fallback_slots", role.fallback_slots, {
allowEmpty: true,
});
if (Array.isArray(role.fallback_slots)) {
for (const fallbackSlot of role.fallback_slots) {
pushIf(
@@ -683,6 +739,15 @@ function validateArtifactGraph(graph) {
`artifact ${kind}.task_index_fields must include ${requiredField}`,
);
}
if (MEDIA_TASK_ARTIFACT_KINDS_REQUIRING_PHASE8_FIELDS.has(kind)) {
for (const requiredField of REQUIRED_MEDIA_TASK_PHASE8_INDEX_FIELDS) {
pushIf(
errors,
!indexFields.has(requiredField),
`artifact ${kind}.task_index_fields must include ${requiredField} for Phase 8 media task index projection`,
);
}
}
}
}
@@ -709,7 +774,9 @@ function validateContractArtifactGraph(errors, contract, artifactGraphRefs) {
for (const artifactKind of contract.artifact_kinds) {
const artifact = artifactGraphRefs.artifactMap.get(artifactKind);
if (!artifact) {
errors.push(`${contractKey}.artifact_kinds references artifact not defined in graph: ${artifactKind}`);
errors.push(
`${contractKey}.artifact_kinds references artifact not defined in graph: ${artifactKind}`,
);
continue;
}
@@ -742,7 +809,10 @@ function resolveExecutorAdapterKey(executor) {
if (!isPlainObject(executor)) {
return null;
}
if (!isNonEmptyString(executor.executor_kind) || !isNonEmptyString(executor.binding_key)) {
if (
!isNonEmptyString(executor.executor_kind) ||
!isNonEmptyString(executor.binding_key)
) {
return null;
}
return `${executor.executor_kind}:${executor.binding_key}`;
@@ -760,7 +830,11 @@ function validateSubset(errors, label, actualValues, requiredValues) {
}
function validateArtifactPolicy(errors, label, policy, artifactGraphRefs) {
pushIf(errors, !isPlainObject(policy), `${label}.artifact_policy must be an object`);
pushIf(
errors,
!isPlainObject(policy),
`${label}.artifact_policy must be an object`,
);
if (!isPlainObject(policy)) {
return;
}
@@ -794,7 +868,12 @@ function validateArtifactPolicy(errors, label, policy, artifactGraphRefs) {
);
}
function validateExecutionProfiles(registry, profiles, matrixRefs, artifactGraphRefs) {
function validateExecutionProfiles(
registry,
profiles,
matrixRefs,
artifactGraphRefs,
) {
const errors = [];
pushIf(errors, profiles.version !== 1, "executionProfiles.version must be 1");
pushIf(
@@ -946,7 +1025,12 @@ function validateExecutionProfiles(registry, profiles, matrixRefs, artifactGraph
profile.executor_adapter_keys,
new Set(adapterMap.keys()),
);
validateArtifactPolicy(errors, profileKey, profile.artifact_policy, artifactGraphRefs);
validateArtifactPolicy(
errors,
profileKey,
profile.artifact_policy,
artifactGraphRefs,
);
validateEnumArray(
errors,
profileKey,
@@ -972,7 +1056,12 @@ function validateExecutionProfiles(registry, profiles, matrixRefs, artifactGraph
profile.evidence_events,
EVIDENCE_EVENTS,
);
validateStringArray(errors, profileKey, "audit_fields", profile.audit_fields);
validateStringArray(
errors,
profileKey,
"audit_fields",
profile.audit_fields,
);
pushIf(
errors,
!isNonEmptyString(profile.notes),
@@ -1094,9 +1183,21 @@ function validateExecutionProfiles(registry, profiles, matrixRefs, artifactGraph
function validateContractRegistry(registry, matrixRefs, artifactGraphRefs) {
const errors = [];
pushIf(errors, registry.version !== 1, "registry.version must be 1");
pushIf(errors, registry.status !== "current", "registry.status must be current");
pushIf(errors, !isNonEmptyString(registry.owner), "registry.owner must be set");
pushIf(errors, !isNonEmptyArray(registry.contracts), "registry.contracts must be a non-empty array");
pushIf(
errors,
registry.status !== "current",
"registry.status must be current",
);
pushIf(
errors,
!isNonEmptyString(registry.owner),
"registry.owner must be set",
);
pushIf(
errors,
!isNonEmptyArray(registry.contracts),
"registry.contracts must be a non-empty array",
);
if (!Array.isArray(registry.contracts)) {
return errors;
}
@@ -1116,7 +1217,8 @@ function validateContractRegistry(registry, matrixRefs, artifactGraphRefs) {
);
pushIf(
errors,
isNonEmptyString(contract.contract_key) && looksLikeEntryKey(contract.contract_key),
isNonEmptyString(contract.contract_key) &&
looksLikeEntryKey(contract.contract_key),
`${contractKey}.contract_key must be a bottom-layer key, not an entry key`,
);
pushIf(
@@ -1136,8 +1238,18 @@ function validateContractRegistry(registry, matrixRefs, artifactGraphRefs) {
!MODALITIES.has(contract.modality),
`${contractKey}.modality is unknown: ${String(contract.modality)}`,
);
validateStringArray(errors, contractKey, "runtime_identity", contract.runtime_identity);
validateStringArray(errors, contractKey, "input_context_kinds", contract.input_context_kinds);
validateStringArray(
errors,
contractKey,
"runtime_identity",
contract.runtime_identity,
);
validateStringArray(
errors,
contractKey,
"input_context_kinds",
contract.input_context_kinds,
);
validateEnumArray(
errors,
contractKey,
@@ -1145,8 +1257,18 @@ function validateContractRegistry(registry, matrixRefs, artifactGraphRefs) {
contract.required_capabilities,
matrixRefs.capabilityKeys,
);
validateEnumArray(errors, contractKey, "permission_profile_keys", contract.permission_profile_keys, PERMISSIONS);
pushIf(errors, !isNonEmptyString(contract.routing_slot), `${contractKey}.routing_slot must be set`);
validateEnumArray(
errors,
contractKey,
"permission_profile_keys",
contract.permission_profile_keys,
PERMISSIONS,
);
pushIf(
errors,
!isNonEmptyString(contract.routing_slot),
`${contractKey}.routing_slot must be set`,
);
pushIf(
errors,
isNonEmptyString(contract.routing_slot) &&
@@ -1154,19 +1276,72 @@ function validateContractRegistry(registry, matrixRefs, artifactGraphRefs) {
`${contractKey}.routing_slot is not defined in capability matrix: ${String(contract.routing_slot)}`,
);
validateExecutor(errors, contract);
validateStringArray(errors, contractKey, "truth_source", contract.truth_source);
validateEnumArray(errors, contractKey, "artifact_kinds", contract.artifact_kinds, ARTIFACT_KINDS);
validateEnumArray(errors, contractKey, "viewer_surface", contract.viewer_surface, VIEWER_SURFACES);
validateEnumArray(errors, contractKey, "evidence_events", contract.evidence_events, EVIDENCE_EVENTS);
validateEnumArray(errors, contractKey, "limecore_policy_refs", contract.limecore_policy_refs, LIMECORE_POLICY_REFS);
validateStringArray(errors, contractKey, "fallback_policy", contract.fallback_policy);
validateStringArray(
errors,
contractKey,
"truth_source",
contract.truth_source,
);
validateEnumArray(
errors,
contractKey,
"artifact_kinds",
contract.artifact_kinds,
ARTIFACT_KINDS,
);
validateEnumArray(
errors,
contractKey,
"viewer_surface",
contract.viewer_surface,
VIEWER_SURFACES,
);
validateEnumArray(
errors,
contractKey,
"evidence_events",
contract.evidence_events,
EVIDENCE_EVENTS,
);
validateEnumArray(
errors,
contractKey,
"limecore_policy_refs",
contract.limecore_policy_refs,
LIMECORE_POLICY_REFS,
);
validateStringArray(
errors,
contractKey,
"fallback_policy",
contract.fallback_policy,
);
pushIf(errors, !isPlainObject(contract.detour_policy), `${contractKey}.detour_policy must be an object`);
pushIf(
errors,
!isPlainObject(contract.detour_policy),
`${contractKey}.detour_policy must be an object`,
);
if (isPlainObject(contract.detour_policy)) {
validateStringArray(errors, contractKey, "detour_policy.allowed", contract.detour_policy.allowed, { allowEmpty: true });
validateStringArray(errors, contractKey, "detour_policy.denied", contract.detour_policy.denied);
validateStringArray(
errors,
contractKey,
"detour_policy.allowed",
contract.detour_policy.allowed,
{ allowEmpty: true },
);
validateStringArray(
errors,
contractKey,
"detour_policy.denied",
contract.detour_policy.denied,
);
}
pushIf(errors, !isNonEmptyString(contract.owner_surface), `${contractKey}.owner_surface must be set`);
pushIf(
errors,
!isNonEmptyString(contract.owner_surface),
`${contractKey}.owner_surface must be set`,
);
validateEntryBindings(errors, contract);
validateContractArtifactGraph(errors, contract, artifactGraphRefs);
}
@@ -1187,7 +1362,86 @@ function validateRequiredDocs() {
});
}
function renderSuccess(registry, matrix, graph, contractReport, profileReport) {
function readRequiredTextFile(errors, filePath) {
const absolutePath = path.resolve(process.cwd(), filePath);
if (!fs.existsSync(absolutePath)) {
errors.push(`required task index source file is missing: ${filePath}`);
return "";
}
return fs.readFileSync(absolutePath, "utf8");
}
function validateTaskIndexPresentationGuard() {
const errors = [];
const presentationSource = readRequiredTextFile(
errors,
TASK_INDEX_PRESENTATION_PATH,
);
const sectionSource = readRequiredTextFile(
errors,
HARNESS_TASK_INDEX_SECTION_PATH,
);
const panelSource = readRequiredTextFile(errors, HARNESS_STATUS_PANEL_PATH);
for (const exportName of REQUIRED_TASK_INDEX_PRESENTATION_EXPORTS) {
pushIf(
errors,
!presentationSource.includes(`export function ${exportName}`),
`${TASK_INDEX_PRESENTATION_PATH} must export ${exportName} as the Phase 8 taskIndex query fact source`,
);
pushIf(
errors,
!sectionSource.includes(exportName),
`${HARNESS_TASK_INDEX_SECTION_PATH} must consume ${exportName} instead of rebuilding taskIndex UI state`,
);
}
pushIf(
errors,
!sectionSource.includes(
'from "@/lib/agentRuntime/modalityTaskIndexPresentation"',
),
`${HARNESS_TASK_INDEX_SECTION_PATH} must import the shared taskIndex presentation helpers`,
);
pushIf(
errors,
!sectionSource.includes("任务中心过滤列表"),
`${HARNESS_TASK_INDEX_SECTION_PATH} must keep the task center filter surface attached to shared taskIndex rows`,
);
pushIf(
errors,
!panelSource.includes(
'import { HarnessTaskIndexSection } from "./HarnessTaskIndexSection";',
) || !panelSource.includes("<HarnessTaskIndexSection"),
`${HARNESS_STATUS_PANEL_PATH} must delegate the taskIndex surface to HarnessTaskIndexSection`,
);
const forbiddenPanelSnippets = [
"buildModalityTaskIndexRows",
"filterModalityTaskIndexRows",
"function TaskIndexItemCard",
"function TaskIndexFilterSelect",
"function TaskIndexSummarySection",
];
for (const snippet of forbiddenPanelSnippets) {
pushIf(
errors,
panelSource.includes(snippet),
`${HARNESS_STATUS_PANEL_PATH} must not inline taskIndex query/list UI (${snippet}); use ${HARNESS_TASK_INDEX_SECTION_PATH}`,
);
}
return { errors };
}
function renderSuccess(
registry,
matrix,
graph,
contractReport,
profileReport,
taskIndexPresentationReport,
) {
const currentCount = registry.contracts.filter(
(contract) => contract.lifecycle === "current",
).length;
@@ -1200,6 +1454,10 @@ function renderSuccess(registry, matrix, graph, contractReport, profileReport) {
` artifact kinds: ${graph.artifact_kinds.length}`,
` entry bindings: ${contractReport.entryBindingReport.entryBindingCount}`,
` task index core fields: ${REQUIRED_ARTIFACT_INDEX_FIELDS.size}`,
` media phase8 index fields: ${REQUIRED_MEDIA_TASK_PHASE8_INDEX_FIELDS.size}`,
` task index presentation guard: ${
taskIndexPresentationReport.errors.length === 0 ? "current" : "failed"
}`,
` execution profiles: ${profileReport.profileCount}`,
` executor adapters: ${profileReport.adapterCount}`,
` registry: ${CONTRACT_PATH}`,
@@ -1227,12 +1485,14 @@ function main() {
matrixReport,
graphReport,
);
const taskIndexPresentationReport = validateTaskIndexPresentationGuard();
const errors = [
...validateRequiredDocs(),
...matrixReport.errors,
...graphReport.errors,
...contractReport.errors,
...profileReport.errors,
...taskIndexPresentationReport.errors,
];
if (errors.length > 0) {
@@ -1243,7 +1503,16 @@ function main() {
process.exit(1);
}
console.log(renderSuccess(registry, matrix, graph, contractReport, profileReport));
console.log(
renderSuccess(
registry,
matrix,
graph,
contractReport,
profileReport,
taskIndexPresentationReport,
),
);
}
main();
+343
View File
@@ -0,0 +1,343 @@
#!/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/",
healthUrl: "http://127.0.0.1:3030/health",
invokeUrl: "http://127.0.0.1:3030/invoke",
timeoutMs: 180_000,
intervalMs: 1_000,
};
const ACTION_TIMEOUT_MS = 45_000;
const POST_HEALTH_SETTLE_MS = 1_000;
function printHelp() {
console.log(`
Lime Design Canvas Smoke
用途:
通过真实 Lime 页面验证 canvas:design Artifact 能进入 LayeredDesignDocument
图层设计画布,并能完成基础图层选择与移动交互。
用法:
npm run smoke:design-canvas
选项:
--app-url <url> 前端地址,默认 http://127.0.0.1:1420/
--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> 总超时,默认 180000
--interval-ms <ms> 轮询间隔,默认 1000
-h, --help 显示帮助
`);
}
function parseArgs(argv) {
const options = { ...DEFAULTS };
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--app-url" && argv[index + 1]) {
options.appUrl = String(argv[index + 1]).trim();
index += 1;
continue;
}
if (arg === "--health-url" && argv[index + 1]) {
options.healthUrl = String(argv[index + 1]).trim();
index += 1;
continue;
}
if (arg === "--invoke-url" && argv[index + 1]) {
options.invokeUrl = String(argv[index + 1]).trim();
index += 1;
continue;
}
if (arg === "--timeout-ms" && argv[index + 1]) {
options.timeoutMs = Number(argv[index + 1]);
index += 1;
continue;
}
if (arg === "--interval-ms" && argv[index + 1]) {
options.intervalMs = Number(argv[index + 1]);
index += 1;
continue;
}
if (arg === "--help" || arg === "-h") {
printHelp();
process.exit(0);
}
}
if (!Number.isFinite(options.timeoutMs) || options.timeoutMs < 30_000) {
throw new Error("--timeout-ms 必须是 >= 30000 的数字");
}
if (!Number.isFinite(options.intervalMs) || options.intervalMs < 100) {
throw new Error("--interval-ms 必须是 >= 100 的数字");
}
if (!options.appUrl || !options.healthUrl || !options.invokeUrl) {
throw new Error("--app-url、--health-url、--invoke-url 均不能为空");
}
return options;
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
function logStage(label) {
console.log(`[smoke:design-canvas] stage=${label}`);
}
function pickStringField(target, ...keys) {
if (!target || typeof target !== "object") {
return "";
}
for (const key of keys) {
const value = target[key];
if (typeof value === "string" && value.trim()) {
return value.trim();
}
}
return "";
}
async function waitForHealth(options) {
const startedAt = Date.now();
let lastError = null;
while (Date.now() - startedAt < options.timeoutMs) {
try {
const response = await fetch(options.healthUrl, { method: "GET" });
const payload = await response.json();
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
console.log(
`[smoke:design-canvas] DevBridge 已就绪 (${Date.now() - startedAt}ms)${
payload?.status ? ` status=${payload.status}` : ""
}`,
);
return;
} catch (error) {
lastError = error;
await sleep(options.intervalMs);
}
}
const detail =
lastError instanceof Error
? lastError.message
: String(lastError || "unknown error");
throw new Error(
`[smoke:design-canvas] DevBridge 未就绪,请先启动 npm run tauri:dev:headless。最后错误: ${detail}`,
);
}
async function invoke(options, cmd, args) {
const response = await fetch(options.invokeUrl, {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({ cmd, args }),
signal: AbortSignal.timeout(Math.min(options.timeoutMs, 180_000)),
});
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;
}
async function resolveDefaultWorkspace(options) {
const defaultProject = await invoke(options, "get_or_create_default_project");
assert(
defaultProject && typeof defaultProject === "object",
"get_or_create_default_project 返回为空",
);
const projectId = pickStringField(defaultProject, "id");
assert(projectId, "默认 workspace 缺少 id");
const ensuredWorkspace = await invoke(options, "workspace_ensure_ready", {
id: projectId,
});
const rootPath =
pickStringField(ensuredWorkspace, "rootPath", "root_path") ||
pickStringField(defaultProject, "rootPath", "root_path");
assert(rootPath, "默认 workspace 缺少 rootPath");
return {
projectId,
rootPath,
};
}
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);
return url.toString();
}
async function waitForText(page, label, text) {
try {
await page.getByText(text).first().waitFor({
state: "visible",
timeout: ACTION_TIMEOUT_MS,
});
} catch (error) {
const bodyText = await page.locator("body").innerText().catch(() => "");
throw new Error(
`[smoke:design-canvas] ${label} 等待失败,缺少文本 ${JSON.stringify(
text,
)};页面文本片段: ${JSON.stringify(bodyText.slice(0, 1200))}`,
);
}
}
async function runPageFlow(options, smokeUrl) {
const userDataDir = fs.mkdtempSync(
path.join(os.tmpdir(), `lime-design-canvas-smoke-${process.pid}-`),
);
const launchOptions = {
headless: true,
viewport: { width: 1440, height: 980 },
};
let context = null;
try {
context = await chromium.launchPersistentContext(userDataDir, {
...launchOptions,
channel: "chrome",
});
} catch (chromeError) {
console.warn(
`[smoke:design-canvas] Chrome channel 启动失败,尝试 Playwright 自带 Chromium: ${
chromeError instanceof Error ? chromeError.message : String(chromeError)
}`,
);
context = await chromium.launchPersistentContext(userDataDir, launchOptions);
}
const page = context.pages()[0] ?? (await context.newPage());
const consoleErrors = [];
page.on("console", (message) => {
if (message.type() === "error") {
consoleErrors.push(message.text());
}
});
page.on("pageerror", (error) => {
consoleErrors.push(error.stack || error.message);
});
try {
logStage("open-design-canvas-page");
await page.goto(smokeUrl, {
waitUntil: "domcontentloaded",
timeout: options.timeoutMs,
});
logStage("wait-design-canvas");
await page
.locator('[data-testid="design-canvas-smoke-page"]')
.waitFor({ state: "visible", timeout: ACTION_TIMEOUT_MS });
await page
.locator('[data-testid="design-canvas"]')
.waitFor({ state: "visible", timeout: ACTION_TIMEOUT_MS });
await waitForText(page, "smoke 标题", "canvas:design 专属 GUI Smoke");
await waitForText(page, "artifact 类型", "canvas:design");
await waitForText(page, "事实源标记", "LayeredDesignDocument");
await waitForText(page, "画布标题", "Smoke 图层设计海报");
await waitForText(page, "图层栏", "图层");
await waitForText(page, "属性栏", "属性");
await waitForText(page, "生成入口", "生成全部图片层");
await waitForText(page, "刷新入口", "刷新生成结果");
await waitForText(page, "单层重生成入口", "重生成当前层");
await waitForText(page, "导出入口", "导出设计工程");
logStage("interact-layer");
await page.getByRole("button", { name: "选择图层 主标题" }).click({
timeout: ACTION_TIMEOUT_MS,
});
await waitForText(page, "主标题选中", "主标题");
await page.getByRole("button", { name: "右移", exact: true }).click({
timeout: ACTION_TIMEOUT_MS,
});
await page.getByRole("button", { name: "隐藏", exact: true }).click({
timeout: ACTION_TIMEOUT_MS,
});
await page.getByRole("button", { name: "显示", exact: true }).click({
timeout: ACTION_TIMEOUT_MS,
});
if (consoleErrors.length > 0) {
throw new Error(
`[smoke:design-canvas] 页面存在 ${consoleErrors.length} 条 console error: ${JSON.stringify(
consoleErrors.slice(0, 5),
)}`,
);
}
} finally {
await context.close().catch(() => undefined);
fs.rmSync(userDataDir, { 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));
logStage("wait-health");
await waitForHealth(options);
await sleep(POST_HEALTH_SETTLE_MS);
logStage("resolve-default-workspace");
const workspace = await resolveDefaultWorkspace(options);
const smokeUrl = buildSmokeUrl(options, workspace);
await runPageFlow(options, smokeUrl);
console.log(
`[smoke:design-canvas] 通过 project=${workspace.projectId} root=${workspace.rootPath}`,
);
}
main().catch((error) => {
console.error(
error instanceof Error ? error.message : String(error || "unknown error"),
);
process.exit(1);
});
+100 -7
View File
@@ -49,6 +49,18 @@ const SECONDARY_PACK = {
].join("\n"),
};
const AGENT_RESULT_MESSAGE = {
id: "smoke-agent-result-knowledge",
title: "对话结果资料",
content: [
"# 对话结果资料",
"",
"- 事实:该结果来自当前 Agent 对话,用于验证生成结果可以沉淀成项目资料。",
"- 适用场景:用户拿到一段可复用结论后,可以一键保存,随后在项目资料管理页检查确认。",
"- 风险提示:沉淀后仍需人工确认,避免把临时分析当成长期事实。",
].join("\n"),
};
function printHelp() {
console.log(`
Lime Knowledge GUI Smoke
@@ -299,6 +311,34 @@ async function clickPageControl(page, { text, ariaLabel, index = 0 }) {
}
}
async function seedAgentResultForKnowledgeCapture(page, options) {
await page.evaluate(
({ projectId, message }) => {
const now = new Date().toISOString();
sessionStorage.setItem(
`aster_messages_${projectId}`,
JSON.stringify([
{
id: message.id,
role: "assistant",
content: message.content,
timestamp: now,
},
]),
);
sessionStorage.removeItem(`aster_curr_sessionId_${projectId}`);
sessionStorage.removeItem(`aster_last_sessionId_${projectId}`);
sessionStorage.removeItem(`aster_thread_turns_${projectId}`);
sessionStorage.removeItem(`aster_thread_items_${projectId}`);
sessionStorage.removeItem(`aster_curr_turnId_${projectId}`);
},
{
projectId: options.projectId,
message: AGENT_RESULT_MESSAGE,
},
);
}
async function createSmokeProject(options) {
const projectName = `Knowledge GUI Smoke ${process.pid}`;
const project = await invoke(options, "workspace_create", {
@@ -314,6 +354,13 @@ async function createSmokeProject(options) {
}
options.projectId = projectId;
options.projectName = String(project?.name || projectName);
const projectRootPath = String(
project?.rootPath || project?.root_path || "",
).trim();
if (projectRootPath) {
options.workingDir = projectRootPath;
fs.mkdirSync(options.workingDir, { recursive: true });
}
}
async function cleanupSmokeProject(options) {
@@ -394,14 +441,14 @@ async function runPlaywrightGuiFlow(options) {
await waitForPageText(page, "首页加载", ["青柠一下,灵感即来"], options.timeoutMs);
logStage("open-knowledge-page");
await clickPageControl(page, { ariaLabel: "知识库" });
await clickPageControl(page, { ariaLabel: "项目资料" });
logStage("wait-knowledge-overview");
await waitForPageText(
page,
"知识库总览加载",
[
"项目资料管理",
"项目资料",
"日常使用入口",
"回到 Agent",
"全部资料",
@@ -424,7 +471,10 @@ async function runPlaywrightGuiFlow(options) {
await waitForPageText(
page,
"Agent 页面加载",
["项目资料:未使用", "请基于当前项目资料生成内容"],
[
`正在使用:${DEFAULT_PACK.title}`,
"请基于当前项目资料生成内容",
],
options.timeoutMs,
);
} catch (error) {
@@ -438,8 +488,51 @@ async function runPlaywrightGuiFlow(options) {
throw error;
}
logStage("return-knowledge-before-agent-result");
await clickPageControl(page, { ariaLabel: "项目资料" });
logStage("prepare-agent-result");
await seedAgentResultForKnowledgeCapture(page, options);
await clickPageControl(page, { text: "用于生成" });
logStage("wait-agent-result");
await waitForPageText(
page,
"Agent 结果样本加载",
[
`正在使用:${DEFAULT_PACK.title}`,
"沉淀为项目资料",
"事实:该结果来自当前 Agent 对话",
],
options.timeoutMs,
);
logStage("capture-agent-result");
await clickPageControl(page, { ariaLabel: "沉淀为项目资料" });
logStage("wait-agent-result-captured");
await waitForPageText(
page,
"Agent 结果沉淀完成",
["项目资料已整理", AGENT_RESULT_MESSAGE.title],
options.timeoutMs,
);
logStage("return-knowledge-page");
await clickPageControl(page, { ariaLabel: "知识库" });
await clickPageControl(page, { ariaLabel: "项目资料" });
logStage("wait-captured-agent-result");
await waitForPageText(
page,
"沉淀资料进入管理页",
[
"项目资料",
"全部资料",
AGENT_RESULT_MESSAGE.title,
"继续确认",
],
options.timeoutMs,
);
logStage("open-import-view");
await clickPageControl(page, { text: "补充导入" });
@@ -518,12 +611,12 @@ async function main() {
await waitForHealth(options);
await sleep(POST_HEALTH_SETTLE_MS);
logStage("seed-knowledge-packs");
await seedKnowledgePacks(options);
logStage("create-smoke-project");
await createSmokeProject(options);
logStage("seed-knowledge-packs");
await seedKnowledgePacks(options);
await runPlaywrightGuiFlow(options);
console.log("[smoke:knowledge-gui] 通过");
} finally {
+11 -11
View File
@@ -303,7 +303,7 @@ describe("GitHub release asset staging", () => {
"arm-sig",
);
writeFile(
path.join(assetsDir, "aarch64-apple-darwin", "Lime_1.27.0_aarch64.dmg"),
path.join(assetsDir, "aarch64-apple-darwin", "Lime_1.28.0_aarch64.dmg"),
);
writeFile(path.join(assetsDir, "x86_64-apple-darwin", "Lime.app.tar.gz"));
writeFile(
@@ -311,7 +311,7 @@ describe("GitHub release asset staging", () => {
"x64-sig",
);
writeFile(
path.join(assetsDir, "x86_64-apple-darwin", "Lime_1.27.0_x64.dmg"),
path.join(assetsDir, "x86_64-apple-darwin", "Lime_1.28.0_x64.dmg"),
);
writeFile(latestPath, "{}");
@@ -319,29 +319,29 @@ describe("GitHub release asset staging", () => {
assetsDir,
extraAssets: [latestPath],
outDir,
version: "v1.27.0",
version: "v1.28.0",
});
expect(copied.map((item) => item.name).sort()).toEqual(
[
"Lime_1.27.0_aarch64.app.tar.gz",
"Lime_1.27.0_aarch64.app.tar.gz.sig",
"Lime_1.27.0_aarch64.dmg",
"Lime_1.27.0_x64.app.tar.gz",
"Lime_1.27.0_x64.app.tar.gz.sig",
"Lime_1.27.0_x64.dmg",
"Lime_1.28.0_aarch64.app.tar.gz",
"Lime_1.28.0_aarch64.app.tar.gz.sig",
"Lime_1.28.0_aarch64.dmg",
"Lime_1.28.0_x64.app.tar.gz",
"Lime_1.28.0_x64.app.tar.gz.sig",
"Lime_1.28.0_x64.dmg",
"latest.json",
].sort(),
);
expect(
fs.readFileSync(
path.join(outDir, "Lime_1.27.0_aarch64.app.tar.gz.sig"),
path.join(outDir, "Lime_1.28.0_aarch64.app.tar.gz.sig"),
"utf8",
),
).toBe("arm-sig");
expect(
fs.readFileSync(
path.join(outDir, "Lime_1.27.0_x64.app.tar.gz.sig"),
path.join(outDir, "Lime_1.28.0_x64.app.tar.gz.sig"),
"utf8",
),
).toBe("x64-sig");
+21
View File
@@ -1424,6 +1424,27 @@ async function main() {
options.timeoutMs + 30_000,
);
runCommand(
npmCommand,
[
"run",
"smoke:design-canvas",
"--",
"--app-url",
options.appUrl,
"--health-url",
options.healthUrl,
"--invoke-url",
options.invokeUrl,
"--timeout-ms",
String(options.timeoutMs),
"--interval-ms",
String(options.intervalMs),
],
"smoke:design-canvas",
options.timeoutMs + 30_000,
);
await cleanupStaleGuiSmokeChromeProfiles(options, {
label: "收尾清理本轮 smoke Chrome profiles",
required: true,