@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.
Files changed (52) hide show
  1. package/AGENTS.md +2 -2
  2. package/CHANGELOG.md +33 -0
  3. package/README.md +4 -6
  4. package/dist/application/dag/generate-task-dag.js +12 -1
  5. package/dist/commands/init.js +3 -3
  6. package/dist/executors/shell-executor.js +188 -2
  7. package/dist/governance/exec-plans.js +4 -0
  8. package/dist/worker/cli.js +13 -12
  9. package/dist/worker/console/doctor.js +55 -2
  10. package/dist/worker/console/index.js +1 -0
  11. package/dist/worker/console/loopback.js +2 -2
  12. package/dist/worker/console/observe-link.js +7 -2
  13. package/dist/worker/console/operator-actions.js +30 -2
  14. package/dist/worker/console/operator-selection.js +92 -0
  15. package/dist/worker/console/operator-surface-health.js +23 -0
  16. package/dist/worker/console/recovery-cta.js +2 -2
  17. package/dist/worker/console/routes.js +45 -19
  18. package/dist/worker/console/security.js +29 -4
  19. package/dist/worker/console/server.js +106 -8
  20. package/dist/worker/console/static/assets/index-3vsjZJHq.js +16 -0
  21. package/dist/worker/console/static/assets/index-i1wV4LrY.css +1 -0
  22. package/dist/worker/console/static/index.html +2 -2
  23. package/dist/worker/observe/routes.js +63 -21
  24. package/dist/worker/observe/server.js +3 -10
  25. package/dist/worker/observe/static/index.html +6 -3
  26. package/dist/worker/observe/static/styles.css +53 -0
  27. package/dist/workflows/dag/backend-test-markdown-workflow.js +163 -41
  28. package/dist/workflows/dag/backend-test-result-contract.js +30 -7
  29. package/dist/workflows/dag/frontend-prewrite-gate.js +77 -0
  30. package/dist/workflows/dag/frontend-repair.js +7 -1
  31. package/dist/workflows/dag/frontend-review-context.js +43 -0
  32. package/dist/workflows/dag/frontend-verification-trace.js +34 -15
  33. package/dist/workflows/dag/governance-profile.js +14 -6
  34. package/dist/workflows/dag/init-hybrid.js +143 -399
  35. package/dist/workflows/dag/types.js +30 -0
  36. package/dist/workflows/dag/validate.js +22 -1
  37. package/docs/README.md +4 -2
  38. package/docs/architecture/evolution.md +6 -6
  39. package/docs/architecture/worker-and-feature.md +9 -9
  40. package/docs/templates/agent-dag.schema.json +40 -0
  41. package/docs/templates/backend-test-dag.generate-pytest.prompt.md +23 -192
  42. package/docs/templates/backend-test-dag.json +8 -8
  43. package/docs/templates/backend-test-dag.review-cases.prompt.md +22 -75
  44. package/package.json +1 -1
  45. package/skills/agent-worker/references/agent-worker-operator.md +2 -2
  46. package/skills/frontend-implementation/references/node-contracts.md +6 -8
  47. package/skills/frontend-review/SKILL.md +5 -8
  48. package/skills/loop-agent/references/command-reference.md +4 -2
  49. package/skills/loop-agent/references/harness-policy.md +1 -1
  50. package/skills/loop-agent/references/hybrid-dag.md +2 -2
  51. package/dist/worker/console/static/assets/index-KUSib7aM.js +0 -16
  52. 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-KUSib7aM.js"></script>
