@tea-agent/loop-agent 0.18.1 → 0.20.0
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/AGENTS.md +2 -2
- package/CHANGELOG.md +33 -0
- package/README.md +4 -6
- package/dist/application/dag/generate-task-dag.js +12 -1
- package/dist/commands/init.js +3 -3
- package/dist/executors/shell-executor.js +188 -2
- package/dist/governance/exec-plans.js +4 -0
- package/dist/worker/cli.js +13 -12
- package/dist/worker/console/doctor.js +55 -2
- package/dist/worker/console/index.js +1 -0
- package/dist/worker/console/loopback.js +2 -2
- package/dist/worker/console/observe-link.js +7 -2
- package/dist/worker/console/operator-actions.js +30 -2
- package/dist/worker/console/operator-selection.js +92 -0
- package/dist/worker/console/operator-surface-health.js +23 -0
- package/dist/worker/console/recovery-cta.js +2 -2
- package/dist/worker/console/routes.js +45 -19
- package/dist/worker/console/security.js +29 -4
- package/dist/worker/console/server.js +106 -8
- package/dist/worker/console/static/assets/index-3vsjZJHq.js +16 -0
- package/dist/worker/console/static/assets/index-i1wV4LrY.css +1 -0
- package/dist/worker/console/static/index.html +2 -2
- package/dist/worker/observe/routes.js +63 -21
- package/dist/worker/observe/server.js +3 -10
- package/dist/worker/observe/static/index.html +6 -3
- package/dist/worker/observe/static/styles.css +53 -0
- package/dist/workflows/dag/backend-test-markdown-workflow.js +163 -41
- package/dist/workflows/dag/backend-test-result-contract.js +30 -7
- package/dist/workflows/dag/frontend-prewrite-gate.js +77 -0
- package/dist/workflows/dag/frontend-repair.js +7 -1
- package/dist/workflows/dag/frontend-review-context.js +43 -0
- package/dist/workflows/dag/frontend-verification-trace.js +34 -15
- package/dist/workflows/dag/governance-profile.js +14 -6
- package/dist/workflows/dag/init-hybrid.js +143 -399
- package/dist/workflows/dag/types.js +30 -0
- package/dist/workflows/dag/validate.js +22 -1
- package/docs/README.md +4 -2
- package/docs/architecture/evolution.md +6 -6
- package/docs/architecture/worker-and-feature.md +9 -9
- package/docs/templates/agent-dag.schema.json +40 -0
- package/docs/templates/backend-test-dag.generate-pytest.prompt.md +23 -192
- package/docs/templates/backend-test-dag.json +8 -8
- package/docs/templates/backend-test-dag.review-cases.prompt.md +22 -75
- package/package.json +1 -1
- package/skills/agent-worker/references/agent-worker-operator.md +2 -2
- package/skills/frontend-implementation/references/node-contracts.md +6 -8
- package/skills/frontend-review/SKILL.md +5 -8
- package/skills/loop-agent/references/command-reference.md +4 -2
- package/skills/loop-agent/references/harness-policy.md +1 -1
- package/skills/loop-agent/references/hybrid-dag.md +2 -2
- package/dist/worker/console/static/assets/index-KUSib7aM.js +0 -16
- package/dist/worker/console/static/assets/index-ucIzpaGJ.css +0 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
:root{--canvas: #f7f7f4;--canvas-soft: #fafaf7;--surface: #ffffff;--surface-muted: #f0efe9;--ink: #26251e;--body: #5a5852;--muted: #807d72;--muted-soft: #a09c92;--hairline: #e6e5e0;--hairline-strong: #cfcdc4;--orange: #f54e00;--orange-active: #d04200;--orange-soft: color-mix(in srgb, var(--orange) 12%, white);--blue: #3f6ea4;--green: #1f8a65;--amber: #a66a00;--red: #c72d53;--peach: #f1d8cb;--mint: #d8ead5;--space-1: 4px;--space-2: 8px;--space-3: 12px;--space-4: 16px;--space-5: 20px;--space-6: 24px;--space-8: 32px;--radius-sm: 6px;--radius-md: 8px;--radius-lg: 12px;--radius-pill: 999px;--transition-fast: .16s ease;--transition-base: .22s ease;--shadow-panel: 0 1px 2px rgb(38 37 30 / 4%), 0 8px 24px rgb(38 37 30 / 3%);--shadow-soft: 0 10px 30px rgb(38 37 30 / 6%);color-scheme:light;font-family:Segoe UI,PingFang SC,Hiragino Sans GB,Noto Sans SC,system-ui,-apple-system,sans-serif;line-height:1.5}*{box-sizing:border-box}html{background:radial-gradient(1200px 480px at 8% -10%,rgb(241 216 203 / 45%),transparent 55%),radial-gradient(900px 420px at 100% 0%,rgb(216 234 213 / 35%),transparent 50%),var(--canvas);min-height:100%}body{margin:0;min-height:100vh;min-width:320px;background:transparent;color:var(--ink);font-size:14px}button,input,select,textarea{font:inherit}a{color:inherit}code,.cmd,.kv dd code{font-family:JetBrains Mono,SFMono-Regular,Consolas,monospace}.embedded-operator-nav{position:sticky;top:0;z-index:40;display:flex;gap:6px;align-items:center;margin:0;padding:8px max(24px,calc((100vw - 1120px)/2));border-bottom:1px solid var(--hairline);background:color-mix(in srgb,var(--canvas) 92%,transparent);-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);font-size:13px;font-weight:700}.embedded-operator-nav a,.embedded-operator-nav span{padding:6px 12px;border-radius:var(--radius-pill);text-decoration:none;color:var(--ink)}.embedded-operator-nav a:hover,.embedded-operator-nav a:focus-visible{background:var(--surface);color:var(--orange-active);outline:none}.embedded-operator-nav span[aria-current=page]{background:var(--ink);color:#fff}.console-shell{width:min(100% - 48px,1120px);margin:0 auto;padding:var(--space-5) 0 var(--space-12, 48px)}.console-header{display:grid;gap:var(--space-4);grid-template-columns:1fr auto;align-items:center;margin-bottom:var(--space-4);padding-bottom:var(--space-4);border-bottom:1px solid var(--hairline)}.brand-block{display:grid;gap:2px;align-content:start;min-width:0}h1{margin:0;color:var(--ink);font-size:clamp(20px,2.2vw,24px);font-weight:650;line-height:1.2;letter-spacing:-.02em}.subtitle{max-width:58ch;margin:0;color:var(--body);font-size:13px;line-height:1.4}.top-nav{display:flex;flex-wrap:wrap;align-items:center;gap:var(--space-2);margin:0 0 var(--space-5);padding:6px;border:1px solid var(--hairline);border-radius:var(--radius-pill);background:color-mix(in srgb,var(--surface) 88%,transparent);-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px);box-shadow:var(--shadow-panel)}.nav-link{appearance:none;display:inline-flex;align-items:center;min-height:34px;padding:0 14px;border:1px solid transparent;border-radius:var(--radius-pill);background:transparent;color:var(--body);font-size:13px;font-weight:600;cursor:pointer;transition:background-color var(--transition-fast),color var(--transition-fast),border-color var(--transition-fast),transform var(--transition-fast)}.nav-link:hover:not(:disabled),.nav-link:focus-visible:not(:disabled){background:var(--surface);color:var(--ink);outline:none}.nav-link-active{background:var(--ink);color:#fff}.nav-link-active:hover:not(:disabled),.nav-link-active:focus-visible:not(:disabled){background:var(--ink);color:#fff}.nav-link-external{margin-left:auto;border-color:var(--hairline);background:var(--surface);color:var(--orange-active)}.nav-link-external:hover:not(:disabled){border-color:var(--orange);background:var(--orange-soft);color:var(--orange-active)}.readiness{position:relative;display:grid;gap:2px;min-width:140px;max-width:280px;border:1px solid var(--hairline);background:var(--surface);border-radius:var(--radius-md);padding:8px 32px 8px 12px;box-shadow:var(--shadow-panel)}.readiness:before{content:"";position:absolute;top:50%;right:12px;width:8px;height:8px;border-radius:50%;transform:translateY(-50%);background:var(--muted-soft)}.readiness[data-state=setup-required]{border-color:var(--hairline);background:var(--surface)}.readiness[data-state=setup-required]:before{background:var(--amber)}.readiness[data-state=ready]{border-color:var(--hairline);background:var(--surface)}.readiness[data-state=ready]:before{background:color-mix(in srgb,var(--green) 72%,white);box-shadow:0 0 0 3px color-mix(in srgb,var(--green) 12%,transparent)}.readiness[data-state=degraded]{border-color:var(--hairline);background:var(--surface)}.readiness[data-state=degraded]:before{background:var(--amber);box-shadow:0 0 0 3px color-mix(in srgb,var(--amber) 12%,transparent)}.readiness-label{display:block;margin:0;color:var(--muted);font-size:11px;font-weight:600;line-height:1.2}.readiness strong{color:var(--ink);font-size:14px;font-weight:700;line-height:1.2}.readiness[data-state=ready] strong,.readiness[data-state=setup-required] strong,.readiness[data-state=degraded] strong{color:var(--ink)}.readiness .muted{margin:2px 0 0;font-size:12px;line-height:1.35}.flash{display:flex;gap:var(--space-3);align-items:flex-start;margin:0 0 var(--space-4);padding:12px 14px;border:1px solid var(--hairline);border-radius:var(--radius-md);background:var(--surface);box-shadow:var(--shadow-panel);animation:flash-in .22s ease}.flash-error{border-color:color-mix(in srgb,var(--red) 35%,var(--hairline));background:color-mix(in srgb,var(--red) 6%,var(--surface));color:var(--red)}.flash-info{border-color:color-mix(in srgb,var(--blue) 30%,var(--hairline));background:color-mix(in srgb,var(--blue) 6%,var(--surface));color:var(--body)}.flash-label{flex:0 0 auto;min-width:3.5rem;font-size:12px;font-weight:700;letter-spacing:.02em}.flash-body{margin:0;overflow-wrap:anywhere}.muted{color:var(--muted)}.console-main{display:grid;gap:var(--space-4)}.inspect-split-panel{position:fixed;z-index:40;top:16px;right:16px;bottom:16px;width:min(420px,calc(100vw - 32px));display:grid;align-content:start;gap:var(--space-4);padding:var(--space-5);overflow:auto;border:1px solid var(--hairline);border-radius:var(--radius-lg);background:var(--surface);box-shadow:var(--shadow-soft)}.inspect-split-header{display:flex;align-items:start;justify-content:space-between;gap:var(--space-3)}.inspect-split-header h2,.inspect-split-panel h3{margin:0}.inspect-split-kicker,.inspect-split-id{margin:0;color:var(--muted);font-size:12px}.inspect-split-facts{display:grid;gap:var(--space-2);margin:0}.inspect-split-facts div{display:grid;grid-template-columns:7rem 1fr;gap:var(--space-2);padding-bottom:var(--space-2);border-bottom:1px solid var(--hairline)}.inspect-split-facts dt{color:var(--muted)}.inspect-split-facts dd{margin:0;overflow-wrap:anywhere}.inspect-split-events{display:grid;gap:8px;margin:0;padding-left:18px;color:var(--body);font-size:12px}.panel{min-width:0;border:1px solid var(--hairline);background:color-mix(in srgb,var(--surface) 96%,transparent);border-radius:var(--radius-lg);padding:var(--space-6);box-shadow:var(--shadow-panel)}.panel h2{margin:0 0 var(--space-2);color:var(--ink);font-size:18px;font-weight:700;letter-spacing:-.01em}.panel-lead{margin:0 0 var(--space-5);color:var(--muted);font-size:13px}.panel h3{margin:0 0 var(--space-3);color:var(--ink);font-size:15px;font-weight:700}.empty-state{border:1px dashed var(--hairline-strong);border-radius:var(--radius-md);padding:var(--space-6);background:var(--canvas-soft);color:var(--body)}.empty-title{margin:0 0 var(--space-2);color:var(--ink);font-size:16px;font-weight:700}.checklist{margin:0 0 var(--space-4);padding:var(--space-4);border:1px solid var(--hairline);border-radius:var(--radius-md);background:var(--canvas-soft);list-style:none;color:var(--body)}.checklist li{position:relative;padding:6px 0 6px 22px}.checklist li:before{content:"";position:absolute;left:0;top:12px;width:8px;height:8px;border-radius:50%;background:var(--orange);opacity:.75}.kv{display:grid;gap:var(--space-1);margin:0}.kv div{display:grid;grid-template-columns:9rem 1fr;gap:var(--space-3);padding:10px 0;border-bottom:1px solid var(--hairline)}.kv div:last-child{border-bottom:none}.kv dt{color:var(--muted);font-size:12px;font-weight:600}.kv dd{margin:0;color:var(--ink);overflow-wrap:anywhere}code{font-size:.85rem;color:var(--ink)}.cmd{display:block;width:100%;margin:0;padding:var(--space-4);border:1px solid var(--hairline);border-radius:var(--radius-md);background:#f3efe8;color:var(--ink);font-size:12px;line-height:1.55;overflow-x:auto;white-space:pre-wrap;word-break:break-word;max-height:280px}.external-link{color:var(--orange);text-decoration:none;font-weight:600}.external-link:hover{text-decoration:underline}.error{color:var(--red)}.wizard-steps{display:grid;grid-template-columns:repeat(auto-fit,minmax(108px,1fr));gap:var(--space-2);list-style:none;padding:0;margin:0 0 var(--space-5)}.wizard-steps li{position:relative;min-height:52px;display:grid;align-content:center;gap:2px;border:1px solid var(--hairline);border-radius:var(--radius-md);padding:8px 10px;background:var(--canvas-soft);color:var(--muted);font-size:12px;font-weight:650;transition:border-color var(--transition-fast),background-color var(--transition-fast),color var(--transition-fast),box-shadow var(--transition-fast)}.wizard-steps li[data-done=true]{border-color:color-mix(in srgb,var(--green) 35%,var(--hairline));background:color-mix(in srgb,var(--mint) 45%,var(--surface));color:var(--green)}.wizard-steps li[data-active=true]{border-color:var(--orange);background:var(--surface);color:var(--ink);box-shadow:0 0 0 3px var(--orange-soft)}.wizard-steps .step-index{font-size:10px;font-weight:700;letter-spacing:.04em;color:var(--muted-soft)}.wizard-steps li[data-active=true] .step-index,.wizard-steps li[data-done=true] .step-index{color:inherit;opacity:.8}.wizard-steps .step-label{line-height:1.25}.field{display:grid;gap:var(--space-1);margin:0 0 var(--space-4);width:min(100%,520px)}.field span{font-size:12px;font-weight:600;color:var(--muted)}.field input,.field textarea,.field select{width:100%;border:1px solid var(--hairline-strong);border-radius:var(--radius-md);background:var(--surface);color:var(--ink);transition:border-color var(--transition-fast),box-shadow var(--transition-fast)}.field input{min-height:40px;padding:0 var(--space-3)}.field select{min-height:40px;padding:0 var(--space-3);cursor:pointer}.field textarea{min-height:120px;padding:var(--space-3);resize:vertical}.field input:focus-visible,.field textarea:focus-visible,.field select:focus-visible{outline:none;border-color:var(--orange);box-shadow:0 0 0 3px var(--orange-soft)}.field input:disabled,.field textarea:disabled,.field select:disabled{background:var(--surface-muted);color:var(--muted);cursor:not-allowed}.workflow-hint{margin:calc(var(--space-1) * -1) 0 var(--space-3);color:var(--muted);font-size:12px}.meta-row{display:grid;gap:6px;margin:0 0 var(--space-4);padding:var(--space-3) var(--space-4);border:1px solid var(--hairline);border-radius:var(--radius-md);background:var(--canvas-soft);font-size:13px}.meta-row code{font-size:12px}.cta-row,.recovery-cta-row{display:flex;flex-wrap:wrap;gap:var(--space-2);align-items:center}.cta-row{margin-top:var(--space-3)}.cta-row .cmd{flex:1 1 100%}.wizard-body{display:grid;gap:var(--space-3);justify-items:stretch;animation:panel-in .24s ease}.wizard-body>div{display:grid;gap:var(--space-3);justify-items:stretch}.wizard-body .field{width:100%;max-width:640px}.wizard-body .cta-row{margin-top:var(--space-1)}.interview-card{width:100%;padding:var(--space-5);border:1px solid var(--hairline);border-radius:var(--radius-lg);background:linear-gradient(180deg,var(--surface),var(--canvas-soft));box-shadow:var(--shadow-panel)}.interview-q{margin:0 0 var(--space-2);color:var(--ink)}.btn,button{appearance:none;display:inline-flex;align-items:center;justify-content:center;gap:6px;min-height:40px;padding:0 16px;border:1px solid var(--hairline-strong);border-radius:var(--radius-md);background:var(--surface);color:var(--ink);font-size:13px;font-weight:650;cursor:pointer;transition:border-color var(--transition-fast),background-color var(--transition-fast),color var(--transition-fast),box-shadow var(--transition-fast),transform var(--transition-fast)}.btn:hover:not(:disabled),button:hover:not(:disabled){border-color:color-mix(in srgb,var(--ink) 18%,var(--hairline-strong));background:var(--canvas-soft);outline:none}.btn:focus-visible:not(:disabled),button:focus-visible:not(:disabled){outline:none;box-shadow:0 0 0 3px var(--orange-soft)}.btn:active:not(:disabled),button:active:not(:disabled){transform:translateY(1px)}.btn:disabled,button:disabled{opacity:.48;cursor:not-allowed;transform:none}.btn-primary,button.btn-primary{border-color:var(--orange);background:var(--orange);color:#fff;box-shadow:0 6px 16px #f54e002e}.btn-primary:hover:not(:disabled),button.btn-primary:hover:not(:disabled){border-color:var(--orange-active);background:var(--orange-active);color:#fff}.btn-secondary,button.btn-secondary{border-color:var(--hairline-strong);background:var(--surface);color:var(--ink)}.btn-ghost,button.btn-ghost{border-color:transparent;background:transparent;color:var(--body)}.btn-ghost:hover:not(:disabled),button.btn-ghost:hover:not(:disabled){background:var(--surface-muted);color:var(--ink)}.recovery-cta-row{margin-top:var(--space-3)}.recovery-cta-row button[disabled]{opacity:.4}@keyframes flash-in{0%{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:none}}@keyframes panel-in{0%{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}@media(max-width:800px){.console-shell{width:min(100% - 24px,1120px);padding-top:var(--space-5)}.console-header{grid-template-columns:1fr}.nav-link-external{margin-left:0}.inspect-split-panel{inset:auto 0 0;width:100%;max-height:min(76vh,680px);border-radius:var(--radius-lg) var(--radius-lg) 0 0}.kv div{grid-template-columns:1fr}.wizard-steps{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(prefers-reduced-motion:reduce){*,*:before,*:after{animation-duration:.01ms!important;animation-iteration-count:1!important;scroll-behavior:auto!important;transition-duration:.01ms!important}}
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<title>Loop 操作台 · Operator Console</title>
|
|
7
|
-
<script type="module" crossorigin src="/assets/index-
|
|
8
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
7
|
+
<script type="module" crossorigin src="/assets/index-3vsjZJHq.js"></script>
|
|
8
|
+
<link rel="stylesheet" crossorigin href="/assets/index-i1wV4LrY.css">
|
|
9
9
|
</head>
|
|
10
10
|
<body>
|
|
11
11
|
<div id="root"></div>
|
|
@@ -2,6 +2,7 @@ import { existsSync } from "node:fs";
|
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
3
|
import { open as openFile, readFile, stat } from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
5
6
|
import { redactSecrets, truncateUtf8Preview } from "../../shared/preview.js";
|
|
6
7
|
import { parseWorkerEventLine } from "../observability/events.js";
|
|
7
8
|
import { isSafeObservabilityIdentifier } from "../observability/event-store.js";
|
|
@@ -17,7 +18,18 @@ const ARTIFACT_PREVIEW_MAX_BYTES = 64 * 1024;
|
|
|
17
18
|
export function createObserveSnapshotCache() {
|
|
18
19
|
return { expiresAt: 0 };
|
|
19
20
|
}
|
|
20
|
-
|
|
21
|
+
export function defaultObserveStaticDir(fromFileUrl = import.meta.url) {
|
|
22
|
+
return path.join(path.dirname(fileURLToPath(fromFileUrl)), "static");
|
|
23
|
+
}
|
|
24
|
+
export function createObserveRouteContext(repoRoot, options = {}) {
|
|
25
|
+
return {
|
|
26
|
+
repoRoot: path.resolve(repoRoot),
|
|
27
|
+
pollIntervalMs: options.pollIntervalMs ?? 1000,
|
|
28
|
+
staticDir: options.staticDir ?? defaultObserveStaticDir(),
|
|
29
|
+
snapshotCache: options.snapshotCache ?? createObserveSnapshotCache(),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
export const ROUTES = [
|
|
21
33
|
{ method: "GET", pattern: /^\/api\/health$/, handler: handleHealth },
|
|
22
34
|
{ method: "GET", pattern: /^\/api\/snapshot$/, handler: handleSnapshot },
|
|
23
35
|
{ method: "GET", pattern: /^\/api\/batches$/, handler: handleBatches },
|
|
@@ -96,16 +108,9 @@ const ROUTES = [
|
|
|
96
108
|
handler: handleEventStream,
|
|
97
109
|
},
|
|
98
110
|
];
|
|
99
|
-
export
|
|
100
|
-
const method = req.method ?? "GET";
|
|
101
|
-
const url = new URL(req.url ?? "/", "http://localhost");
|
|
102
|
-
const pathname = decodeURIComponent(url.pathname);
|
|
103
|
-
if (method !== "GET") {
|
|
104
|
-
sendJson(res, 405, { error: "Method not allowed" });
|
|
105
|
-
return true;
|
|
106
|
-
}
|
|
111
|
+
export function matchObserveGetRoute(pathname) {
|
|
107
112
|
for (const route of ROUTES) {
|
|
108
|
-
if (route.method !==
|
|
113
|
+
if (route.method !== "GET")
|
|
109
114
|
continue;
|
|
110
115
|
const match = route.pattern.exec(pathname);
|
|
111
116
|
if (!match)
|
|
@@ -115,16 +120,34 @@ export async function handleRequest(req, res, ctx) {
|
|
|
115
120
|
params.id = match[1];
|
|
116
121
|
if (match[2])
|
|
117
122
|
params.sub = match[2];
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
123
|
+
return { params, query: new URLSearchParams(), handler: route.handler };
|
|
124
|
+
}
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
export async function handleMatchedObserveRoute(req, res, match, ctx) {
|
|
128
|
+
try {
|
|
129
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
130
|
+
await match.handler(req, res, { ...match, query: url.searchParams }, ctx);
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
if (!res.headersSent) {
|
|
134
|
+
sendJson(res, 500, {
|
|
135
|
+
error: error instanceof Error ? error.message : String(error),
|
|
136
|
+
});
|
|
127
137
|
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
export async function handleRequest(req, res, ctx) {
|
|
141
|
+
const method = req.method ?? "GET";
|
|
142
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
143
|
+
const pathname = decodeURIComponent(url.pathname);
|
|
144
|
+
if (method !== "GET") {
|
|
145
|
+
sendJson(res, 405, { error: "Method not allowed" });
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
const match = matchObserveGetRoute(pathname);
|
|
149
|
+
if (match) {
|
|
150
|
+
await handleMatchedObserveRoute(req, res, match, ctx);
|
|
128
151
|
return true;
|
|
129
152
|
}
|
|
130
153
|
if (pathname.startsWith("/api/")) {
|
|
@@ -134,8 +157,17 @@ export async function handleRequest(req, res, ctx) {
|
|
|
134
157
|
return false;
|
|
135
158
|
}
|
|
136
159
|
export async function serveStatic(req, res, staticDir) {
|
|
160
|
+
await serveObserveStatic(req, res, staticDir);
|
|
161
|
+
}
|
|
162
|
+
export async function serveObserveStatic(req, res, staticDir, options = {}) {
|
|
137
163
|
const url = new URL(req.url ?? "/", "http://localhost");
|
|
138
164
|
let pathname = decodeURIComponent(url.pathname);
|
|
165
|
+
const urlPrefix = options.urlPrefix?.replace(/\/$/, "");
|
|
166
|
+
const embeddedShell = Boolean(urlPrefix) &&
|
|
167
|
+
(pathname === urlPrefix || pathname.startsWith(`${urlPrefix}/`));
|
|
168
|
+
if (urlPrefix && embeddedShell) {
|
|
169
|
+
pathname = pathname.slice(urlPrefix.length) || "/";
|
|
170
|
+
}
|
|
139
171
|
if (pathname === "/") {
|
|
140
172
|
pathname = "/index.html";
|
|
141
173
|
}
|
|
@@ -151,9 +183,19 @@ export async function serveStatic(req, res, staticDir) {
|
|
|
151
183
|
sendJson(res, 404, { error: "Not found" });
|
|
152
184
|
return;
|
|
153
185
|
}
|
|
154
|
-
|
|
186
|
+
let content = await readFile(resolvedFile);
|
|
155
187
|
const contentType = contentTypeFor(pathname);
|
|
156
|
-
|
|
188
|
+
if (embeddedShell && contentType.startsWith("text/html")) {
|
|
189
|
+
content = Buffer.from(content
|
|
190
|
+
.toString("utf8")
|
|
191
|
+
.replace(/<body\b/i, '<body data-embedded-shell="true"'));
|
|
192
|
+
}
|
|
193
|
+
res.writeHead(200, {
|
|
194
|
+
"Content-Type": contentType,
|
|
195
|
+
"Content-Length": content.byteLength,
|
|
196
|
+
"X-Content-Type-Options": "nosniff",
|
|
197
|
+
"X-Frame-Options": "SAMEORIGIN",
|
|
198
|
+
});
|
|
157
199
|
res.end(content);
|
|
158
200
|
}
|
|
159
201
|
async function handleHealth(_req, res, _match, ctx) {
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import { createServer } from "node:http";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import {
|
|
4
|
-
import { createObserveSnapshotCache, handleRequest, serveStatic, } from "./routes.js";
|
|
5
|
-
const STATIC_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "static");
|
|
3
|
+
import { createObserveRouteContext, handleRequest, serveStatic, } from "./routes.js";
|
|
6
4
|
/** Align with common reverse-proxy keepalive (e.g. nginx 75s). */
|
|
7
5
|
const DEFAULT_KEEP_ALIVE_TIMEOUT_MS = 65_000;
|
|
8
6
|
export function createObserveServer(options) {
|
|
@@ -15,12 +13,7 @@ export function createObserveServer(options) {
|
|
|
15
13
|
const pollIntervalMs = options.pollIntervalMs ?? 1000;
|
|
16
14
|
const debug = options.debug === true;
|
|
17
15
|
const keepAliveTimeoutMs = options.keepAliveTimeoutMs ?? DEFAULT_KEEP_ALIVE_TIMEOUT_MS;
|
|
18
|
-
const routeContext = {
|
|
19
|
-
repoRoot,
|
|
20
|
-
pollIntervalMs,
|
|
21
|
-
staticDir: STATIC_DIR,
|
|
22
|
-
snapshotCache: createObserveSnapshotCache(),
|
|
23
|
-
};
|
|
16
|
+
const routeContext = createObserveRouteContext(repoRoot, { pollIntervalMs });
|
|
24
17
|
return new Promise((resolve, reject) => {
|
|
25
18
|
const server = createServer((req, res) => {
|
|
26
19
|
const startedAt = Date.now();
|
|
@@ -73,7 +66,7 @@ export function createObserveServer(options) {
|
|
|
73
66
|
async function dispatch(req, res) {
|
|
74
67
|
const handled = await handleRequest(req, res, routeContext);
|
|
75
68
|
if (!handled) {
|
|
76
|
-
await serveStatic(req, res,
|
|
69
|
+
await serveStatic(req, res, routeContext.staticDir);
|
|
77
70
|
}
|
|
78
71
|
}
|
|
79
72
|
server.on("error", reject);
|
|
@@ -10,10 +10,14 @@
|
|
|
10
10
|
rel="stylesheet"
|
|
11
11
|
href="https://cdn.jsdelivr.net/npm/remixicon@4.6.0/fonts/remixicon.css"
|
|
12
12
|
/>
|
|
13
|
-
<link rel="stylesheet" href="
|
|
13
|
+
<link rel="stylesheet" href="styles.css" />
|
|
14
14
|
</head>
|
|
15
15
|
<body data-shell="observe-console">
|
|
16
16
|
<a class="skip-link" href="#main-content">跳到主内容</a>
|
|
17
|
+
<nav class="embedded-operator-nav" aria-label="Operator 导航">
|
|
18
|
+
<a href="/">操作</a>
|
|
19
|
+
<span aria-current="page">观测</span>
|
|
20
|
+
</nav>
|
|
17
21
|
<div id="app">
|
|
18
22
|
<header class="site-header">
|
|
19
23
|
<a href="#/" class="logo" aria-label="Loop Agent 运行看板首页">
|
|
@@ -53,7 +57,6 @@
|
|
|
53
57
|
>
|
|
54
58
|
<header class="page-header dashboard-page-header">
|
|
55
59
|
<div class="view-heading page-header-heading">
|
|
56
|
-
<p class="view-kicker">Observe</p>
|
|
57
60
|
<h2>运行总览</h2>
|
|
58
61
|
</div>
|
|
59
62
|
<div
|
|
@@ -345,6 +348,6 @@
|
|
|
345
348
|
</section>
|
|
346
349
|
</main>
|
|
347
350
|
</div>
|
|
348
|
-
<script type="module" src="
|
|
351
|
+
<script type="module" src="app.js"></script>
|
|
349
352
|
</body>
|
|
350
353
|
</html>
|
|
@@ -74,6 +74,59 @@ body {
|
|
|
74
74
|
line-height: 1.5;
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
.embedded-operator-nav {
|
|
78
|
+
display: none;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
body[data-embedded-shell="true"] .site-header .logo {
|
|
82
|
+
display: none;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
body[data-embedded-shell="true"] .site-header {
|
|
86
|
+
position: sticky;
|
|
87
|
+
top: 49px;
|
|
88
|
+
z-index: 30;
|
|
89
|
+
grid-template-columns: 1fr auto;
|
|
90
|
+
grid-template-areas: "nav status" "crumb crumb";
|
|
91
|
+
min-height: 56px;
|
|
92
|
+
margin-bottom: var(--space-5);
|
|
93
|
+
padding: 10px 0;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
body[data-embedded-shell="true"] .embedded-operator-nav {
|
|
97
|
+
position: sticky;
|
|
98
|
+
top: 0;
|
|
99
|
+
z-index: 40;
|
|
100
|
+
display: flex;
|
|
101
|
+
gap: 6px;
|
|
102
|
+
align-items: center;
|
|
103
|
+
padding: 8px max(24px, calc((100vw - 1680px) / 2));
|
|
104
|
+
border-bottom: 1px solid var(--hairline);
|
|
105
|
+
background: color-mix(in srgb, var(--canvas) 92%, transparent);
|
|
106
|
+
backdrop-filter: blur(10px);
|
|
107
|
+
font-size: 13px;
|
|
108
|
+
font-weight: 700;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
.embedded-operator-nav a,
|
|
112
|
+
.embedded-operator-nav span {
|
|
113
|
+
padding: 6px 12px;
|
|
114
|
+
border-radius: var(--radius-pill);
|
|
115
|
+
text-decoration: none;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.embedded-operator-nav a:hover,
|
|
119
|
+
.embedded-operator-nav a:focus-visible {
|
|
120
|
+
background: var(--surface);
|
|
121
|
+
color: var(--orange-active);
|
|
122
|
+
outline: none;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
.embedded-operator-nav span {
|
|
126
|
+
background: var(--ink);
|
|
127
|
+
color: #fff;
|
|
128
|
+
}
|
|
129
|
+
|
|
77
130
|
button,
|
|
78
131
|
input,
|
|
79
132
|
select,
|
|
@@ -5,13 +5,23 @@ const CASE_ID = /\bBE-[A-Z0-9]+(?:-[A-Z0-9]+)*-\d{3}\b/g;
|
|
|
5
5
|
const AC_ID = /\bAC-[A-Z0-9]+(?:-[A-Z0-9]+)*\b/g;
|
|
6
6
|
const SECRET = /(?:password|passwd|secret|token|api[_-]?key|private[_-]?key|authorization)\s*[:=]\s*\S+/i;
|
|
7
7
|
const PLACEHOLDER = /\b(?:TODO|TBD|FIXME)\b|后续补充|暂不实现|接口正常|结果正确|按实际情况处理/i;
|
|
8
|
+
const CASE_SECTION_ALIASES = {
|
|
9
|
+
acceptanceCriteria: ["Acceptance Criteria", "验收标准"],
|
|
10
|
+
sourceReferences: ["Source References", "需求依据"],
|
|
11
|
+
preconditions: ["Preconditions", "前置条件"],
|
|
12
|
+
testData: ["Test Data", "测试数据"],
|
|
13
|
+
steps: ["Steps", "操作步骤"],
|
|
14
|
+
expectedResults: ["Expected Results", "预期结果"],
|
|
15
|
+
automationNotes: ["Automation Notes", "自动化映射", "自动化说明"],
|
|
16
|
+
testPurpose: ["Test Purpose", "测试目的", "测试场景"],
|
|
17
|
+
};
|
|
8
18
|
const REQUIRED_CASE_SECTIONS = [
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
19
|
+
CASE_SECTION_ALIASES.acceptanceCriteria,
|
|
20
|
+
CASE_SECTION_ALIASES.sourceReferences,
|
|
21
|
+
CASE_SECTION_ALIASES.preconditions,
|
|
22
|
+
CASE_SECTION_ALIASES.steps,
|
|
23
|
+
CASE_SECTION_ALIASES.expectedResults,
|
|
24
|
+
CASE_SECTION_ALIASES.automationNotes,
|
|
15
25
|
];
|
|
16
26
|
async function exists(filePath) {
|
|
17
27
|
try {
|
|
@@ -34,7 +44,7 @@ function unique(values) {
|
|
|
34
44
|
return [...new Set(values)].sort((left, right) => left.localeCompare(right));
|
|
35
45
|
}
|
|
36
46
|
export function requiredBackendMarkdownCaseAcIds(criteria) {
|
|
37
|
-
const downstreamEvidence = /(?:pytest|junit|html|stdout|stderr|traceability
|
|
47
|
+
const downstreamEvidence = /(?:pytest|junit|html|stdout|stderr|traceability|markdown|readme|human-readable|skip|xfail|吞(?:断言|异常)|生产代码|生产配置|配置写入|写入边界|中文(?:用例|文档|结构|展示)?|用例(?:索引|结构|文档)|测试目的|需求依据|操作步骤|预期结果|自动化映射|追溯|一一映射|一一追溯|执行并产生|执行结果|测试报告|失败分类|证据)/i;
|
|
38
48
|
return unique([...criteria]
|
|
39
49
|
.filter((criterion) => !downstreamEvidence.test(criterion))
|
|
40
50
|
.flatMap((criterion) => criterion.match(/\bAC-[A-Z0-9]+(?:-[A-Z0-9]+)*\b/g) ?? []));
|
|
@@ -165,8 +175,9 @@ function splitCases(markdown) {
|
|
|
165
175
|
body: markdown.slice(match.index, headings[index + 1]?.index ?? markdown.length),
|
|
166
176
|
}));
|
|
167
177
|
}
|
|
168
|
-
function sectionBody(body,
|
|
169
|
-
const
|
|
178
|
+
function sectionBody(body, headings) {
|
|
179
|
+
const headingPattern = headings.map(escapeRegExp).join("|");
|
|
180
|
+
const marker = new RegExp(`^###\\s+(?:${headingPattern})\\s*$`, "mi");
|
|
170
181
|
const match = marker.exec(body);
|
|
171
182
|
if (!match)
|
|
172
183
|
return "";
|
|
@@ -320,14 +331,15 @@ export async function validateBackendMarkdownCases(input) {
|
|
|
320
331
|
if (previous)
|
|
321
332
|
throw new Error(`duplicate case id ${testCase.id}: ${previous} and ${path.relative(input.workspaceRoot, file)}`);
|
|
322
333
|
seen.set(testCase.id, path.relative(input.workspaceRoot, file).replaceAll(path.sep, "/"));
|
|
323
|
-
for (const
|
|
324
|
-
|
|
325
|
-
|
|
334
|
+
for (const sectionAliases of REQUIRED_CASE_SECTIONS) {
|
|
335
|
+
const sectionPattern = sectionAliases.map(escapeRegExp).join("|");
|
|
336
|
+
if (!new RegExp(`^###\\s+(?:${sectionPattern})\\s*$`, "mi").test(testCase.body)) {
|
|
337
|
+
throw new Error(`${testCase.id} missing section: ${sectionAliases.join(" or ")}`);
|
|
326
338
|
}
|
|
327
339
|
}
|
|
328
340
|
const acIds = unique(testCase.body.match(AC_ID) ?? []);
|
|
329
341
|
acIds.forEach((id) => coveredAc.add(id));
|
|
330
|
-
const sourceSection = sectionBody(testCase.body,
|
|
342
|
+
const sourceSection = sectionBody(testCase.body, CASE_SECTION_ALIASES.sourceReferences);
|
|
331
343
|
const sourceRefs = extractSourceReferences({
|
|
332
344
|
sourceSection,
|
|
333
345
|
sourceBinding: input.sourceBinding,
|
|
@@ -348,8 +360,8 @@ export async function validateBackendMarkdownCases(input) {
|
|
|
348
360
|
throw new Error(`${testCase.id} references a missing source path: ${sourceRef}`);
|
|
349
361
|
}
|
|
350
362
|
}
|
|
351
|
-
const steps = sectionBody(testCase.body,
|
|
352
|
-
const expected = sectionBody(testCase.body,
|
|
363
|
+
const steps = sectionBody(testCase.body, CASE_SECTION_ALIASES.steps);
|
|
364
|
+
const expected = sectionBody(testCase.body, CASE_SECTION_ALIASES.expectedResults);
|
|
353
365
|
if (!hasNumberedListItem(steps))
|
|
354
366
|
throw new Error(`${testCase.id} has no numbered executable step`);
|
|
355
367
|
if (!hasAssertableExpectedResult(expected))
|
|
@@ -381,18 +393,32 @@ function collectMarkdownCaseIds(markdown) {
|
|
|
381
393
|
}
|
|
382
394
|
function testFunctionRegion(input) {
|
|
383
395
|
const functionLineStart = input.source.lastIndexOf("\n", input.functionIndex - 1) + 1;
|
|
384
|
-
const
|
|
385
|
-
const
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
+
const functionLine = input.source.slice(functionLineStart, input.functionHeaderEnd);
|
|
397
|
+
const indent = functionLine.match(/^\s*/)?.[0] ?? "";
|
|
398
|
+
let regionStart = functionLineStart;
|
|
399
|
+
let cursor = functionLineStart;
|
|
400
|
+
while (cursor > 0) {
|
|
401
|
+
const previousEnd = cursor - 1;
|
|
402
|
+
const previousStart = input.source.lastIndexOf("\n", previousEnd - 1) + 1;
|
|
403
|
+
const previousLine = input.source.slice(previousStart, previousEnd).replace(/\r$/, "");
|
|
404
|
+
if (!previousLine.trim())
|
|
405
|
+
break;
|
|
406
|
+
if (new RegExp(`^${escapeRegExp(indent)}@`).test(previousLine)) {
|
|
407
|
+
regionStart = previousStart;
|
|
408
|
+
cursor = previousStart;
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
break;
|
|
412
|
+
}
|
|
413
|
+
const lines = input.source.slice(input.functionHeaderEnd).split(/\r?\n/);
|
|
414
|
+
let consumed = 0;
|
|
415
|
+
for (const line of lines) {
|
|
416
|
+
const boundary = line.match(/^(\s*)(?:(?:async\s+)?def|class)\s+[A-Za-z_][A-Za-z0-9_]*\b/);
|
|
417
|
+
if (boundary && boundary[1].length <= indent.length)
|
|
418
|
+
break;
|
|
419
|
+
consumed += line.length + 1;
|
|
420
|
+
}
|
|
421
|
+
const regionEnd = Math.min(input.source.length, input.functionHeaderEnd + consumed);
|
|
396
422
|
return input.source
|
|
397
423
|
.slice(regionStart, regionEnd)
|
|
398
424
|
.split(/\r?\n/)
|
|
@@ -436,8 +462,8 @@ export async function validateBackendMarkdownTraceability(workspaceRoot) {
|
|
|
436
462
|
if (unsafeSwallowedException) {
|
|
437
463
|
throw new Error(`swallowed exception is forbidden in generated backend pytest: ${path.relative(workspaceRoot, file)} (${unsafeSwallowedException})`);
|
|
438
464
|
}
|
|
439
|
-
for (const match of content.matchAll(/^(?:async\s+)?def\s+(test_[A-Za-z0-9_]+)\s*\([^)]*\)\s*(?:->\s*[^:\r\n]+)?\s*:/gm)) {
|
|
440
|
-
const symbol = match[
|
|
465
|
+
for (const match of content.matchAll(/^(\s*)(?:async\s+)?def\s+(test_[A-Za-z0-9_]+)\s*\([^)]*\)\s*(?:->\s*[^:\r\n]+)?\s*:/gm)) {
|
|
466
|
+
const symbol = match[2];
|
|
441
467
|
const region = testFunctionRegion({
|
|
442
468
|
source: content,
|
|
443
469
|
functionIndex: match.index,
|
|
@@ -482,6 +508,82 @@ export async function validateBackendMarkdownTraceability(workspaceRoot) {
|
|
|
482
508
|
"- skip/xfail findings: 0",
|
|
483
509
|
].join("\n") + "\n");
|
|
484
510
|
}
|
|
511
|
+
function cleanCaseTitle(rawHeading, id) {
|
|
512
|
+
return rawHeading
|
|
513
|
+
.replace(/^##\s+/, "")
|
|
514
|
+
.replace(new RegExp(`^${escapeRegExp(id)}\\s*(?:[||—–-]\\s*)?`), "")
|
|
515
|
+
.trim() || id;
|
|
516
|
+
}
|
|
517
|
+
export async function collectBackendTestHumanCaseCatalog(workspaceRoot) {
|
|
518
|
+
const files = await markdownFiles(workspaceRoot);
|
|
519
|
+
const catalog = new Map();
|
|
520
|
+
for (const file of files) {
|
|
521
|
+
if (path.basename(file).toLowerCase() === "readme.md")
|
|
522
|
+
continue;
|
|
523
|
+
const markdown = await readFile(file, "utf8");
|
|
524
|
+
for (const testCase of splitCases(markdown)) {
|
|
525
|
+
const heading = testCase.body.match(/^##\s+.*$/m)?.[0] ?? testCase.id;
|
|
526
|
+
const purpose = sectionBody(testCase.body, CASE_SECTION_ALIASES.testPurpose).trim();
|
|
527
|
+
const fallbackScenario = sectionBody(testCase.body, CASE_SECTION_ALIASES.expectedResults)
|
|
528
|
+
.replace(/^\s*(?:\d+[.)]|[-*+])\s+/gm, "")
|
|
529
|
+
.replace(/\s+/g, " ")
|
|
530
|
+
.trim();
|
|
531
|
+
catalog.set(testCase.id, {
|
|
532
|
+
id: testCase.id,
|
|
533
|
+
title: cleanCaseTitle(heading, testCase.id),
|
|
534
|
+
scenario: purpose.replace(/\s+/g, " ").trim() || fallbackScenario || "详见 Markdown 用例步骤与预期结果。",
|
|
535
|
+
markdownPath: path.relative(workspaceRoot, file).replaceAll(path.sep, "/"),
|
|
536
|
+
testFunctions: [],
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
const testcaseRoot = path.join(workspaceRoot, "testcase");
|
|
541
|
+
const pythonFiles = [];
|
|
542
|
+
async function walk(directory) {
|
|
543
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
544
|
+
const absolute = path.join(directory, entry.name);
|
|
545
|
+
if (entry.isDirectory())
|
|
546
|
+
await walk(absolute);
|
|
547
|
+
else if (/^test_.*\.py$/i.test(entry.name))
|
|
548
|
+
pythonFiles.push(absolute);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
if (await exists(testcaseRoot))
|
|
552
|
+
await walk(testcaseRoot);
|
|
553
|
+
for (const file of pythonFiles) {
|
|
554
|
+
const content = await readFile(file, "utf8");
|
|
555
|
+
for (const match of content.matchAll(/^(\s*)(?:async\s+)?def\s+(test_[A-Za-z0-9_]+)\s*\([^)]*\)\s*(?:->\s*[^:\r\n]+)?\s*:/gm)) {
|
|
556
|
+
const symbol = match[2];
|
|
557
|
+
const region = testFunctionRegion({ source: content, functionIndex: match.index, functionHeaderEnd: match.index + match[0].length });
|
|
558
|
+
const ids = new Set(region.match(CASE_ID) ?? []);
|
|
559
|
+
const fromSymbol = symbolCaseId(symbol);
|
|
560
|
+
if (fromSymbol)
|
|
561
|
+
ids.add(fromSymbol);
|
|
562
|
+
for (const id of ids) {
|
|
563
|
+
const item = catalog.get(id);
|
|
564
|
+
if (!item)
|
|
565
|
+
continue;
|
|
566
|
+
item.scriptPath = path.relative(workspaceRoot, file).replaceAll(path.sep, "/");
|
|
567
|
+
if (!item.testFunctions.includes(symbol))
|
|
568
|
+
item.testFunctions.push(symbol);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
return [...catalog.values()].sort((left, right) => left.id.localeCompare(right.id));
|
|
573
|
+
}
|
|
574
|
+
function junitCaseId(name) {
|
|
575
|
+
return symbolCaseId(name) ?? name.match(CASE_ID)?.[0];
|
|
576
|
+
}
|
|
577
|
+
function humanStatus(status) {
|
|
578
|
+
return status === "passed" ? "通过" : status === "failure" ? "失败" : status === "error" ? "错误" : "跳过";
|
|
579
|
+
}
|
|
580
|
+
function formatDuration(durationMs) {
|
|
581
|
+
return durationMs === undefined ? "未记录" : `${(durationMs / 1000).toFixed(3)} 秒`;
|
|
582
|
+
}
|
|
583
|
+
function inferredScriptPath(classname) {
|
|
584
|
+
const moduleName = classname.split(".").filter((part) => part && !/^Test/.test(part)).join("/");
|
|
585
|
+
return `${moduleName || "unknown"}.py`;
|
|
586
|
+
}
|
|
485
587
|
export function redactBackendTestOutput(value) {
|
|
486
588
|
return value
|
|
487
589
|
.replace(/((?:password|passwd|secret|token|api[_-]?key|authorization)\s*[:=]\s*)\S+/gi, "$1[REDACTED]")
|
|
@@ -496,19 +598,28 @@ function escapeHtml(value) {
|
|
|
496
598
|
}
|
|
497
599
|
export function renderBackendTestHtml(input) {
|
|
498
600
|
const passRate = input.parsed.tests > 0 ? input.parsed.passed / input.parsed.tests : 0;
|
|
499
|
-
const
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
601
|
+
const catalog = new Map((input.cases ?? []).map((item) => [item.id, item]));
|
|
602
|
+
const rows = input.parsed.cases.map((result) => {
|
|
603
|
+
const caseId = junitCaseId(result.name) ?? "未关联";
|
|
604
|
+
const item = catalog.get(caseId);
|
|
605
|
+
const scriptPath = item?.scriptPath ?? inferredScriptPath(result.classname);
|
|
606
|
+
const details = result.details || result.message || "";
|
|
607
|
+
const failureCell = result.status === "passed"
|
|
608
|
+
? "—"
|
|
609
|
+
: `<strong>${escapeHtml(result.message || humanStatus(result.status))}</strong>${details ? `<details><summary>查看完整技术详情</summary><pre>${escapeHtml(details)}</pre></details>` : ""}`;
|
|
610
|
+
return `<tr class="result-${result.status}"><td><code>${escapeHtml(caseId)}</code></td><td><strong>${escapeHtml(item?.title ?? result.name)}</strong><small>${escapeHtml(item?.scenario ?? "未从 Markdown 用例提取场景说明。")}</small></td><td><code>${escapeHtml(scriptPath)}</code><small>${escapeHtml(result.name)}</small></td><td><span class="status ${result.status}">${humanStatus(result.status)}</span></td><td>${formatDuration(result.durationMs)}</td><td>${failureCell}</td></tr>`;
|
|
611
|
+
}).join("");
|
|
612
|
+
const conclusion = input.parsed.failed + input.parsed.errors === 0
|
|
613
|
+
? `本轮测试通过:${input.parsed.passed} 条用例执行成功。`
|
|
614
|
+
: `本轮测试未通过:${input.parsed.failed + input.parsed.errors} 条用例失败或错误,请优先查看失败原因。`;
|
|
615
|
+
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escapeHtml(input.title)}</title><style>:root{color-scheme:light;--ink:#172033;--muted:#667085;--line:#d9e2ec;--panel:#f7f9fc;--brand:#17365d;--pass:#067647;--pass-bg:#ecfdf3;--fail:#b42318;--fail-bg:#fef3f2;--skip:#9a6700;--skip-bg:#fffaeb}*{box-sizing:border-box}body{margin:0;background:#eef2f6;color:var(--ink);font-family:system-ui,"Microsoft YaHei","PingFang SC",sans-serif;line-height:1.6}header,main{max-width:1440px;margin:auto}header{padding:2.5rem 2rem 1.25rem}main{padding:0 2rem 3rem}h1{margin:.25rem 0;color:var(--brand)}h2{margin-top:2rem;color:var(--brand)}.eyebrow{color:var(--muted);font-weight:700}.banner{padding:1rem 1.25rem;border-radius:.75rem;background:${input.parsed.failed + input.parsed.errors === 0 ? "var(--pass-bg)" : "var(--fail-bg)"};color:${input.parsed.failed + input.parsed.errors === 0 ? "var(--pass)" : "var(--fail)"};font-weight:700}.cards{display:grid;grid-template-columns:repeat(6,minmax(7rem,1fr));gap:.75rem;margin:1rem 0}.card{padding:1rem;border:1px solid var(--line);border-radius:.75rem;background:white}.card span,small{display:block;color:var(--muted)}.card strong{font-size:1.55rem}section{background:white;border:1px solid var(--line);border-radius:.9rem;padding:1.25rem;margin-top:1rem;box-shadow:0 8px 24px rgba(23,54,93,.04)}.table-wrap{overflow:auto}table{width:100%;border-collapse:collapse;min-width:1100px}th,td{border-bottom:1px solid var(--line);padding:.75rem;text-align:left;vertical-align:top}th{background:var(--panel);position:sticky;top:0}.status{display:inline-block;padding:.15rem .55rem;border-radius:999px;font-weight:700}.status.passed{color:var(--pass);background:var(--pass-bg)}.status.failure,.status.error{color:var(--fail);background:var(--fail-bg)}.status.skipped{color:var(--skip);background:var(--skip-bg)}code{white-space:nowrap}pre{white-space:pre-wrap;max-height:22rem;overflow:auto;background:#101828;color:#f2f4f7;padding:1rem;border-radius:.5rem}details{margin-top:.5rem}summary{cursor:pointer;color:var(--brand);font-weight:700}.secondary{display:grid;grid-template-columns:1fr 1fr;gap:1rem}.secondary pre{background:var(--panel);color:var(--ink)}@media(max-width:900px){.cards{grid-template-columns:repeat(2,1fr)}.secondary{grid-template-columns:1fr}header,main{padding-left:1rem;padding-right:1rem}}</style></head><body><header><div class="eyebrow">后端自动化测试报告</div><h1>${escapeHtml(input.title)}</h1><p>主要面向测试、研发和评审人员;执行事实来自同一次 pytest JUnit。</p></header><main><section><h2>测试结论</h2><div class="banner">${escapeHtml(conclusion)}</div><div class="cards"><div class="card"><span>用例总数</span><strong>${input.parsed.tests}</strong></div><div class="card"><span>通过</span><strong>${input.parsed.passed}</strong></div><div class="card"><span>失败</span><strong>${input.parsed.failed}</strong></div><div class="card"><span>错误</span><strong>${input.parsed.errors}</strong></div><div class="card"><span>跳过</span><strong>${input.parsed.skipped}</strong></div><div class="card"><span>通过率 / 总耗时</span><strong>${(passRate * 100).toFixed(2)}%</strong><small>${formatDuration(input.parsed.durationMs)}</small></div></div></section><section><h2>用例执行结果</h2><div class="table-wrap"><table><thead><tr><th>用例编号</th><th>用例名称及测试场景</th><th>自动化脚本</th><th>结果</th><th>耗时</th><th>失败原因</th></tr></thead><tbody>${rows || '<tr><td colspan="6">未发现可展示的 JUnit testcase。</td></tr>'}</tbody></table></div></section><section><h2>执行证据</h2><div class="secondary"><details><summary>环境信息</summary><pre>${escapeHtml(input.environmentSummary.slice(0, 8000))}</pre></details><details><summary>用例追溯信息</summary><pre>${escapeHtml(input.traceabilitySummary.slice(0, 8000))}</pre></details></div></section></main></body></html>`;
|
|
505
616
|
}
|
|
506
617
|
export function renderBackendTestFacts(input) {
|
|
507
618
|
const passRate = input.parsed.tests > 0 ? input.parsed.passed / input.parsed.tests : 0;
|
|
508
619
|
return ([
|
|
509
|
-
"#
|
|
620
|
+
"# 后端测试执行事实",
|
|
510
621
|
"",
|
|
511
|
-
"##
|
|
622
|
+
"## 执行摘要",
|
|
512
623
|
"",
|
|
513
624
|
`- Status: ${input.pytestExitCode === 0 ? "passed" : "completed-with-failures"}`,
|
|
514
625
|
`- Pytest exit code: ${input.pytestExitCode}`,
|
|
@@ -516,7 +627,7 @@ export function renderBackendTestFacts(input) {
|
|
|
516
627
|
"- JUnit valid: yes",
|
|
517
628
|
"- HTML valid: yes",
|
|
518
629
|
"",
|
|
519
|
-
"##
|
|
630
|
+
"## 计数",
|
|
520
631
|
"",
|
|
521
632
|
`- Total: ${input.parsed.tests}`,
|
|
522
633
|
`- Passed: ${input.parsed.passed}`,
|
|
@@ -525,20 +636,31 @@ export function renderBackendTestFacts(input) {
|
|
|
525
636
|
`- Skipped: ${input.parsed.skipped}`,
|
|
526
637
|
`- Pass rate: ${(passRate * 100).toFixed(2)}%`,
|
|
527
638
|
"",
|
|
528
|
-
"##
|
|
639
|
+
"## 逐条执行结果",
|
|
640
|
+
"",
|
|
641
|
+
"| 用例编号 | 用例名称 | 自动化脚本 | 测试函数 | 结果 | 耗时 | 失败原因 |",
|
|
642
|
+
"|---|---|---|---|---|---:|---|",
|
|
643
|
+
...input.parsed.cases.map((result) => {
|
|
644
|
+
const caseId = junitCaseId(result.name) ?? "未关联";
|
|
645
|
+
const item = new Map((input.cases ?? []).map((entry) => [entry.id, entry])).get(caseId);
|
|
646
|
+
const script = item?.scriptPath ?? inferredScriptPath(result.classname);
|
|
647
|
+
return `| ${caseId} | ${item?.title ?? result.name} | \`${script}\` | \`${result.name}\` | ${humanStatus(result.status)} | ${formatDuration(result.durationMs)} | ${(result.message ?? "—").replaceAll("|", "\\|")} |`;
|
|
648
|
+
}),
|
|
649
|
+
"",
|
|
650
|
+
"## 证据",
|
|
529
651
|
"",
|
|
530
652
|
`- JUnit: ${input.junitRelativePath}`,
|
|
531
653
|
`- JUnit SHA-256: ${createHash("sha256").update(input.junitContent).digest("hex")}`,
|
|
532
654
|
`- HTML: ${input.htmlRelativePath}`,
|
|
533
655
|
`- HTML SHA-256: ${createHash("sha256").update(input.htmlContent).digest("hex")}`,
|
|
534
656
|
"",
|
|
535
|
-
"##
|
|
657
|
+
"## 失败用例",
|
|
536
658
|
"",
|
|
537
659
|
...(input.parsed.failures.length > 0
|
|
538
660
|
? input.parsed.failures.map((failure) => `- ${failure.name} [${failure.kind}]: ${failure.message}`)
|
|
539
661
|
: ["- None"]),
|
|
540
662
|
"",
|
|
541
|
-
"##
|
|
663
|
+
"## 成熟度证据",
|
|
542
664
|
"",
|
|
543
665
|
"- Code coverage: unavailable unless a separate validated coverage artifact exists.",
|
|
544
666
|
"- Stability: unavailable unless at least five independent runs are recorded.",
|