@tea-agent/loop-agent 0.39.0-next.25 → 0.39.0-next.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/dist/build-stamp.json +2 -2
- package/dist/executors/pi-executor.js +51 -0
- package/dist/executors/pi-sdk-executor.js +103 -52
- package/dist/executors/shell-executor.js +54 -0
- package/dist/worker/observe/static/state.js +2 -2
- package/dist/worker/observe/static/views/session-timeline.js +18 -5
- package/dist/workflows/dag/frontend-test-case-quality.js +5 -13
- package/dist/workflows/dag/frontend-test-environment-probe.js +227 -0
- package/dist/workflows/dag/frontend-test-markdown.js +61 -0
- package/dist/workflows/dag/frontend-test-result-contract.js +10 -18
- package/dist/workflows/dag/frontend-test-standard-scenarios.js +68 -0
- package/dist/workflows/dag/init-hybrid.js +8 -16
- package/dist/workflows/dag/rerun-plan.js +22 -3
- package/dist/workflows/dag/types.js +4 -0
- package/dist/workflows/dag/validate.js +2 -0
- package/docs/templates/agent-dag.schema.json +31 -0
- package/docs/templates/frontend-test-dag.json +8 -10
- package/docs/templates/frontend-test-dag.retrieve-context.prompt.md +1 -1
- package/package.json +1 -1
- package/skills/codebase-scout/SKILL.md +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -33,6 +33,12 @@
|
|
|
33
33
|
|
|
34
34
|
### 改进
|
|
35
35
|
|
|
36
|
+
- frontend-test prepare-package 会从 `docs/templates/`、`harness.json` `governanceRoot`/templates 以及 init 投影的 `ai_workspace/loop-agent/templates/` 拷贝标准场景;full init 目标仓不再静默落到仅含 `STD-FE-SMOKE-ENTRY` 的 fallback。
|
|
37
|
+
- frontend-test 环境探测区分 connection-refused(curl 7)与 HTTP 4xx/5xx,并提示先启动本地前端后再从 probe 节点续跑;不再把所有不可达都写成同一句 `frontend-base-url-unreachable`。
|
|
38
|
+
- frontend-test 报告解析用例 Markdown 的 `##`/`###` 标题与有序列表(`测试步骤` / `预期结果` / `前置条件与重置`);缺 `测试目的` 时回退到用例 H1,避免主 HTML 出现空步骤。
|
|
39
|
+
- `dag rerun --from-node` 把尚未启动的 writer/map 视为首次执行;frontend-test probe 这种失败的 exclusive shell 可以带着下游未跑节点续跑,不必整图 `standaloneTaskRerun`。
|
|
40
|
+
- Pi SDK 节点在 stall watchdog(默认 15 分钟无 provider 活动)触发后,会先 abort 当前等待,并在同一会话自动发送一次「继续」;仅 abort 已确认且尚未调用 write 工具时恢复,失败仍判 timeout。绝对墙钟超时与 CLI 后端不走这条恢复。
|
|
41
|
+
- DAG 节点检查器「执行过程」时间线默认展开原始协议事件(`message_start` / `message_end`),按钮文案为「隐藏原始协议事件」;用户可点选关闭,同会话内选择会保持。
|
|
36
42
|
- Operator Chat 的乐观停止现在把“视觉已停止”和 Turn ownership 分开:Abort 获得可靠终态前明确显示“正在停止”,此时可为原会话保存一条服务端持久化的“停止后发送”消息;后一次覆盖前一次,切换会话、刷新页面或 Console 重启不会丢失,旧 Turn 结束后复用正常 Turn 准入路径自动发送,并以固定 `clientRequestId` 防止重复执行。
|
|
37
43
|
- Operator Chat 修复快速终止时“停止后发送”与普通发送之间的准入空洞:发送路径改读实时 ownership;若 deferred 请求恰好撞上旧 Turn 终态,会先按服务端状态对账,再使用同一 `clientRequestId` 自动转为普通发送,不再要求用户刷新重试或丢失本次提交。
|
|
38
44
|
- `dag rerun-task` 现在会把父 run 最后一轮仍未解决的 `verify-pi` / review 反馈、证据引用和修订轮次带入新完整 DAG;planner、implementer 与 verifier/reviewer 必须修复或举证说明不再适用,不能仅因文件已存在就返回 `already-satisfied`。Console 同步区分「自动修订」「从失败节点恢复」「带反馈重新执行任务」。
|
package/dist/build-stamp.json
CHANGED
|
@@ -408,6 +408,50 @@ export class BoundedTextPreview {
|
|
|
408
408
|
].join("\n");
|
|
409
409
|
}
|
|
410
410
|
}
|
|
411
|
+
/**
|
|
412
|
+
* Normalize provider-specific completion-limit labels to the DAG's canonical
|
|
413
|
+
* `length` signal. Other terminal reasons remain observable without being
|
|
414
|
+
* treated as truncation.
|
|
415
|
+
*/
|
|
416
|
+
export function normalizeProviderStopReason(value) {
|
|
417
|
+
if (typeof value !== "string" || value.trim().length === 0)
|
|
418
|
+
return undefined;
|
|
419
|
+
const trimmed = value.trim();
|
|
420
|
+
const compact = trimmed.toLowerCase().replace(/[\s_-]/g, "");
|
|
421
|
+
if ([
|
|
422
|
+
"length",
|
|
423
|
+
"maxtokens",
|
|
424
|
+
"maxoutputtokens",
|
|
425
|
+
"outputlimit",
|
|
426
|
+
"tokenlimit",
|
|
427
|
+
"tokenlimitreached",
|
|
428
|
+
].includes(compact)) {
|
|
429
|
+
return "length";
|
|
430
|
+
}
|
|
431
|
+
return trimmed;
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* Extract a terminal stop reason from the provider event shapes used by Pi
|
|
435
|
+
* SDK/CLI bridges. Providers disagree on `stop*` versus `finish*`; keep this
|
|
436
|
+
* boundary centralized so downstream DAG recovery sees one canonical signal.
|
|
437
|
+
*/
|
|
438
|
+
export function extractPiEventStopReason(event) {
|
|
439
|
+
const nested = [event.message, event.response, event.completion].filter(isRecord);
|
|
440
|
+
const containers = [...nested, event];
|
|
441
|
+
for (const container of containers) {
|
|
442
|
+
for (const key of [
|
|
443
|
+
"stopReason",
|
|
444
|
+
"stop_reason",
|
|
445
|
+
"finishReason",
|
|
446
|
+
"finish_reason",
|
|
447
|
+
]) {
|
|
448
|
+
const normalized = normalizeProviderStopReason(container[key]);
|
|
449
|
+
if (normalized)
|
|
450
|
+
return normalized;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
return undefined;
|
|
454
|
+
}
|
|
411
455
|
export class PiJsonlStreamCollector {
|
|
412
456
|
lineBuffer = "";
|
|
413
457
|
discardUntilNewline = false;
|
|
@@ -415,6 +459,7 @@ export class PiJsonlStreamCollector {
|
|
|
415
459
|
parsedEvents = 0;
|
|
416
460
|
tokensUsed = 0;
|
|
417
461
|
outputTooLarge = false;
|
|
462
|
+
stopReason;
|
|
418
463
|
subagentTotalCalls = 0;
|
|
419
464
|
subagentFailedCalls = 0;
|
|
420
465
|
subagentModes = new Set();
|
|
@@ -454,6 +499,7 @@ export class PiJsonlStreamCollector {
|
|
|
454
499
|
assistantText: this.assistantText.trim(),
|
|
455
500
|
outputTooLarge: this.outputTooLarge,
|
|
456
501
|
parsedEvents: this.parsedEvents,
|
|
502
|
+
stopReason: this.stopReason,
|
|
457
503
|
subagentStats: this.formatSubagentStats(),
|
|
458
504
|
tokensUsed: this.tokensUsed,
|
|
459
505
|
};
|
|
@@ -472,6 +518,10 @@ export class PiJsonlStreamCollector {
|
|
|
472
518
|
}
|
|
473
519
|
}
|
|
474
520
|
consumeEvent(event) {
|
|
521
|
+
const stopReason = extractPiEventStopReason(event);
|
|
522
|
+
if (stopReason === "length" || !this.stopReason) {
|
|
523
|
+
this.stopReason = stopReason ?? this.stopReason;
|
|
524
|
+
}
|
|
475
525
|
const candidate = extractAssistantTextFromEvent(event);
|
|
476
526
|
if (candidate !== undefined) {
|
|
477
527
|
if (candidate.length > MAX_ASSISTANT_TEXT_CHARS) {
|
|
@@ -865,6 +915,7 @@ async function executeSingleCliAttempt(options, modelConfig, promptFilePath) {
|
|
|
865
915
|
stdoutTruncated: stdoutPreview.truncated,
|
|
866
916
|
stderrTruncated: stderrPreview.truncated,
|
|
867
917
|
outputTooLarge: collected.outputTooLarge,
|
|
918
|
+
stopReason: collected.stopReason,
|
|
868
919
|
timedOut,
|
|
869
920
|
tokensUsed: collected.tokensUsed,
|
|
870
921
|
subagentStats: collected.subagentStats,
|
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
import { appendFile, mkdir } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { BoundedTextPreview, classifyPiFailure, createPiJsonlStreamCollector, DEFAULT_ABORT_GRACE_MS, DEFAULT_STALL_TIMEOUT_MS, DEFAULT_TIMEOUT_MS, extractAssistantTextFromPiJson, } from "./pi-executor.js";
|
|
3
|
+
import { BoundedTextPreview, classifyPiFailure, createPiJsonlStreamCollector, DEFAULT_ABORT_GRACE_MS, DEFAULT_STALL_TIMEOUT_MS, DEFAULT_TIMEOUT_MS, extractPiEventStopReason, extractAssistantTextFromPiJson, } from "./pi-executor.js";
|
|
4
4
|
import { serializeSessionEvent } from "./pi-event-serializer.js";
|
|
5
5
|
import { PI_RECOMMENDED_COMPACTION, PI_RECOMMENDED_RETRY, } from "../shared/pi-retry-settings.js";
|
|
6
|
+
/** Same-session recovery after a transport stall. Exactly one continue per SDK attempt. */
|
|
7
|
+
export const PI_SDK_STALL_CONTINUE_MAX = 1;
|
|
8
|
+
/**
|
|
9
|
+
* Fixed follow-up sent after aborting a stalled prompt.
|
|
10
|
+
* Kept short so the model can resume without repeating the original task payload.
|
|
11
|
+
*/
|
|
12
|
+
export const PI_SDK_STALL_CONTINUE_PROMPT = "继续。上一轮因长时间无 provider 活动已被中止;请从中断处接着完成,不要重复已完成的步骤。若此前没有有效输出,请重新执行原任务。";
|
|
6
13
|
let sdkSessionFactoryOverride;
|
|
7
14
|
let sdkImportOverrideForTests;
|
|
8
15
|
let sdkModuleOverrideForTests;
|
|
@@ -404,25 +411,6 @@ function extractSdkUsageSample(event) {
|
|
|
404
411
|
const responseKey = responseKeyCandidates.find((value) => typeof value === "string" && value.length > 0);
|
|
405
412
|
return responseKey ? { responseKey, tokens } : { tokens };
|
|
406
413
|
}
|
|
407
|
-
/** Extract a non-empty stop reason from a terminal SDK event.
|
|
408
|
-
* Probes multiple field shapes across SDK versions so a missing field fails
|
|
409
|
-
* open (undefined) rather than misclassifying an attempt. */
|
|
410
|
-
function readStopReason(event) {
|
|
411
|
-
const message = isRecord(event.message) ? event.message : undefined;
|
|
412
|
-
const candidates = [
|
|
413
|
-
message?.stop_reason,
|
|
414
|
-
message?.stopReason,
|
|
415
|
-
event.stopReason,
|
|
416
|
-
event.stop_reason,
|
|
417
|
-
];
|
|
418
|
-
for (const candidate of candidates) {
|
|
419
|
-
if (typeof candidate === "string" &&
|
|
420
|
-
candidate.trim().length > 0) {
|
|
421
|
-
return candidate.trim();
|
|
422
|
-
}
|
|
423
|
-
}
|
|
424
|
-
return undefined;
|
|
425
|
-
}
|
|
426
414
|
function aggregateSdkTokenUsage(samples) {
|
|
427
415
|
const identified = new Map();
|
|
428
416
|
let anonymousMaximum = 0;
|
|
@@ -444,6 +432,8 @@ function aggregateSdkTokenUsage(samples) {
|
|
|
444
432
|
* When reuseScope is active, only shared auth/model resources are reused; each attempt still
|
|
445
433
|
* creates and disposes its own session and resource loader. Session reuse across steps is
|
|
446
434
|
* deferred until a proven reset/isolation strategy exists.
|
|
435
|
+
* A transport stall may abort the current prompt and send one continue on the
|
|
436
|
+
* same session; that is in-attempt recovery, not cross-step session reuse.
|
|
447
437
|
*/
|
|
448
438
|
export async function executeSingleSdkAttempt(options) {
|
|
449
439
|
const modelConfig = options.modelConfig;
|
|
@@ -494,7 +484,7 @@ export async function executeSingleSdkAttempt(options) {
|
|
|
494
484
|
type === "message_end" ||
|
|
495
485
|
type === "agent_end" ||
|
|
496
486
|
type === "agent_settled") {
|
|
497
|
-
const candidate =
|
|
487
|
+
const candidate = extractPiEventStopReason(event);
|
|
498
488
|
if (candidate)
|
|
499
489
|
stopReason = candidate;
|
|
500
490
|
}
|
|
@@ -559,16 +549,30 @@ export async function executeSingleSdkAttempt(options) {
|
|
|
559
549
|
let resolveStall;
|
|
560
550
|
let resolveSettlement;
|
|
561
551
|
let settled = false;
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
552
|
+
let abortConfirmed = true;
|
|
553
|
+
const persistRuntimeNote = (event) => {
|
|
554
|
+
const line = serializeSessionEvent(event);
|
|
555
|
+
stdoutPreview.append(`${line}\n`);
|
|
556
|
+
stdoutCollector.append(`${line}\n`);
|
|
557
|
+
sessionEventAppender?.append(line, event);
|
|
558
|
+
};
|
|
559
|
+
const resetSettlement = () => {
|
|
560
|
+
settled = false;
|
|
561
|
+
return new Promise((resolve) => {
|
|
562
|
+
resolveSettlement = resolve;
|
|
563
|
+
});
|
|
564
|
+
};
|
|
565
|
+
const resetStallPromise = () => {
|
|
566
|
+
if (stallTimeoutMs <= 0) {
|
|
567
|
+
resolveStall = undefined;
|
|
568
|
+
return null;
|
|
569
|
+
}
|
|
570
|
+
return new Promise((resolve) => {
|
|
567
571
|
resolveStall = resolve;
|
|
568
|
-
})
|
|
569
|
-
|
|
572
|
+
});
|
|
573
|
+
};
|
|
570
574
|
const armStallWatchdog = () => {
|
|
571
|
-
if (
|
|
575
|
+
if (stallTimeoutMs <= 0 || !resolveStall)
|
|
572
576
|
return;
|
|
573
577
|
if (stallHandle)
|
|
574
578
|
clearTimeout(stallHandle);
|
|
@@ -609,17 +613,6 @@ export async function executeSingleSdkAttempt(options) {
|
|
|
609
613
|
stdoutCollector.append(`${line}\n`);
|
|
610
614
|
sessionEventAppender?.append(line, event);
|
|
611
615
|
});
|
|
612
|
-
const filePrefix = options.attachedFiles
|
|
613
|
-
.map((file) => `@${file}`)
|
|
614
|
-
.join(" ");
|
|
615
|
-
const promptMessage = filePrefix
|
|
616
|
-
? `${filePrefix}\n${options.userMessage}`
|
|
617
|
-
: options.userMessage;
|
|
618
|
-
const promptPromise = session.prompt(promptMessage);
|
|
619
|
-
// A settlement event is permitted to win the race while prompt() remains
|
|
620
|
-
// pending. Observe a later rejection so it never becomes unhandled.
|
|
621
|
-
void promptPromise.catch(() => { });
|
|
622
|
-
armStallWatchdog();
|
|
623
616
|
const timeoutPromise = timeoutMs > 0
|
|
624
617
|
? new Promise((resolve) => {
|
|
625
618
|
timeoutHandle = setTimeout(() => {
|
|
@@ -627,21 +620,79 @@ export async function executeSingleSdkAttempt(options) {
|
|
|
627
620
|
}, timeoutMs);
|
|
628
621
|
})
|
|
629
622
|
: null;
|
|
630
|
-
const
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
623
|
+
const runTurn = async (userMessage) => {
|
|
624
|
+
const settlementPromise = resetSettlement();
|
|
625
|
+
const stallPromise = resetStallPromise();
|
|
626
|
+
const promptPromise = session.prompt(userMessage);
|
|
627
|
+
// A settlement event is permitted to win the race while prompt() remains
|
|
628
|
+
// pending. Observe a later rejection so it never becomes unhandled.
|
|
629
|
+
void promptPromise.catch(() => { });
|
|
630
|
+
armStallWatchdog();
|
|
631
|
+
return Promise.race([
|
|
632
|
+
promptPromise.then(() => "done"),
|
|
633
|
+
settlementPromise,
|
|
634
|
+
...(timeoutPromise ? [timeoutPromise] : []),
|
|
635
|
+
...(stallPromise ? [stallPromise] : []),
|
|
636
|
+
]);
|
|
637
|
+
};
|
|
638
|
+
const disposeSession = async () => {
|
|
642
639
|
disposeAttempted = true;
|
|
643
640
|
const disposeConfirmed = await runSessionActionWithGrace("dispose", () => session.dispose());
|
|
644
641
|
terminationConfirmed = abortConfirmed && disposeConfirmed;
|
|
642
|
+
};
|
|
643
|
+
const failClosedAfterWait = async (reason, extraStderr, abortFirst = true) => {
|
|
644
|
+
timedOut = true;
|
|
645
|
+
appendStderr(reason === "stall"
|
|
646
|
+
? `pi SDK step stalled after ${stallTimeoutMs}ms with no provider activity`
|
|
647
|
+
: `pi SDK step timed out after ${timeoutMs}ms`);
|
|
648
|
+
if (extraStderr)
|
|
649
|
+
appendStderr(extraStderr);
|
|
650
|
+
if (abortFirst) {
|
|
651
|
+
abortConfirmed = await runSessionActionWithGrace("abort", () => session.abort());
|
|
652
|
+
}
|
|
653
|
+
await disposeSession();
|
|
654
|
+
};
|
|
655
|
+
const filePrefix = options.attachedFiles
|
|
656
|
+
.map((file) => `@${file}`)
|
|
657
|
+
.join(" ");
|
|
658
|
+
const promptMessage = filePrefix
|
|
659
|
+
? `${filePrefix}\n${options.userMessage}`
|
|
660
|
+
: options.userMessage;
|
|
661
|
+
let continuesSent = 0;
|
|
662
|
+
let raced = await runTurn(promptMessage);
|
|
663
|
+
if (raced === "stall") {
|
|
664
|
+
abortConfirmed = await runSessionActionWithGrace("abort", () => session.abort());
|
|
665
|
+
const withinAbsoluteTimeout = timeoutMs <= 0 || Date.now() - startedAt < timeoutMs;
|
|
666
|
+
const canContinue = abortConfirmed &&
|
|
667
|
+
writeToolCallCount === 0 &&
|
|
668
|
+
withinAbsoluteTimeout &&
|
|
669
|
+
continuesSent < PI_SDK_STALL_CONTINUE_MAX;
|
|
670
|
+
if (canContinue) {
|
|
671
|
+
// The aborted turn's stopReason must not leak onto a recovered continue.
|
|
672
|
+
stopReason = undefined;
|
|
673
|
+
continuesSent += 1;
|
|
674
|
+
persistRuntimeNote({
|
|
675
|
+
type: "loop-agent-stall-continue",
|
|
676
|
+
prompt: PI_SDK_STALL_CONTINUE_PROMPT,
|
|
677
|
+
maxContinues: PI_SDK_STALL_CONTINUE_MAX,
|
|
678
|
+
});
|
|
679
|
+
raced = await runTurn(PI_SDK_STALL_CONTINUE_PROMPT);
|
|
680
|
+
if (raced !== "done" && raced !== "settled") {
|
|
681
|
+
await failClosedAfterWait(raced === "stall" ? "stall" : "absolute-timeout", raced === "stall"
|
|
682
|
+
? "pi SDK stall continue did not recover; treating as timeout"
|
|
683
|
+
: "pi SDK stall continue hit the absolute timeout", true);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
else {
|
|
687
|
+
await failClosedAfterWait("stall", !abortConfirmed
|
|
688
|
+
? undefined
|
|
689
|
+
: writeToolCallCount > 0
|
|
690
|
+
? "pi SDK stall continue skipped because write tools already ran"
|
|
691
|
+
: "pi SDK stall continue skipped because absolute timeout elapsed", false);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
else if (raced === "absolute-timeout") {
|
|
695
|
+
await failClosedAfterWait("absolute-timeout");
|
|
645
696
|
}
|
|
646
697
|
}
|
|
647
698
|
catch (error) {
|
|
@@ -15,6 +15,8 @@ import { materializeBackendTestAnalysisContract } from "../workflows/dag/backend
|
|
|
15
15
|
import { extractBackendTestContractEnvelope } from "../workflows/dag/backend-test-contract-envelope.js";
|
|
16
16
|
import { materializeFrontendImplementationContract } from "../workflows/dag/frontend-implementation-contract.js";
|
|
17
17
|
import { materializeFrontendTestResult, validateFrontendCaseEvidence, } from "../workflows/dag/frontend-test-result-contract.js";
|
|
18
|
+
import { copyFrontendTestStandardScenarios } from "../workflows/dag/frontend-test-standard-scenarios.js";
|
|
19
|
+
import { probeFrontendTestEnvironment } from "../workflows/dag/frontend-test-environment-probe.js";
|
|
18
20
|
import { renderFrontendTestL5Report } from "../workflows/dag/frontend-test-l5-report.js";
|
|
19
21
|
import { validateFrontendCaseChecklist } from "../workflows/dag/frontend-test-case-checklist.js";
|
|
20
22
|
import { materializeFrontendTestCaseManifest } from "../workflows/dag/frontend-test-case-manifest.js";
|
|
@@ -2061,6 +2063,52 @@ async function executeFrontendTestHtmlReport(input, meta) {
|
|
|
2061
2063
|
};
|
|
2062
2064
|
}
|
|
2063
2065
|
}
|
|
2066
|
+
async function executeFrontendTestStandardScenarios(input) {
|
|
2067
|
+
const started = Date.now();
|
|
2068
|
+
try {
|
|
2069
|
+
const result = await copyFrontendTestStandardScenarios({
|
|
2070
|
+
workspaceRoot: input.cwd,
|
|
2071
|
+
});
|
|
2072
|
+
return {
|
|
2073
|
+
ok: true,
|
|
2074
|
+
stdout: JSON.stringify(result),
|
|
2075
|
+
stderr: "",
|
|
2076
|
+
failureCategory: "success",
|
|
2077
|
+
durationMs: Date.now() - started,
|
|
2078
|
+
};
|
|
2079
|
+
}
|
|
2080
|
+
catch (error) {
|
|
2081
|
+
return {
|
|
2082
|
+
ok: false,
|
|
2083
|
+
stdout: "",
|
|
2084
|
+
stderr: error instanceof Error ? error.message : String(error),
|
|
2085
|
+
failureCategory: "invalid-output",
|
|
2086
|
+
durationMs: Date.now() - started,
|
|
2087
|
+
};
|
|
2088
|
+
}
|
|
2089
|
+
}
|
|
2090
|
+
async function executeFrontendTestEnvironmentProbe(input) {
|
|
2091
|
+
const started = Date.now();
|
|
2092
|
+
const result = await probeFrontendTestEnvironment({
|
|
2093
|
+
workspaceRoot: input.cwd,
|
|
2094
|
+
});
|
|
2095
|
+
if (result.ok) {
|
|
2096
|
+
return {
|
|
2097
|
+
ok: true,
|
|
2098
|
+
stdout: result.stdout,
|
|
2099
|
+
stderr: "",
|
|
2100
|
+
failureCategory: "success",
|
|
2101
|
+
durationMs: Date.now() - started,
|
|
2102
|
+
};
|
|
2103
|
+
}
|
|
2104
|
+
return {
|
|
2105
|
+
ok: false,
|
|
2106
|
+
stdout: "",
|
|
2107
|
+
stderr: result.stderr,
|
|
2108
|
+
failureCategory: "nonzero-exit",
|
|
2109
|
+
durationMs: Date.now() - started,
|
|
2110
|
+
};
|
|
2111
|
+
}
|
|
2064
2112
|
async function executeFrontendTestEvidenceValidation(input) {
|
|
2065
2113
|
const started = Date.now();
|
|
2066
2114
|
try {
|
|
@@ -2339,6 +2387,12 @@ export async function executeDagShellNode(input, meta) {
|
|
|
2339
2387
|
};
|
|
2340
2388
|
}
|
|
2341
2389
|
}
|
|
2390
|
+
if (shell?.frontendTestStandardScenarios) {
|
|
2391
|
+
return executeFrontendTestStandardScenarios(input);
|
|
2392
|
+
}
|
|
2393
|
+
if (shell?.frontendTestEnvironmentProbe) {
|
|
2394
|
+
return executeFrontendTestEnvironmentProbe(input);
|
|
2395
|
+
}
|
|
2342
2396
|
if (shell?.frontendBrowserToolPreflight) {
|
|
2343
2397
|
const started = Date.now();
|
|
2344
2398
|
try {
|
|
@@ -76,8 +76,8 @@ export const uiState = {
|
|
|
76
76
|
dagNodeExecutionOutputLoading: false,
|
|
77
77
|
dagNodeExecutionOutputError: null,
|
|
78
78
|
dagNodeExecutionOutputKey: null,
|
|
79
|
-
/** When true,「执行过程」展开 message_start/end
|
|
80
|
-
sessionTimelineShowProtocol:
|
|
79
|
+
/** When true,「执行过程」展开 message_start/end 原始协议事件。默认展开,用户可点选隐藏。 */
|
|
80
|
+
sessionTimelineShowProtocol: true,
|
|
81
81
|
/** Open <details> keys restored across timeline re-renders. */
|
|
82
82
|
sessionTimelineOpenDetails: null,
|
|
83
83
|
lastSnapshot: null,
|
|
@@ -439,12 +439,25 @@ function ensureOpenDetailsSet() {
|
|
|
439
439
|
return uiState.sessionTimelineOpenDetails;
|
|
440
440
|
}
|
|
441
441
|
|
|
442
|
-
function
|
|
442
|
+
function closedDetailKey(detailKey) {
|
|
443
|
+
return `${detailKey}::user-closed`;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function bindDetailsToggle(details, detailKey, defaultOpen = false) {
|
|
443
447
|
const openSet = ensureOpenDetailsSet();
|
|
444
|
-
if (
|
|
448
|
+
if (defaultOpen) {
|
|
449
|
+
if (!openSet.has(closedDetailKey(detailKey))) details.open = true;
|
|
450
|
+
} else if (openSet.has(detailKey)) {
|
|
451
|
+
details.open = true;
|
|
452
|
+
}
|
|
445
453
|
details.addEventListener("toggle", () => {
|
|
446
|
-
if (details.open)
|
|
447
|
-
|
|
454
|
+
if (details.open) {
|
|
455
|
+
openSet.add(detailKey);
|
|
456
|
+
openSet.delete(closedDetailKey(detailKey));
|
|
457
|
+
} else {
|
|
458
|
+
openSet.delete(detailKey);
|
|
459
|
+
openSet.add(closedDetailKey(detailKey));
|
|
460
|
+
}
|
|
448
461
|
});
|
|
449
462
|
}
|
|
450
463
|
|
|
@@ -599,7 +612,7 @@ function renderProtocolCard(item) {
|
|
|
599
612
|
const details = document.createElement("details");
|
|
600
613
|
details.className = "process-timeline-details";
|
|
601
614
|
details.dataset.detailKey = item.detailKey;
|
|
602
|
-
bindDetailsToggle(details, item.detailKey);
|
|
615
|
+
bindDetailsToggle(details, item.detailKey, true);
|
|
603
616
|
const summary = document.createElement("summary");
|
|
604
617
|
summary.className = "process-timeline-summary";
|
|
605
618
|
summary.textContent = `消息收发 · ${item.protocolCount} 条协议事件`;
|
|
@@ -1,25 +1,17 @@
|
|
|
1
1
|
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { markdownSection } from "./frontend-test-markdown.js";
|
|
3
4
|
const CASE_ID = /^FE-[A-Za-z0-9]+-\d{3}-(?:core|boundary|flow|backend)$/i;
|
|
4
5
|
const AC_ID = /^AC(?:-[A-Z0-9]+)+$/i;
|
|
5
6
|
const PLACEHOLDER = /\b(?:TODO|TBD|FIXME)\b|结果正确|页面正常|按实际情况处理|验证成功/i;
|
|
6
7
|
const SECTION_ALIASES = {
|
|
7
8
|
purpose: ["Test Purpose", "测试目的", "测试场景"],
|
|
8
9
|
source: ["Source References", "需求依据"],
|
|
9
|
-
preconditions: ["Preconditions", "前置条件"],
|
|
10
|
-
steps: ["Steps", "操作步骤"],
|
|
10
|
+
preconditions: ["Preconditions", "前置条件", "前置条件与重置"],
|
|
11
|
+
steps: ["Test Steps", "测试步骤", "Steps", "操作步骤"],
|
|
11
12
|
expected: ["Expected Results", "预期结果"],
|
|
12
13
|
automation: ["Automation Notes", "自动化映射", "自动化说明"],
|
|
13
14
|
};
|
|
14
|
-
function section(body, names) {
|
|
15
|
-
const marker = new RegExp(`^###\\s+(?:${names.map((v) => v.replace(/[.*+?^${}()|[\\]\\]/g, "\\$&")).join("|")})\\s*$`, "mi");
|
|
16
|
-
const hit = marker.exec(body);
|
|
17
|
-
if (!hit)
|
|
18
|
-
return "";
|
|
19
|
-
const rest = body.slice(hit.index + hit[0].length);
|
|
20
|
-
const next = /^###\s+/m.exec(rest);
|
|
21
|
-
return rest.slice(0, next?.index ?? rest.length).trim();
|
|
22
|
-
}
|
|
23
15
|
function hasList(value) { return /^\s*(?:\d+[.)]|[-*+])\s+\S+/m.test(value); }
|
|
24
16
|
function hasAssertion(value) { return /(?:status|状态|包含|显示|等于|为|可见|不可见|跳转|错误|成功|失败|should|expect|assert|must)/i.test(value); }
|
|
25
17
|
export async function validateFrontendCaseContent(input) {
|
|
@@ -69,9 +61,9 @@ export async function validateFrontendCaseContent(input) {
|
|
|
69
61
|
if (PLACEHOLDER.test(body))
|
|
70
62
|
findings.push({ ruleId: "placeholder-wording", caseId: id, detail: "case contains placeholder or non-assertable wording" });
|
|
71
63
|
for (const [key, names] of Object.entries(SECTION_ALIASES))
|
|
72
|
-
if (!
|
|
64
|
+
if (!markdownSection(body, names))
|
|
73
65
|
findings.push({ ruleId: `missing-${key}`, caseId: id, detail: `missing section: ${names.join(" or ")}` });
|
|
74
|
-
const steps =
|
|
66
|
+
const steps = markdownSection(body, SECTION_ALIASES.steps), expected = markdownSection(body, SECTION_ALIASES.expected);
|
|
75
67
|
if (!hasList(steps))
|
|
76
68
|
findings.push({ ruleId: "unstructured-steps", caseId: id, detail: "steps should contain numbered or bulleted executable actions" });
|
|
77
69
|
if (!hasList(expected) || !hasAssertion(expected))
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
export const FRONTEND_TEST_CONTEXT_REL = "testcase/frontend/rag/context.md";
|
|
5
|
+
export const FRONTEND_TEST_PROBE_REL = "testcase/frontend/rag/environment-probe.json";
|
|
6
|
+
const CONNECTION_REFUSED_HINT = "被测服务未在 baseUrl 监听(connection refused)。请先启动本地前端(例如 scripts/serve.sh 或项目约定的 npm start),确认可访问后再从 materialize-frontend-test-execution-shell 续跑。";
|
|
7
|
+
export function classifyFrontendBaseUrlProbeFailure(input) {
|
|
8
|
+
if (input.spawnError)
|
|
9
|
+
return { errorClass: "spawn-error" };
|
|
10
|
+
const exit = input.curlExit ?? 0;
|
|
11
|
+
if (exit === 7) {
|
|
12
|
+
return { errorClass: "connection-refused", hint: CONNECTION_REFUSED_HINT };
|
|
13
|
+
}
|
|
14
|
+
if (exit === 6) {
|
|
15
|
+
return {
|
|
16
|
+
errorClass: "dns-unresolved",
|
|
17
|
+
hint: "无法解析 baseUrl 主机名。确认 context.md 中的地址可在本机解析,或改用 127.0.0.1。",
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
if (exit === 28) {
|
|
21
|
+
return {
|
|
22
|
+
errorClass: "connect-timeout",
|
|
23
|
+
hint: "连接 baseUrl 超时。确认被测服务已启动且端口可访问。",
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
if (exit !== 0)
|
|
27
|
+
return { errorClass: `curl-exit-${exit}` };
|
|
28
|
+
if (input.httpStatus && Number.isFinite(input.httpStatus)) {
|
|
29
|
+
return { errorClass: `http-${input.httpStatus}` };
|
|
30
|
+
}
|
|
31
|
+
return { errorClass: "unreachable" };
|
|
32
|
+
}
|
|
33
|
+
function defaultCurlRunner(args) {
|
|
34
|
+
const result = spawnSync("curl", [...args], { encoding: "utf8" });
|
|
35
|
+
return {
|
|
36
|
+
status: result.status,
|
|
37
|
+
stdout: result.stdout ?? "",
|
|
38
|
+
stderr: result.stderr ?? "",
|
|
39
|
+
error: result.error,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
function redactUrl(value) {
|
|
43
|
+
try {
|
|
44
|
+
const parsed = new URL(value);
|
|
45
|
+
parsed.username = "";
|
|
46
|
+
parsed.password = "";
|
|
47
|
+
parsed.search = "";
|
|
48
|
+
return parsed.toString();
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return value.replace(/\/\/[^@\s]+@/g, "//");
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function resolveBaseUrl(context) {
|
|
55
|
+
const patterns = [
|
|
56
|
+
/baseUrl\s*[:=]\s*["'`]?((?:https?):\/\/[^\s"'`<>]+)/i,
|
|
57
|
+
/base[-_ ]url\s*[:=]\s*["'`]?((?:https?):\/\/[^\s"'`<>]+)/i,
|
|
58
|
+
/playwright-cli open --browser=chrome\s+((?:https?):\/\/[^\s"'`<>]+)/i,
|
|
59
|
+
/(https?:\/\/(?:localhost|127\.0\.0\.1)[^\s)}\],"']*)/i,
|
|
60
|
+
];
|
|
61
|
+
for (const pattern of patterns) {
|
|
62
|
+
const match = context.match(pattern);
|
|
63
|
+
if (match?.[1])
|
|
64
|
+
return match[1].replace(/[)}\],"'.`]+$/g, "");
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
async function writeProbe(workspaceRoot, probe) {
|
|
69
|
+
const probePath = path.join(workspaceRoot, FRONTEND_TEST_PROBE_REL);
|
|
70
|
+
const contextPath = path.join(workspaceRoot, FRONTEND_TEST_CONTEXT_REL);
|
|
71
|
+
await mkdir(path.dirname(probePath), { recursive: true });
|
|
72
|
+
await writeFile(probePath, `${JSON.stringify(probe, null, 2)}\n`, "utf8");
|
|
73
|
+
let context = "";
|
|
74
|
+
try {
|
|
75
|
+
context = await readFile(contextPath, "utf8");
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
context = "";
|
|
79
|
+
}
|
|
80
|
+
const line = `environmentProbe: ${probe.status}${probe.blockedReason ? ` (${probe.blockedReason})` : ""}`;
|
|
81
|
+
const next = /environmentProbe\s*[:=]/i.test(context)
|
|
82
|
+
? context.replace(/environmentProbe\s*[:=]\s*.*/i, line)
|
|
83
|
+
: `${context.trimEnd()}\n\n${line}\n`;
|
|
84
|
+
await writeFile(contextPath, next, "utf8");
|
|
85
|
+
}
|
|
86
|
+
export async function probeFrontendTestEnvironment(input) {
|
|
87
|
+
const curl = input.curl ?? defaultCurlRunner;
|
|
88
|
+
const contextPath = path.join(input.workspaceRoot, FRONTEND_TEST_CONTEXT_REL);
|
|
89
|
+
let context;
|
|
90
|
+
try {
|
|
91
|
+
context = await readFile(contextPath, "utf8");
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return { ok: false, stderr: `missing ${FRONTEND_TEST_CONTEXT_REL}` };
|
|
95
|
+
}
|
|
96
|
+
const rawUrl = resolveBaseUrl(context);
|
|
97
|
+
if (!rawUrl) {
|
|
98
|
+
return {
|
|
99
|
+
ok: false,
|
|
100
|
+
stderr: "frontend-test preflight missing absolute baseUrl from context.md",
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
let parsed;
|
|
104
|
+
try {
|
|
105
|
+
parsed = new URL(rawUrl);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return { ok: false, stderr: `baseUrl must be absolute http(s): ${rawUrl}` };
|
|
109
|
+
}
|
|
110
|
+
if ((parsed.protocol !== "http:" && parsed.protocol !== "https:") ||
|
|
111
|
+
parsed.username ||
|
|
112
|
+
parsed.password ||
|
|
113
|
+
parsed.search ||
|
|
114
|
+
parsed.hash) {
|
|
115
|
+
return {
|
|
116
|
+
ok: false,
|
|
117
|
+
stderr: `unsafe baseUrl from context.md: ${redactUrl(rawUrl)}`,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
if (/(?:^|\.)(?:www\.)?[^.]*?(?:prod|production)/i.test(parsed.hostname)) {
|
|
121
|
+
return {
|
|
122
|
+
ok: false,
|
|
123
|
+
stderr: `production URL forbidden: ${redactUrl(rawUrl)}`,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
const baseUrl = parsed.toString();
|
|
127
|
+
const safe = redactUrl(baseUrl);
|
|
128
|
+
const sourceMatch = context.match(/baseUrlSource\s*[:=]\s*([^\r\n]+)/i);
|
|
129
|
+
const baseUrlSource = sourceMatch ? sourceMatch[1].trim() : "context.md";
|
|
130
|
+
const failBlocked = async (reason, extra) => {
|
|
131
|
+
const probe = {
|
|
132
|
+
status: "unreachable",
|
|
133
|
+
blockedReason: reason,
|
|
134
|
+
baseUrlRedacted: extra.baseUrlRedacted ?? safe,
|
|
135
|
+
httpStatus: extra.httpStatus ?? null,
|
|
136
|
+
method: extra.method ?? null,
|
|
137
|
+
curlExit: extra.curlExit ?? null,
|
|
138
|
+
errorClass: extra.errorClass ?? null,
|
|
139
|
+
...(extra.hint ? { hint: extra.hint } : {}),
|
|
140
|
+
...(extra.baseUrlSource ? { baseUrlSource: extra.baseUrlSource } : {}),
|
|
141
|
+
};
|
|
142
|
+
await writeProbe(input.workspaceRoot, probe);
|
|
143
|
+
const payload = {
|
|
144
|
+
blockedReason: reason,
|
|
145
|
+
baseUrl: probe.baseUrlRedacted,
|
|
146
|
+
httpStatus: probe.httpStatus,
|
|
147
|
+
errorClass: probe.errorClass,
|
|
148
|
+
...(probe.hint ? { hint: probe.hint } : {}),
|
|
149
|
+
};
|
|
150
|
+
return {
|
|
151
|
+
ok: false,
|
|
152
|
+
stderr: `frontend-test preflight blocked: ${JSON.stringify(payload)}`,
|
|
153
|
+
probe,
|
|
154
|
+
};
|
|
155
|
+
};
|
|
156
|
+
const curlCheck = curl(["--version"]);
|
|
157
|
+
if (curlCheck.error || curlCheck.status !== 0) {
|
|
158
|
+
return failBlocked("curl-unavailable", {
|
|
159
|
+
baseUrlRedacted: safe,
|
|
160
|
+
errorClass: "curl-missing",
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
const probeMethod = (method) => curl([
|
|
164
|
+
"-sS",
|
|
165
|
+
"-o",
|
|
166
|
+
"/dev/null",
|
|
167
|
+
"-w",
|
|
168
|
+
"%{http_code}",
|
|
169
|
+
"--connect-timeout",
|
|
170
|
+
"3",
|
|
171
|
+
"--max-time",
|
|
172
|
+
"8",
|
|
173
|
+
"-X",
|
|
174
|
+
method,
|
|
175
|
+
"-L",
|
|
176
|
+
"--max-redirs",
|
|
177
|
+
"3",
|
|
178
|
+
"--http1.1",
|
|
179
|
+
"--proto-redir",
|
|
180
|
+
"=http,https",
|
|
181
|
+
safe,
|
|
182
|
+
]);
|
|
183
|
+
let used = "HEAD";
|
|
184
|
+
let result = probeMethod("HEAD");
|
|
185
|
+
let code = String(result.stdout || "").trim();
|
|
186
|
+
let statusNum = Number.parseInt(code, 10);
|
|
187
|
+
const headRejected = result.status !== 0 || !statusNum || statusNum === 405 || statusNum === 501;
|
|
188
|
+
if (headRejected) {
|
|
189
|
+
used = "GET";
|
|
190
|
+
result = probeMethod("GET");
|
|
191
|
+
code = String(result.stdout || "").trim();
|
|
192
|
+
statusNum = Number.parseInt(code, 10);
|
|
193
|
+
}
|
|
194
|
+
const ok = statusNum >= 200 && statusNum < 400;
|
|
195
|
+
if (!ok) {
|
|
196
|
+
const classified = classifyFrontendBaseUrlProbeFailure({
|
|
197
|
+
spawnError: Boolean(result.error),
|
|
198
|
+
curlExit: result.status,
|
|
199
|
+
httpStatus: Number.isFinite(statusNum) ? statusNum : null,
|
|
200
|
+
});
|
|
201
|
+
return failBlocked("frontend-base-url-unreachable", {
|
|
202
|
+
baseUrlRedacted: safe,
|
|
203
|
+
httpStatus: Number.isFinite(statusNum) ? statusNum : null,
|
|
204
|
+
method: used,
|
|
205
|
+
curlExit: result.status,
|
|
206
|
+
errorClass: classified.errorClass,
|
|
207
|
+
hint: classified.hint,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
const probe = {
|
|
211
|
+
status: "reachable",
|
|
212
|
+
blockedReason: null,
|
|
213
|
+
baseUrl: safe,
|
|
214
|
+
baseUrlRedacted: safe,
|
|
215
|
+
baseUrlSource,
|
|
216
|
+
httpStatus: statusNum,
|
|
217
|
+
method: used,
|
|
218
|
+
curlExit: result.status,
|
|
219
|
+
errorClass: null,
|
|
220
|
+
};
|
|
221
|
+
await writeProbe(input.workspaceRoot, probe);
|
|
222
|
+
return {
|
|
223
|
+
ok: true,
|
|
224
|
+
stdout: `frontend-test-execution-v1 validated contextBaseUrl=${safe} source=${baseUrlSource} probe=reachable method=${used} httpStatus=${statusNum}`,
|
|
225
|
+
probe,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded ATX section/list parsing for frontend-test case Markdown.
|
|
3
|
+
* Generator cases use `##` headings; older fixtures use `###`.
|
|
4
|
+
*/
|
|
5
|
+
const ATX_HEADING = /^(#{1,3})\s+(\S.*)$/;
|
|
6
|
+
function headingMatches(title, names) {
|
|
7
|
+
const normalized = title.trim();
|
|
8
|
+
return names.some((name) => {
|
|
9
|
+
if (normalized === name)
|
|
10
|
+
return true;
|
|
11
|
+
return (normalized.startsWith(name) &&
|
|
12
|
+
(normalized.length === name.length ||
|
|
13
|
+
/[\s::与((-]/.test(normalized.charAt(name.length))));
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
export function markdownSection(body, names) {
|
|
17
|
+
const lines = body.replace(/\r\n/g, "\n").split("\n");
|
|
18
|
+
let start = -1;
|
|
19
|
+
let startLevel = 0;
|
|
20
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
21
|
+
const match = ATX_HEADING.exec(lines[index]);
|
|
22
|
+
if (!match)
|
|
23
|
+
continue;
|
|
24
|
+
if (!headingMatches(match[2], names))
|
|
25
|
+
continue;
|
|
26
|
+
start = index;
|
|
27
|
+
startLevel = match[1].length;
|
|
28
|
+
break;
|
|
29
|
+
}
|
|
30
|
+
if (start < 0)
|
|
31
|
+
return "";
|
|
32
|
+
const collected = [];
|
|
33
|
+
for (let index = start + 1; index < lines.length; index += 1) {
|
|
34
|
+
const match = ATX_HEADING.exec(lines[index]);
|
|
35
|
+
if (match && match[1].length <= startLevel)
|
|
36
|
+
break;
|
|
37
|
+
collected.push(lines[index]);
|
|
38
|
+
}
|
|
39
|
+
return collected.join("\n").trim();
|
|
40
|
+
}
|
|
41
|
+
export function markdownList(value) {
|
|
42
|
+
const items = [];
|
|
43
|
+
for (const raw of value.replace(/\r\n/g, "\n").split("\n")) {
|
|
44
|
+
const listed = raw.match(/^\s*(?:\d+[.)]|[-*+])\s+(.*)$/);
|
|
45
|
+
if (listed) {
|
|
46
|
+
const text = listed[1].trim();
|
|
47
|
+
if (text)
|
|
48
|
+
items.push(text);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
const continuation = raw.match(/^\s{2,}(\S.*)$/);
|
|
52
|
+
if (continuation && items.length > 0) {
|
|
53
|
+
items[items.length - 1] = `${items[items.length - 1]} ${continuation[1].trim()}`;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return items;
|
|
57
|
+
}
|
|
58
|
+
export function markdownH1Title(body) {
|
|
59
|
+
const match = /^#\s+(\S.*)$/m.exec(body.replace(/\r\n/g, "\n"));
|
|
60
|
+
return match?.[1]?.trim() ?? "";
|
|
61
|
+
}
|
|
@@ -5,6 +5,7 @@ import { z } from "zod";
|
|
|
5
5
|
import { writeDagRunJsonArtifact } from "../../infrastructure/harness/artifact-store.js";
|
|
6
6
|
import { readPlaywrightCliReceipts, summarizePlaywrightCliReceipts, } from "../../executors/pi-playwright-cli-tool.js";
|
|
7
7
|
import { validateFrontendCaseContent } from "./frontend-test-case-quality.js";
|
|
8
|
+
import { markdownH1Title, markdownList, markdownSection, } from "./frontend-test-markdown.js";
|
|
8
9
|
export const FRONTEND_TEST_RESULT_SCHEMA_ID = "frontend-test-result-v1";
|
|
9
10
|
const safeRelativePathSchema = z.string().min(1).refine((value) => !path.posix.isAbsolute(value) &&
|
|
10
11
|
!path.win32.isAbsolute(value) &&
|
|
@@ -220,34 +221,25 @@ async function loadCaseBrowserReceiptSummary(input) {
|
|
|
220
221
|
function sha256(content) {
|
|
221
222
|
return createHash("sha256").update(content).digest("hex");
|
|
222
223
|
}
|
|
223
|
-
function markdownSection(body, names) {
|
|
224
|
-
const escaped = names.map((name) => name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|");
|
|
225
|
-
const marker = new RegExp(`^###\\s+(?:${escaped})\\s*$`, "mi");
|
|
226
|
-
const hit = marker.exec(body);
|
|
227
|
-
if (!hit)
|
|
228
|
-
return "";
|
|
229
|
-
const rest = body.slice(hit.index + hit[0].length);
|
|
230
|
-
const next = /^###\s+/m.exec(rest);
|
|
231
|
-
return rest.slice(0, next?.index ?? rest.length).trim();
|
|
232
|
-
}
|
|
233
|
-
function markdownList(value) {
|
|
234
|
-
const items = value.split(/\r?\n/).map((line) => line.replace(/^\s*(?:\d+[.)]|[-*+])\s+/, "").trim()).filter(Boolean);
|
|
235
|
-
return items;
|
|
236
|
-
}
|
|
237
224
|
/**
|
|
238
225
|
* Read planned case facts from the case Markdown. Recognizes both English and
|
|
239
|
-
* Chinese headings
|
|
240
|
-
* test steps), alongside the legacy `操作步骤`/`Steps`
|
|
226
|
+
* Chinese headings at `##` or `###`, including `测试点` (test points) and
|
|
227
|
+
* `测试步骤` (planned test steps), alongside the legacy `操作步骤`/`Steps`
|
|
228
|
+
* headings. Numbered and bulleted lists are extracted; a missing 测试目的
|
|
229
|
+
* falls back to the case H1 title.
|
|
241
230
|
*/
|
|
242
231
|
async function readFrontendCaseContent(workspaceRoot, casePath) {
|
|
243
232
|
if (!safeRelativePathSchema.safeParse(casePath).success || !casePath.startsWith("testcase/frontend/cases/")) {
|
|
244
233
|
throw new Error(`unsafe frontend case path: ${casePath}`);
|
|
245
234
|
}
|
|
246
235
|
const body = await readFile(path.resolve(workspaceRoot, casePath), "utf8");
|
|
236
|
+
const purpose = markdownSection(body, ["Test Purpose", "测试目的", "测试场景"]) ||
|
|
237
|
+
markdownH1Title(body).replace(/^FE-[^\s::]+[::]\s*/, "").trim() ||
|
|
238
|
+
"未提供测试目的";
|
|
247
239
|
return {
|
|
248
240
|
caseContent: {
|
|
249
|
-
purpose
|
|
250
|
-
preconditions: markdownList(markdownSection(body, ["Preconditions", "前置条件"])),
|
|
241
|
+
purpose,
|
|
242
|
+
preconditions: markdownList(markdownSection(body, ["Preconditions", "前置条件", "前置条件与重置"])),
|
|
251
243
|
steps: markdownList(markdownSection(body, ["Test Steps", "测试步骤", "Steps", "操作步骤"])),
|
|
252
244
|
expectedResults: markdownList(markdownSection(body, ["Expected Results", "预期结果"])),
|
|
253
245
|
},
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
export const FRONTEND_TEST_STANDARD_SCENARIOS_FILENAME = "frontend-test-standard-scenarios.v1.json";
|
|
4
|
+
export const FRONTEND_TEST_STANDARD_SCENARIOS_DEST = "testcase/frontend/rag/standard-scenarios.v1.json";
|
|
5
|
+
const MINIMAL_STANDARD_SCENARIOS = {
|
|
6
|
+
schemaVersion: 1,
|
|
7
|
+
id: "frontend-test-standard-scenarios-v1",
|
|
8
|
+
scenarios: [
|
|
9
|
+
{
|
|
10
|
+
id: "STD-FE-SMOKE-ENTRY",
|
|
11
|
+
title: "入口可打开",
|
|
12
|
+
category: "smoke",
|
|
13
|
+
priority: "must",
|
|
14
|
+
testPoints: ["open"],
|
|
15
|
+
minCases: 1,
|
|
16
|
+
},
|
|
17
|
+
],
|
|
18
|
+
};
|
|
19
|
+
function isSafeRelativeDir(value) {
|
|
20
|
+
if (!value || path.isAbsolute(value) || path.win32.isAbsolute(value)) {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
return value.split(/[\\/]/).every((part) => part.length > 0 && part !== "." && part !== "..");
|
|
24
|
+
}
|
|
25
|
+
export function listFrontendTestStandardScenarioCandidates(input) {
|
|
26
|
+
const seen = new Set();
|
|
27
|
+
const add = (relative) => {
|
|
28
|
+
const normalized = relative.replaceAll("\\", "/");
|
|
29
|
+
if (!seen.has(normalized))
|
|
30
|
+
seen.add(normalized);
|
|
31
|
+
};
|
|
32
|
+
add(`docs/templates/${FRONTEND_TEST_STANDARD_SCENARIOS_FILENAME}`);
|
|
33
|
+
const governanceRoot = input.governanceRoot?.trim().replaceAll("\\", "/").replace(/\/+$/, "");
|
|
34
|
+
if (governanceRoot && isSafeRelativeDir(governanceRoot)) {
|
|
35
|
+
add(`${governanceRoot}/templates/${FRONTEND_TEST_STANDARD_SCENARIOS_FILENAME}`);
|
|
36
|
+
}
|
|
37
|
+
add(`ai_workspace/loop-agent/templates/${FRONTEND_TEST_STANDARD_SCENARIOS_FILENAME}`);
|
|
38
|
+
return [...seen];
|
|
39
|
+
}
|
|
40
|
+
async function readGovernanceRoot(workspaceRoot) {
|
|
41
|
+
try {
|
|
42
|
+
const raw = JSON.parse(await readFile(path.join(workspaceRoot, "harness.json"), "utf8"));
|
|
43
|
+
return typeof raw.governanceRoot === "string" ? raw.governanceRoot : undefined;
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
export async function copyFrontendTestStandardScenarios(input) {
|
|
50
|
+
const destRel = FRONTEND_TEST_STANDARD_SCENARIOS_DEST;
|
|
51
|
+
const destAbs = path.join(input.workspaceRoot, destRel);
|
|
52
|
+
const candidates = listFrontendTestStandardScenarioCandidates({
|
|
53
|
+
governanceRoot: await readGovernanceRoot(input.workspaceRoot),
|
|
54
|
+
});
|
|
55
|
+
await mkdir(path.dirname(destAbs), { recursive: true });
|
|
56
|
+
for (const relative of candidates) {
|
|
57
|
+
const sourceAbs = path.join(input.workspaceRoot, relative);
|
|
58
|
+
try {
|
|
59
|
+
await copyFile(sourceAbs, destAbs);
|
|
60
|
+
return { status: "copied", from: relative, to: destRel };
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// try the next candidate
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
await writeFile(destAbs, `${JSON.stringify(MINIMAL_STANDARD_SCENARIOS, null, 2)}\n`, "utf8");
|
|
67
|
+
return { status: "fallback", to: destRel };
|
|
68
|
+
}
|
|
@@ -4978,15 +4978,11 @@ function buildFrontendTestHybridDag(sources) {
|
|
|
4978
4978
|
writeSet: ragWriteSet,
|
|
4979
4979
|
allowedPaths: [...ragWriteSet],
|
|
4980
4980
|
forbiddenPaths: forbidden,
|
|
4981
|
-
outputContract: "Write testcase/frontend/rag/standard-scenarios.v1.json for generate-time standard scenario coverage.",
|
|
4982
|
-
subtask_prompt: "Prepare frontend-test package: materialize standard-scenarios.v1.json into the RAG package.",
|
|
4981
|
+
outputContract: "Write testcase/frontend/rag/standard-scenarios.v1.json for generate-time standard scenario coverage. Copy docs/templates or harness.json governanceRoot templates (including ai_workspace/loop-agent/templates) when present; otherwise write the minimal STD-FE-SMOKE-ENTRY fallback.",
|
|
4982
|
+
subtask_prompt: "Prepare frontend-test package: materialize standard-scenarios.v1.json into the RAG package from docs/templates, governanceRoot/templates, or the init-projected ai_workspace/loop-agent/templates path.",
|
|
4983
4983
|
shell: {
|
|
4984
|
-
commands: [
|
|
4985
|
-
|
|
4986
|
-
"node -e",
|
|
4987
|
-
JSON.stringify("const fs=require('fs'),path=require('path');const dest='testcase/frontend/rag/standard-scenarios.v1.json';const candidates=[path.join('docs','templates','frontend-test-standard-scenarios.v1.json')];let src=null;for(const c of candidates){if(fs.existsSync(c)){src=c;break;}}fs.mkdirSync(path.dirname(dest),{recursive:true});if(src){fs.copyFileSync(src,dest);process.stdout.write(JSON.stringify({status:'copied',from:src,to:dest}));}else{const minimal={schemaVersion:1,id:'frontend-test-standard-scenarios-v1',scenarios:[{id:'STD-FE-SMOKE-ENTRY',title:'入口可打开',category:'smoke',priority:'must',testPoints:['open'],minCases:1}]};fs.writeFileSync(dest,JSON.stringify(minimal,null,2)+'\n');process.stdout.write(JSON.stringify({status:'fallback',to:dest}));}"),
|
|
4988
|
-
].join(" "),
|
|
4989
|
-
],
|
|
4984
|
+
commands: [],
|
|
4985
|
+
frontendTestStandardScenarios: {},
|
|
4990
4986
|
cwd: ".",
|
|
4991
4987
|
timeoutMs: 60_000,
|
|
4992
4988
|
},
|
|
@@ -5022,15 +5018,11 @@ function buildFrontendTestHybridDag(sources) {
|
|
|
5022
5018
|
writeSet: ragWriteSet,
|
|
5023
5019
|
allowedPaths: [...ragWriteSet],
|
|
5024
5020
|
forbiddenPaths: forbidden,
|
|
5025
|
-
outputContract: "Fail-closed environment preflight: absolute non-production baseUrl + curl HTTP reachability; writes environmentProbe facts; unreachable => blockedReason frontend-base-url-unreachable (
|
|
5026
|
-
subtask_prompt: "Parse the resolved absolute baseUrl from testcase/frontend/rag/context.md, reject production/non-http(s)/credential/query/fragment URLs, then probe it with curl HEAD and GET fallback (connect/max-time; no auth/cookie). 2xx/3xx => reachable and continue. 4xx/5xx/DNS/timeout/
|
|
5021
|
+
outputContract: "Fail-closed environment preflight: absolute non-production baseUrl + curl HTTP reachability; writes environmentProbe facts; unreachable => blockedReason frontend-base-url-unreachable with errorClass (connection-refused / dns-unresolved / connect-timeout / http-N / curl-exit-N). Node ERROR so generate/map do not run. Does not start the app.",
|
|
5022
|
+
subtask_prompt: "Parse the resolved absolute baseUrl from testcase/frontend/rag/context.md, reject production/non-http(s)/credential/query/fragment URLs, then probe it with curl HEAD and GET fallback (connect/max-time; no auth/cookie). 2xx/3xx => reachable and continue. Connection refused (curl 7) records errorClass=connection-refused and tells the operator to start the local app (scripts/serve.sh or npm start) then rerun from this node. 4xx/5xx/DNS/timeout/TLS => blockedReason frontend-base-url-unreachable with a distinct errorClass. Missing curl => blockedReason curl-unavailable. Do not start the app. Fixture/reset remain soft guidance.",
|
|
5027
5023
|
shell: {
|
|
5028
|
-
commands: [
|
|
5029
|
-
|
|
5030
|
-
"node -e",
|
|
5031
|
-
JSON.stringify(`const fs=require('fs');const {spawnSync}=require('child_process');const p='testcase/frontend/rag/context.md';const probePath='testcase/frontend/rag/environment-probe.json';function writeProbe(obj){try{fs.mkdirSync('testcase/frontend/rag',{recursive:true});fs.writeFileSync(probePath,JSON.stringify(obj,null,2)+'\\n');let ctx=fs.existsSync(p)?fs.readFileSync(p,'utf8'):'';const line='environmentProbe: '+obj.status+(obj.blockedReason?(' ('+obj.blockedReason+')'):'');if(/environmentProbe\\s*[:=]/i.test(ctx)){ctx=ctx.replace(/environmentProbe\\s*[:=]\\s*.*/i,line);}else{ctx=ctx.trimEnd()+'\\n\\n'+line+'\\n';}fs.writeFileSync(p,ctx);}catch(e){console.error('probe-write-failed',e&&e.message||e);}}function redactUrl(u){try{const x=new URL(u);x.username='';x.password='';if(x.search){x.search='';}return x.toString();}catch(_){return String(u).replace(/\\/\\/[^@\\s]+@/g,'//');}}function failBlocked(reason,extra){const payload=Object.assign({status:'unreachable',blockedReason:reason,baseUrlRedacted:extra&&extra.baseUrlRedacted||null,httpStatus:extra&&extra.httpStatus||null,method:extra&&extra.method||null,curlExit:extra&&extra.curlExit||null,errorClass:extra&&extra.errorClass||null},extra||{});writeProbe(payload);console.error('frontend-test preflight blocked: '+JSON.stringify({blockedReason:reason,baseUrl:payload.baseUrlRedacted,httpStatus:payload.httpStatus,errorClass:payload.errorClass}));throw new Error('frontend-test preflight blocked: '+reason);}if(!fs.existsSync(p))throw new Error('missing '+p);const s=fs.readFileSync(p,'utf8');const patterns=[/baseUrl\\s*[:=]\\s*["'\\x60]?((?:https?):\\/\\/[^\\s"'\\x60<>]+)/i,/base[-_ ]url\\s*[:=]\\s*["'\\x60]?((?:https?):\\/\\/[^\\s"'\\x60<>]+)/i,/playwright-cli open --browser=chrome\\s+((?:https?):\\/\\/[^\\s"'\\x60<>]+)/i,/(https?:\\/\\/(?:localhost|127\\.0\\.0\\.1)[^\\s)\\}\\],"']*)/i];let baseUrl=null;for(const re of patterns){const m=s.match(re);if(m){baseUrl=m[1];break;}}if(!baseUrl)throw new Error('frontend-test preflight missing absolute baseUrl from context.md');baseUrl=baseUrl.replace(/[)\\}\\],."'\\x60]+$/,'');const sourceMatch=s.match(/baseUrlSource\\s*[:=]\\s*([^\\r\\n]+)/i);const baseUrlSource=sourceMatch?sourceMatch[1].trim():'context.md';let parsed;try{parsed=new URL(baseUrl);}catch(_){throw new Error('baseUrl must be absolute http(s): '+baseUrl);}if((parsed.protocol!=='http:'&&parsed.protocol!=='https:')||parsed.username||parsed.password||parsed.search||parsed.hash)throw new Error('unsafe baseUrl from context.md: '+redactUrl(baseUrl));if(/(?:^|\\.)(?:www\\.)?[^.]*(?:prod|production)/i.test(parsed.hostname))throw new Error('production URL forbidden: '+redactUrl(baseUrl));baseUrl=parsed.toString();const safe=redactUrl(baseUrl);const curlCheck=spawnSync('curl',['--version'],{encoding:'utf8'});if(curlCheck.error||curlCheck.status!==0){failBlocked('curl-unavailable',{baseUrlRedacted:safe,errorClass:'curl-missing'});}function probe(method){const args=['-sS','-o','/dev/null','-w','%{http_code}','--connect-timeout','3','--max-time','8','-X',method,'-L','--max-redirs','3','--http1.1','--proto-redir','=http,https',safe];const r=spawnSync('curl',args,{encoding:'utf8'});return r;}let used='HEAD';let r=probe('HEAD');let code=String(r.stdout||'').trim();let statusNum=parseInt(code,10);const headRejected=r.status!==0||!statusNum||statusNum===405||statusNum===501;if(headRejected){used='GET';r=probe('GET');code=String(r.stdout||'').trim();statusNum=parseInt(code,10);}const ok=statusNum>=200&&statusNum<400;if(!ok){const errClass=r.error?'spawn-error':(r.status!==0?'curl-exit-'+r.status:('http-'+statusNum));failBlocked('frontend-base-url-unreachable',{baseUrlRedacted:safe,httpStatus:statusNum||null,method:used,curlExit:r.status,errorClass:errClass});}writeProbe({status:'reachable',blockedReason:null,baseUrl:safe,baseUrlRedacted:safe,baseUrlSource,httpStatus:statusNum,method:used,curlExit:r.status});console.log('frontend-test-execution-v1 validated contextBaseUrl='+safe+' source='+baseUrlSource+' probe=reachable method='+used+' httpStatus='+statusNum);`),
|
|
5032
|
-
].join(" "),
|
|
5033
|
-
],
|
|
5024
|
+
commands: [],
|
|
5025
|
+
frontendTestEnvironmentProbe: {},
|
|
5034
5026
|
cwd: ".",
|
|
5035
5027
|
timeoutMs: 60000,
|
|
5036
5028
|
},
|
|
@@ -306,6 +306,9 @@ export function computeResetClosure(spec, effectiveFromNodeId) {
|
|
|
306
306
|
...collectReachableDescendants(spec, effectiveFromNodeId),
|
|
307
307
|
].sort();
|
|
308
308
|
}
|
|
309
|
+
function isNeverStartedNodeStatus(status) {
|
|
310
|
+
return status === undefined || status === "SKIPPED" || status === "PENDING";
|
|
311
|
+
}
|
|
309
312
|
export function assessResetClosureSafety(spec, resetNodeIds, options = {}) {
|
|
310
313
|
const tasks = taskById(spec);
|
|
311
314
|
const blockingNodes = [];
|
|
@@ -314,9 +317,17 @@ export function assessResetClosureSafety(spec, resetNodeIds, options = {}) {
|
|
|
314
317
|
const task = tasks.get(nodeId);
|
|
315
318
|
if (!task)
|
|
316
319
|
continue;
|
|
320
|
+
if (options.parentNodes !== undefined &&
|
|
321
|
+
isNeverStartedNodeStatus(options.parentNodes[nodeId]?.status)) {
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
317
324
|
if (isWriterTask(task, spec)) {
|
|
318
325
|
if (nodeId === options.allowedTransportWriterNodeId)
|
|
319
326
|
continue;
|
|
327
|
+
if (nodeId === options.allowedExclusiveShellFromNodeId &&
|
|
328
|
+
task.executor === "shell") {
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
320
331
|
blockingNodes.push({
|
|
321
332
|
nodeId,
|
|
322
333
|
reasonCode: "restart-subgraph-contains-writer",
|
|
@@ -594,9 +605,17 @@ export async function evaluateDagRerunPlan(input) {
|
|
|
594
605
|
blockedReasons.push("ambiguous-skipped-rewrite");
|
|
595
606
|
}
|
|
596
607
|
}
|
|
597
|
-
const closureSafety = assessResetClosureSafety(input.parentSpec, resetNodeIds,
|
|
598
|
-
|
|
599
|
-
|
|
608
|
+
const closureSafety = assessResetClosureSafety(input.parentSpec, resetNodeIds, {
|
|
609
|
+
...(transportWriterRestart && effectiveFromNodeId
|
|
610
|
+
? { allowedTransportWriterNodeId: effectiveFromNodeId }
|
|
611
|
+
: {}),
|
|
612
|
+
parentNodes: input.parentState.nodes,
|
|
613
|
+
...(effectiveFromNodeId &&
|
|
614
|
+
effectiveTask?.executor === "shell" &&
|
|
615
|
+
effectiveRecord?.status === "ERROR"
|
|
616
|
+
? { allowedExclusiveShellFromNodeId: effectiveFromNodeId }
|
|
617
|
+
: {}),
|
|
618
|
+
});
|
|
600
619
|
blockingNodes.push(...closureSafety.blockingNodes);
|
|
601
620
|
for (const code of closureSafety.reasonCodes) {
|
|
602
621
|
reasonCodes.push(code);
|
|
@@ -427,6 +427,8 @@ export const dagBackendTestPipelineSchema = z.enum([
|
|
|
427
427
|
"ingest-backend-test-gap",
|
|
428
428
|
]);
|
|
429
429
|
export const dagFrontendBrowserToolPreflightSchema = z.object({}).strict();
|
|
430
|
+
export const dagFrontendTestStandardScenariosSchema = z.object({}).strict();
|
|
431
|
+
export const dagFrontendTestEnvironmentProbeSchema = z.object({}).strict();
|
|
430
432
|
export const dagFrontendTestEvidenceValidationSchema = z.object({}).strict();
|
|
431
433
|
export const dagFrontendTestCaseChecklistSchema = z.object({}).strict();
|
|
432
434
|
export const dagFrontendTestHtmlReportSchema = z.object({}).strict();
|
|
@@ -517,6 +519,8 @@ export const dagShellConfigSchema = z.object({
|
|
|
517
519
|
frontendReviewContext: dagFrontendReviewContextSchema.optional(),
|
|
518
520
|
frontendTestCaseChecklist: dagFrontendTestCaseChecklistSchema.optional(),
|
|
519
521
|
frontendBrowserToolPreflight: dagFrontendBrowserToolPreflightSchema.optional(),
|
|
522
|
+
frontendTestStandardScenarios: dagFrontendTestStandardScenariosSchema.optional(),
|
|
523
|
+
frontendTestEnvironmentProbe: dagFrontendTestEnvironmentProbeSchema.optional(),
|
|
520
524
|
frontendTestEvidenceValidation: dagFrontendTestEvidenceValidationSchema.optional(),
|
|
521
525
|
finalWriteSetApprovalGate: dagFinalWriteSetApprovalGateSchema.optional(),
|
|
522
526
|
frontendTestL5Report: z.object({}).strict().optional(),
|
|
@@ -465,6 +465,8 @@ function validateShellTaskConfig(task, spec, issues) {
|
|
|
465
465
|
!shell.backendTestPipeline &&
|
|
466
466
|
!shell.frontendPrewriteGate &&
|
|
467
467
|
!shell.frontendBrowserToolPreflight &&
|
|
468
|
+
!shell.frontendTestStandardScenarios &&
|
|
469
|
+
!shell.frontendTestEnvironmentProbe &&
|
|
468
470
|
!shell.frontendVerificationBundle &&
|
|
469
471
|
!shell.frontendReviewContext &&
|
|
470
472
|
!shell.frontendTestCaseChecklist &&
|
|
@@ -356,6 +356,31 @@
|
|
|
356
356
|
"properties": { "schemaVersion": { "const": 1 }, "requireBaseline": { "const": true } }
|
|
357
357
|
},
|
|
358
358
|
"frontendTestCaseChecklist": { "type": "object", "additionalProperties": false },
|
|
359
|
+
"frontendBrowserToolPreflight": { "type": "object", "additionalProperties": false },
|
|
360
|
+
"frontendTestStandardScenarios": { "type": "object", "additionalProperties": false },
|
|
361
|
+
"frontendTestEnvironmentProbe": { "type": "object", "additionalProperties": false },
|
|
362
|
+
"frontendTestCaseManifest": {
|
|
363
|
+
"type": "object",
|
|
364
|
+
"additionalProperties": false,
|
|
365
|
+
"properties": {
|
|
366
|
+
"maxCases": { "type": "integer", "minimum": 1 },
|
|
367
|
+
"declaredAcIds": { "type": "array", "items": { "type": "string" } }
|
|
368
|
+
}
|
|
369
|
+
},
|
|
370
|
+
"frontendTestResultFinalize": {
|
|
371
|
+
"type": "object",
|
|
372
|
+
"additionalProperties": false,
|
|
373
|
+
"properties": {
|
|
374
|
+
"declaredAcIds": { "type": "array", "items": { "type": "string" } }
|
|
375
|
+
}
|
|
376
|
+
},
|
|
377
|
+
"frontendTestReports": {
|
|
378
|
+
"type": "object",
|
|
379
|
+
"additionalProperties": false,
|
|
380
|
+
"properties": {
|
|
381
|
+
"l5": { "type": "boolean" }
|
|
382
|
+
}
|
|
383
|
+
},
|
|
359
384
|
"frontendTestEvidenceValidation": { "type": "object", "additionalProperties": false },
|
|
360
385
|
"frontendTestHtmlReport": { "type": "object", "additionalProperties": false },
|
|
361
386
|
"backendTestPipeline": {
|
|
@@ -383,6 +408,12 @@
|
|
|
383
408
|
{ "required": ["frontendVerificationBundle"] },
|
|
384
409
|
{ "required": ["frontendReviewContext"] },
|
|
385
410
|
{ "required": ["frontendTestCaseChecklist"] },
|
|
411
|
+
{ "required": ["frontendBrowserToolPreflight"] },
|
|
412
|
+
{ "required": ["frontendTestStandardScenarios"] },
|
|
413
|
+
{ "required": ["frontendTestEnvironmentProbe"] },
|
|
414
|
+
{ "required": ["frontendTestCaseManifest"] },
|
|
415
|
+
{ "required": ["frontendTestResultFinalize"] },
|
|
416
|
+
{ "required": ["frontendTestReports"] },
|
|
386
417
|
{ "required": ["frontendTestEvidenceValidation"] },
|
|
387
418
|
{ "required": ["frontendTestHtmlReport"] },
|
|
388
419
|
{ "required": ["backendTestPipeline"] }
|
|
@@ -66,12 +66,11 @@
|
|
|
66
66
|
".harness/**",
|
|
67
67
|
"artifacts/**"
|
|
68
68
|
],
|
|
69
|
-
"outputContract": "Write testcase/frontend/rag/standard-scenarios.v1.json for generate-time standard scenario coverage.",
|
|
70
|
-
"subtask_prompt": "
|
|
69
|
+
"outputContract": "Write testcase/frontend/rag/standard-scenarios.v1.json for generate-time standard scenario coverage. Copy docs/templates or harness.json governanceRoot templates (including ai_workspace/loop-agent/templates) when present; otherwise write the minimal STD-FE-SMOKE-ENTRY fallback.",
|
|
70
|
+
"subtask_prompt": "Prepare frontend-test package: materialize standard-scenarios.v1.json into the RAG package from docs/templates, governanceRoot/templates, or the init-projected ai_workspace/loop-agent/templates path.",
|
|
71
71
|
"shell": {
|
|
72
|
-
"commands": [
|
|
73
|
-
|
|
74
|
-
],
|
|
72
|
+
"commands": [],
|
|
73
|
+
"frontendTestStandardScenarios": {},
|
|
75
74
|
"cwd": ".",
|
|
76
75
|
"timeoutMs": 60000
|
|
77
76
|
}
|
|
@@ -120,12 +119,11 @@
|
|
|
120
119
|
".harness/**",
|
|
121
120
|
"artifacts/**"
|
|
122
121
|
],
|
|
123
|
-
"outputContract": "Fail-closed environment preflight: absolute non-production baseUrl + curl HTTP reachability; writes environmentProbe facts; unreachable => blockedReason frontend-base-url-unreachable (
|
|
124
|
-
"subtask_prompt": "Parse the concrete baseUrl selected by retrieve-frontend-test-context-pi from testcase/frontend/rag/context.md. Reject production, non-http(s), credentials, query and fragment. Probe with curl (HEAD then GET fallback; connect/max-time; no auth/cookie). 2xx/3xx => reachable and continue. 4xx/5xx/DNS/timeout/
|
|
122
|
+
"outputContract": "Fail-closed environment preflight: absolute non-production baseUrl + curl HTTP reachability; writes environmentProbe facts; unreachable => blockedReason frontend-base-url-unreachable with errorClass (connection-refused / dns-unresolved / connect-timeout / http-N). Node ERROR so generate/map do not run. Does not start the app.",
|
|
123
|
+
"subtask_prompt": "Parse the concrete baseUrl selected by retrieve-frontend-test-context-pi from testcase/frontend/rag/context.md. Reject production, non-http(s), credentials, query and fragment. Probe with curl (HEAD then GET fallback; connect/max-time; no auth/cookie). 2xx/3xx => reachable and continue. Connection refused records errorClass=connection-refused and tells the operator to start the local app then rerun from this node. 4xx/5xx/DNS/timeout/TLS => blockedReason frontend-base-url-unreachable. Missing curl => blockedReason curl-unavailable. Do not start the app.",
|
|
125
124
|
"shell": {
|
|
126
|
-
"commands": [
|
|
127
|
-
|
|
128
|
-
],
|
|
125
|
+
"commands": [],
|
|
126
|
+
"frontendTestEnvironmentProbe": {},
|
|
129
127
|
"cwd": ".",
|
|
130
128
|
"timeoutMs": 60000
|
|
131
129
|
}
|
|
@@ -17,5 +17,5 @@ Do not claim the environment is reachable until preflight completes. Preflight d
|
|
|
17
17
|
|
|
18
18
|
## Standard scenario coverage
|
|
19
19
|
|
|
20
|
-
- Copy or reference `docs/templates/frontend-test-standard-scenarios.v1.json` into `testcase/frontend/rag/standard-scenarios.v1.json` when available.
|
|
20
|
+
- Copy or reference `docs/templates/frontend-test-standard-scenarios.v1.json`, `harness.json` `governanceRoot`/templates, or the init-projected `ai_workspace/loop-agent/templates/frontend-test-standard-scenarios.v1.json` into `testcase/frontend/rag/standard-scenarios.v1.json` when available.
|
|
21
21
|
- Add `## Standard scenario coverage` to coverage-map.md with planned/n/a for each must scenario.
|
package/package.json
CHANGED