8
- <link rel="stylesheet" crossorigin href="/assets/index-ucIzpaGJ.css">
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
- const ROUTES = [
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 async function handleRequest(req, res, ctx) {
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 !== 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
- try {
119
- await route.handler(req, res, { params, query: url.searchParams }, ctx);
120
- }
121
- catch (error) {
122
- if (!res.headersSent) {
123
- sendJson(res, 500, {
124
- error: error instanceof Error ? error.message : String(error),
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
- const content = await readFile(resolvedFile);
186
+ let content = await readFile(resolvedFile);
155
187
  const contentType = contentTypeFor(pathname);
156
- res.writeHead(200, { "Content-Type": contentType });
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 { fileURLToPath } from "node:url";
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, STATIC_DIR);
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="/styles.css" />
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="/app.js"></script>
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
- "Acceptance Criteria",
10
- "Source References",
11
- "Preconditions",
12
- "Steps",
13
- "Expected Results",
14
- "Automation Notes",
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|追溯|一一映射|一一追溯|执行并产生|执行结果|测试报告|失败分类|证据)/i;
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, heading) {
169
- const marker = new RegExp(`^###\\s+${heading}\\s*$`, "mi");
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 section of REQUIRED_CASE_SECTIONS) {
324
- if (!new RegExp(`^###\\s+${section}\\s*$`, "mi").test(testCase.body)) {
325
- throw new Error(`${testCase.id} missing section: ${section}`);
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, "Source References");
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, "Steps");
352
- const expected = sectionBody(testCase.body, "Expected Results");
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 prefix = input.source.slice(0, functionLineStart);
385
- const previousBlankLine = Math.max(prefix.lastIndexOf("\n\n"), prefix.lastIndexOf("\r\n\r\n"));
386
- const decoratorCandidateStart = previousBlankLine >= 0
387
- ? previousBlankLine + (prefix.startsWith("\r\n", previousBlankLine) ? 4 : 2)
388
- : 0;
389
- const decoratorCandidate = input.source.slice(decoratorCandidateStart, functionLineStart);
390
- const regionStart = /^\s*@/m.test(decoratorCandidate)
391
- ? decoratorCandidateStart
392
- : functionLineStart;
393
- const nextBoundary = /^(?:(?:async\s+)?def|class)\s+[A-Za-z_][A-Za-z0-9_]*\b/gm;
394
- nextBoundary.lastIndex = input.functionHeaderEnd;
395
- const regionEnd = nextBoundary.exec(input.source)?.index ?? input.source.length;
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[1];
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 failures = input.parsed.failures.length
500
- ? input.parsed.failures
501
- .map((failure) => `<tr><td>${escapeHtml(failure.name)}</td><td>${escapeHtml(failure.kind)}</td><td><pre>${escapeHtml(failure.message)}</pre></td></tr>`)
502
- .join("")
503
- : '<tr><td colspan="3">No failures</td></tr>';
504
- return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><title>${escapeHtml(input.title)}</title><style>body{font-family:system-ui,"Microsoft YaHei",sans-serif;margin:2rem;color:#172033}h1,h2{color:#17365d}.cards{display:grid;grid-template-columns:repeat(3,minmax(8rem,1fr));gap:.75rem}.card{padding:1rem;border:1px solid #ccd6e0;border-radius:.5rem;background:#f7f9fc}.bad{color:#b42318}table{width:100%;border-collapse:collapse}th,td{border:1px solid #ccd6e0;padding:.55rem;text-align:left;vertical-align:top}pre{white-space:pre-wrap;max-height:18rem;overflow:auto}</style></head><body><h1>${escapeHtml(input.title)}</h1><div class="cards"><div class="card">Total<br><strong>${input.parsed.tests}</strong></div><div class="card">Passed<br><strong>${input.parsed.passed}</strong></div><div class="card bad">Failed/Error<br><strong>${input.parsed.failed + input.parsed.errors}</strong></div><div class="card">Skipped<br><strong>${input.parsed.skipped}</strong></div><div class="card">Pass rate<br><strong>${(passRate * 100).toFixed(2)}%</strong></div><div class="card">Duration<br><strong>${input.parsed.durationMs ?? "unavailable"} ms</strong></div></div><h2>Environment</h2><pre>${escapeHtml(input.environmentSummary.slice(0, 4000))}</pre><h2>Traceability</h2><pre>${escapeHtml(input.traceabilitySummary.slice(0, 4000))}</pre><h2>Failures</h2><table><thead><tr><th>Test</th><th>Kind</th><th>Summary</th></tr></thead><tbody>${failures}</tbody></table></body></html>`;
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
- "# Backend Test Execution Facts",
620
+ "# 后端测试执行事实",
510
621
  "",
511
- "## Execution",
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
- "## Counts",
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
- "## Evidence",
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
- "## Failed Tests",
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
- "## Maturity Evidence",
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.",