diff --git a/.graphlemon/runs/837fc183-f87f-41d9-8bc9-6262d7172834/code_diff.patch b/.graphlemon/runs/837fc183-f87f-41d9-8bc9-6262d7172834/code_diff.patch new file mode 100644 index 0000000..4eb2a9f --- /dev/null +++ b/.graphlemon/runs/837fc183-f87f-41d9-8bc9-6262d7172834/code_diff.patch @@ -0,0 +1,458 @@ +diff --git a/frontend/app.js b/frontend/app.js +index da24c1e..2f17236 100644 +--- a/frontend/app.js ++++ b/frontend/app.js +@@ -97,6 +97,13 @@ const routes = { + const TASK_DETAIL_RE = /^#\/tasks\/([^/]+)$/; + const SKILL_AUDIT_RE = /^#\/skills\/([^/]+)\/audit$/; + const ARTIFACT_DETAIL_RE = /^#\/artifacts\/([^/]+)$/; ++// Org Graph 三节点条点击可达详情 (2026-08-06 SDD): both v_dashboard's and ++// v_task_detail's org-strip nodes route here. `:org_node` is restricted to ++// the three real values on purpose -- anything else (typo, stray path ++// segment) falls through to the "Not found" fallback in render() instead of ++// reaching renderTaskDetail/mountTaskDetail with a bogus org_node that would ++// otherwise need its own defensive checks further down. ++const ORG_NODE_DETAIL_RE = /^#\/tasks\/([^/]+)\/org\/(planner|coder|validator)$/; + + let currentRouteCleanup = null; + function registerRouteCleanup(fn) { +@@ -114,9 +121,15 @@ function render() { + let routeArgs = []; + if (!route) { + const taskMatch = hash.match(TASK_DETAIL_RE); ++ const orgNodeMatch = hash.match(ORG_NODE_DETAIL_RE); + const skillMatch = hash.match(SKILL_AUDIT_RE); + const artifactMatch = hash.match(ARTIFACT_DETAIL_RE); +- if (taskMatch) { ++ if (orgNodeMatch) { ++ // Reuses v_task_detail itself (same render/mount) with the org_node ++ // carried through as a second arg -- no separate view/route handler. ++ route = { render: renderTaskDetail, mount: mountTaskDetail }; ++ routeArgs = [orgNodeMatch[1], orgNodeMatch[2]]; ++ } else if (taskMatch) { + route = { render: renderTaskDetail, mount: mountTaskDetail }; + routeArgs = [taskMatch[1]]; + } else if (skillMatch) { +@@ -537,6 +550,120 @@ function mountBugFeedback() { + + const ORG_NODE_ORDER = ["planner", "coder", "validator"]; + ++// ---- Org Graph 三节点条点击可达详情 (2026-08-06 SDD): shared by ++// v_dashboard's `#org_strip` and v_task_detail's own inline org-strip below ++// -- both mark each `.org-node` with `data-org-node`, both route through ++// `#/tasks/:task_id/org/:org_node`, both pick "最新一次 work_graph_run" for ++// that org_node the same way (C6: plain array filtering, no new backend ++// query params). DAG/log/artifact display itself stays entirely inside ++// renderGraphVisualizerPanel/renderTodoTree/mountLogViewer/v_artifact_browser ++// (C3-C5) -- this block only does the "接线": which run is "latest", and ++// where on the already-rendered page to scroll/highlight. ++ ++function getLatestRunForOrgNode(task, orgNode) { ++ const runs = task.work_graph_runs.filter((r) => r.org_node === orgNode); ++ return runs.length ? runs[runs.length - 1] : null; ++} ++ ++// v_dashboard's org-strip: role + hint text, only ever links out to ++// v_task_detail (a genuine page change, so plain hash assignment is fine). ++function renderDashboardOrgStripHtml(activeOrgNode, runStatusByOrgNode) { ++ return ORG_NODE_ORDER.map((node, i) => { ++ const isActive = activeOrgNode === node; ++ const runStatus = runStatusByOrgNode[node]; ++ const hint = isActive ? "当前节点" : runStatus === "done" ? "已完成" : runStatus || "未开始"; ++ const arrow = i < ORG_NODE_ORDER.length - 1 ? '
→
' : ""; ++ return '
' ++ + node + '
' + escapeHtml(hint) + "
" + arrow; ++ }).join(""); ++} ++ ++// v_task_detail's inline "Org Graph" panel: plainer markup (no role/hint ++// split), `activeSet` already carries different semantics than the ++// dashboard's single activeOrgNode -- kept as its own function rather than ++// forcing the two call sites to share one shape. ++function renderTaskDetailOrgStripHtml(activeSet) { ++ return ORG_NODE_ORDER.map((node, i) => { ++ const arrow = i < ORG_NODE_ORDER.length - 1 ? '
→
' : ""; ++ return '
' + node + "
" + arrow; ++ }).join(""); ++} ++ ++function mountOrgStripClicks(container, taskId) { ++ if (!taskId) return; ++ for (const el of container.querySelectorAll(".org-node[data-org-node]")) { ++ el.addEventListener("click", () => { ++ window.location.hash = "#/tasks/" + taskId + "/org/" + el.dataset.orgNode; ++ }); ++ } ++} ++ ++// "组合视图" content for v_task_detail's "Org 节点详情" panel: reuses the ++// artifact list's own markup pattern (li[data-artifact-id], same as ++// renderArtifactSummaryList) filtered down to this run's own artifacts -- ++// linking to #/artifacts/:id (v_artifact_browser), not re-rendering artifact ++// content itself (C5). The DAG/to-do-tree/log panels are NOT duplicated here ++// -- they're already rendered once, further down this same page, by the ++// unmodified renderGraphVisualizerPanel/renderTodoTree calls; this function ++// only describes which run that is and lists its artifacts. ++function renderOrgComboPanelContent(task, orgNode) { ++ const run = getLatestRunForOrgNode(task, orgNode); ++ if (!run) { ++ return '
' + escapeHtml(orgNode) + " 尚未开始(暂无 Work Graph run)
"; ++ } ++ const hasDag = !!run.graph_manifest && run.graph_manifest.nodes.length > 0; ++ const artifacts = task.artifacts.filter((a) => a.produced_by_run_id === run.run_id); ++ const artifactsHtml = artifacts.length ? ( ++ '" ++ ) : '
暂无 Artifact
'; ++ return ( ++ '
最新一次 run_id=' + escapeHtml(run.run_id) + "(status=" + escapeHtml(run.status) + ")" ++ + (hasDag ? " · 已定位到下方 Work Graph / to-do 树,点击节点可展开日志" : " · 该 run 尚未生成 Work Graph") + "
" ++ + '

该 run 产出的 Artifact

' ++ + artifactsHtml ++ ); ++} ++ ++function wireOrgComboArtifactClicks(scopeEl) { ++ const combo = scopeEl.querySelector("#org_combo_content"); ++ if (!combo) return; ++ for (const li of combo.querySelectorAll("li[data-artifact-id]")) { ++ li.addEventListener("click", () => { ++ window.location.hash = "#/artifacts/" + encodeURIComponent(li.dataset.artifactId); ++ }); ++ } ++} ++ ++// Called both on initial mount (deep link / dashboard click landed here with ++// an org_node already known) and from the inline org-strip's own click ++// handler (already on this page -- 02-V happy path 2.2 explicitly wants an ++// in-page scroll, not another full page render). Scrolls to the DAG panel ++// first (renders whenever graph_manifest exists), falls back to the to-do ++// tree panel, then to the combo panel itself for the "尚无 run"/"尚未生成 ++// Work Graph" empty states -- never throws if a target is missing. ++function focusOrgNodePanel(content, task, orgNode) { ++ for (const el of content.querySelectorAll(".org-strip .org-node[data-org-node]")) { ++ el.classList.toggle("org-node-focused", el.dataset.orgNode === orgNode); ++ } ++ const comboContent = content.querySelector("#org_combo_content"); ++ if (comboContent) { ++ comboContent.innerHTML = renderOrgComboPanelContent(task, orgNode); ++ wireOrgComboArtifactClicks(content); ++ } ++ const run = getLatestRunForOrgNode(task, orgNode); ++ let target = run ? document.getElementById("graph-panel-" + run.run_id) : null; ++ if (!target && run) target = document.getElementById("todo-tree-" + run.run_id); ++ if (!target) target = content.querySelector("#org_combo_panel"); ++ if (target && target.scrollIntoView) { ++ target.scrollIntoView({ behavior: "smooth", block: "start" }); ++ target.classList.add("org-highlight-flash"); ++ setTimeout(() => target.classList.remove("org-highlight-flash"), 1600); ++ } ++} ++ + function renderDashboard() { + if (!getCurrentProjectId()) return renderNoProjectSelected(); + return ` +@@ -585,16 +712,13 @@ function mountDashboard() { + const quotaBanner = document.getElementById("quota_info_banner"); + const wsBanner = document.getElementById("ws_disconnected_banner"); + const recentEvents = []; ++ // Set right before every renderOrgStrip() call below -- org-strip clicks ++ // need to know which task_id to route to (#/tasks/:task_id/org/:org_node). ++ let orgStripTaskId = null; + + function renderOrgStrip(activeOrgNode, runStatusByOrgNode) { +- orgStrip.innerHTML = ORG_NODE_ORDER.map((node, i) => { +- const isActive = activeOrgNode === node; +- const runStatus = runStatusByOrgNode[node]; +- const hint = isActive ? "当前节点" : runStatus === "done" ? "已完成" : runStatus || "未开始"; +- const arrow = i < ORG_NODE_ORDER.length - 1 ? '
→
' : ""; +- return '
' +- + node + '
' + escapeHtml(hint) + "
" + arrow; +- }).join(""); ++ orgStrip.innerHTML = renderDashboardOrgStripHtml(activeOrgNode, runStatusByOrgNode); ++ mountOrgStripClicks(orgStrip, orgStripTaskId); + } + + async function refreshTasksAndOrgGraph() { +@@ -602,11 +726,13 @@ function mountDashboard() { + const tasks = await resp.json(); + const active = tasks.find((t) => t.status !== "done") || tasks[0]; + if (!active) { ++ orgStripTaskId = null; + orgStrip.innerHTML = '
暂无运行中任务
'; + taskSummary.innerHTML = '
暂无运行中任务 · 提出第一个目标 →
'; + quotaBanner.style.display = "none"; + return; + } ++ orgStripTaskId = active.task_id; + // v_dashboard Start affordance (2026-08-03): the full Pause/Stop set + // only makes sense on v_task_detail (they need the task's own status + // context to pick which one to show) -- the dashboard only ever needs +@@ -888,7 +1014,10 @@ function renderGraphVisualizerPanel(run, options) { + }).join(""); + + return ( +- '

' + run.org_node + " Work Graph DAG 视图(v_graph_visualizer,run_id=" + run.run_id + ")

" ++ // id="graph-panel-": Org Graph 点击可达详情's scroll/highlight ++ // target (focusOrgNodePanel above) -- additive, doesn't change any ++ // existing class/data-* this function already emitted. ++ '

' + run.org_node + " Work Graph DAG 视图(v_graph_visualizer,run_id=" + run.run_id + ")

" + + '
' + rowsHtml + "
" + + '
' + GRAPH_LEGEND_HTML + "
" + + "
" +@@ -1031,11 +1160,19 @@ function renderTaskDetail(taskId) { + `; + } + +-function mountTaskDetail(taskId) { ++function mountTaskDetail(taskId, initialOrgNode) { + const projectId = getCurrentProjectId(); + if (!projectId) return; + const content = document.getElementById("td_content"); + ++ // Org Graph 三节点条点击可达详情 (2026-08-06 SDD): which org_node's ++ // "组合视图" (DAG/日志/Artifact) is currently focused -- seeded from the ++ // #/tasks/:task_id/org/:org_node route (deep link / dashboard click) and ++ // updated in place when the inline org-strip below is clicked while ++ // already on this page, so a WS-triggered refresh() re-render keeps ++ // pointing at the same node instead of losing focus. ++ let focusedOrgNode = initialOrgNode || null; ++ + // v_log_viewer instances (02-V §2.7.1) mounted lazily on first to-do + // expand -- tracked here so a re-render (WS-triggered refresh) or leaving + // the page closes any live c_ws_log_tail connections instead of leaking them. +@@ -1069,7 +1206,9 @@ function mountTaskDetail(taskId) { + ); + }).join(""); + return ( +- '

' + run.org_node + " Work Graph 的 to-do 树(run_id=" + run.run_id + ")

" ++ // id="todo-tree-": secondary scroll/highlight target when the ++ // DAG panel above didn't render (graph_manifest missing). ++ '

' + run.org_node + " Work Graph 的 to-do 树(run_id=" + run.run_id + ")

" + + '
    ' + items + "
" + + '
DAG 视图(同一份 graph_manifest 的另一种呈现)见下方 v_graph_visualizer
' + + "
" +@@ -1082,10 +1221,7 @@ function mountTaskDetail(taskId) { + for (const run of task.work_graph_runs) { + if (run.status !== "done" && run.status !== "failed") activeSet.add(run.org_node); + } +- const orgStripHtml = ORG_NODE_ORDER.map((node, i) => { +- const arrow = i < ORG_NODE_ORDER.length - 1 ? '
→
' : ""; +- return '
' + node + "
" + arrow; +- }).join(""); ++ const orgStripHtml = renderTaskDetailOrgStripHtml(activeSet); + + // v_task_detail Start/Pause/Stop (2026-08-03): a task only ever has one + // real thing "running" against it at a time -- exactly one of these +@@ -1168,6 +1304,13 @@ function mountTaskDetail(taskId) { + + "
CREATED_VIA" + escapeHtml(task.created_via) + "
" + + "
" + + '

Org Graph

' + orgStripHtml + "
" ++ // "Org 节点详情" (2026-08-06 SDD): click-through target -- filled in by ++ // focusOrgNodePanel() below, either right after this render (deep ++ // link/dashboard click already carries an org_node) or on a later ++ // inline org-strip click. Left as the default hint until then. ++ + '

Org 节点详情

' ++ + '
点击上方 Org Graph 节点,查看该节点最新一次 Work Graph / 日志 / Artifact
' ++ + "
" + + todoTreesHtml + + graphVisualizerHtml + + '
' +@@ -1191,6 +1334,22 @@ function mountTaskDetail(taskId) { + } + mountGraphVisualizerTooltips(content); + ++ // Org Graph 三节点条点击可达详情: clicking a node here (already on ++ // v_task_detail) stays on this page -- update focusedOrgNode + the URL ++ // (history.replaceState, no hashchange -> no full route re-render) and ++ // scroll/highlight in place, per 02-V happy path 2.2. ++ for (const el of content.querySelectorAll(".org-strip .org-node[data-org-node]")) { ++ el.addEventListener("click", () => { ++ focusedOrgNode = el.dataset.orgNode; ++ const newHash = "#/tasks/" + taskId + "/org/" + focusedOrgNode; ++ if (window.location.hash !== newHash) history.replaceState(null, "", newHash); ++ focusOrgNodePanel(content, task, focusedOrgNode); ++ }); ++ } ++ // Deep link / dashboard click landed here with an org_node already ++ // known -- populate + scroll to it once, right after this render. ++ if (focusedOrgNode) focusOrgNodePanel(content, task, focusedOrgNode); ++ + const resumeBtn = document.getElementById("btn_manual_resume"); + if (resumeBtn) { + resumeBtn.addEventListener("click", async () => { +diff --git a/frontend/style.css b/frontend/style.css +index 1ec077d..c3b4179 100644 +--- a/frontend/style.css ++++ b/frontend/style.css +@@ -45,6 +45,18 @@ body { font-family: system-ui, sans-serif; margin: 0; } + .org-node .role { font-weight: 600; font-size: 13px; } + .org-node .hint { margin-top: 2px; } + .org-arrow { flex: 0 0 30px; text-align: center; color: #6b7280; } ++ ++/* Org Graph 三节点条点击可达详情 (2026-08-06 SDD): org-strip nodes route to ++ `#/tasks/:task_id/org/:org_node` (v_dashboard) or scroll/highlight in ++ place (v_task_detail's own inline org-strip). */ ++.org-strip .org-node[data-org-node] { cursor: pointer; } ++.org-strip .org-node[data-org-node]:hover { box-shadow: 0 0 0 2px #4f46e5; } ++.org-strip .org-node.org-node-focused { box-shadow: 0 0 0 2px #4f46e5; } ++.org-highlight-flash { animation: org-highlight-flash-kf 1.6s ease-out; } ++@keyframes org-highlight-flash-kf { ++ 0% { box-shadow: 0 0 0 3px #f59e0b; } ++ 100% { box-shadow: none; } ++} + .task-summary .row { display: flex; justify-content: space-between; font-size: 13px; padding: 6px 0; border-bottom: 1px solid #e5e7eb; } + .task-summary .row:last-child { border-bottom: none; } + .progress { height: 8px; border-radius: 4px; background: #e5e7eb; overflow: hidden; margin-top: 6px; } +diff --git a/frontend/test_org_click_through_smoke.js b/frontend/test_org_click_through_smoke.js +new file mode 100644 +index 0000000..043319b +--- /dev/null ++++ b/frontend/test_org_click_through_smoke.js +@@ -0,0 +1,144 @@ ++// Org Graph 三节点条点击可达详情 (2026-08-06 SDD) smoke test. Same ++// no-framework/no-fixture convention as frontend/test_graph_console_smoke.js ++// -- loads app.js in a bare vm.Context and asserts on generated HTML ++// strings / regex matches / plain data for the new top-level functions: ++// - renderDashboardOrgStripHtml / renderTaskDetailOrgStripHtml: both ++// org-strip render locations now carry data-org-node on every node ++// (root cause from the BRD: renderOrgStrip() had zero data-* attrs). ++// - ORG_NODE_DETAIL_RE: the new #/tasks/:task_id/org/:org_node route -- ++// legal three-way org_node enum, illegal node name, missing segment. ++// - getLatestRunForOrgNode: "取最新 run" filter -- must return the ++// started_at-latest (array-last) entry for a given org_node, not the ++// first, and must not merge two runs' nodes together. ++// - renderOrgComboPanelContent: empty-state (no run yet) and populated ++// (with/without artifacts) cases, always via the existing simple-list ++// markup pattern -- no separate artifact-content renderer invented here. ++// ++// Run: node frontend/test_org_click_through_smoke.js ++ ++"use strict"; ++const fs = require("fs"); ++const path = require("path"); ++const vm = require("vm"); ++const assert = require("assert"); ++ ++function makeFakeDiv() { ++ let text = ""; ++ return { ++ set textContent(v) { text = v == null ? "" : String(v); }, ++ get textContent() { return text; }, ++ get innerHTML() { ++ return text.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); ++ }, ++ }; ++} ++ ++const sandbox = { ++ document: { ++ createElement: () => makeFakeDiv(), ++ getElementById: () => null, ++ querySelectorAll: () => [], ++ addEventListener: () => {}, ++ }, ++ window: { ++ location: { protocol: "http:", host: "localhost", hash: "" }, ++ addEventListener: () => {}, ++ }, ++ localStorage: { getItem: () => null, setItem: () => {} }, ++ console, ++}; ++vm.createContext(sandbox); ++ ++const src = fs.readFileSync(path.join(__dirname, "app.js"), "utf8"); ++// Top-level `const` regexes stay lexically scoped to the script and never ++// reach the sandbox object (same Annex B quirk test_graph_console_smoke.js ++// already documents for ARTIFACT_DETAIL_RE) -- re-export what this test needs. ++const augmentedSrc = src + "\nglobalThis.ORG_NODE_DETAIL_RE = ORG_NODE_DETAIL_RE;\n"; ++new vm.Script(augmentedSrc, { filename: "app.js" }).runInContext(sandbox); ++ ++let assertionCount = 0; ++function check(condition, message) { ++ assertionCount++; ++ assert.ok(condition, message); ++} ++ ++// ---- 1. data-org-node on both org-strip render locations ---- ++{ ++ const dashboardHtml = sandbox.renderDashboardOrgStripHtml("coder", { planner: "done", coder: "running" }); ++ for (const node of ["planner", "coder", "validator"]) { ++ check(dashboardHtml.includes('data-org-node="' + node + '"'), "dashboard org-strip must carry data-org-node for " + node); ++ } ++ check(dashboardHtml.includes('class="org-node active" data-org-node="coder"'), "active node keeps its existing .active class alongside the new attribute"); ++ ++ const activeSet = new Set(["planner"]); ++ const taskDetailHtml = sandbox.renderTaskDetailOrgStripHtml(activeSet); ++ for (const node of ["planner", "coder", "validator"]) { ++ check(taskDetailHtml.includes('data-org-node="' + node + '"'), "v_task_detail inline org-strip must carry data-org-node for " + node); ++ } ++} ++ ++// ---- 2. ORG_NODE_DETAIL_RE: happy / boundary / failure ---- ++{ ++ const legal = "#/tasks/abc-123/org/coder".match(sandbox.ORG_NODE_DETAIL_RE); ++ check(legal, "legal task_id + coder must match"); ++ check(legal[1] === "abc-123" && legal[2] === "coder", "captured groups must be task_id and org_node"); ++ ++ for (const node of ["planner", "coder", "validator"]) { ++ check(("#/tasks/t1/org/" + node).match(sandbox.ORG_NODE_DETAIL_RE), "all three org_node enum values must match: " + node); ++ } ++ ++ check(!"#/tasks/t1/org/reviewer".match(sandbox.ORG_NODE_DETAIL_RE), "illegal org_node (not in the 3-way enum) must not match"); ++ check(!"#/tasks/t1/org/".match(sandbox.ORG_NODE_DETAIL_RE), "missing org_node segment must not match"); ++ check(!"#/tasks/t1".match(sandbox.ORG_NODE_DETAIL_RE), "plain #/tasks/:id (no /org/ suffix) must not match this regex"); ++} ++ ++// ---- 3. getLatestRunForOrgNode: latest-wins, no cross-run merging ---- ++{ ++ const task = { ++ work_graph_runs: [ ++ { run_id: "run-old", org_node: "planner", status: "done", started_at: "2026-08-01 00:00:00" }, ++ { run_id: "run-new", org_node: "planner", status: "done", started_at: "2026-08-02 00:00:00" }, ++ { run_id: "run-coder", org_node: "coder", status: "running", started_at: "2026-08-02 01:00:00" }, ++ ], ++ }; ++ const latestPlanner = sandbox.getLatestRunForOrgNode(task, "planner"); ++ check(latestPlanner.run_id === "run-new", "must return the array-last (time-latest) run for a repeated org_node, not the first"); ++ ++ const latestCoder = sandbox.getLatestRunForOrgNode(task, "coder"); ++ check(latestCoder.run_id === "run-coder", "single-run org_node resolves to that one run"); ++ ++ const noValidator = sandbox.getLatestRunForOrgNode(task, "validator"); ++ check(noValidator === null, "org_node with zero runs must resolve to null, not throw or return undefined-shaped data"); ++} ++ ++// ---- 4. renderOrgComboPanelContent: empty state + artifact filtering ---- ++{ ++ const emptyTask = { work_graph_runs: [], artifacts: [] }; ++ const emptyHtml = sandbox.renderOrgComboPanelContent(emptyTask, "validator"); ++ check(emptyHtml.includes("尚未开始"), "no-run org_node must render an explicit empty state, not blank/undefined"); ++ ++ const taskWithRun = { ++ work_graph_runs: [ ++ { run_id: "run-1", org_node: "coder", status: "done", graph_manifest: { nodes: [{ node_id: "n1", depends_on: [] }] } }, ++ ], ++ artifacts: [ ++ { artifact_id: "art-1", produced_by_run_id: "run-1", file_path: "src/x.py", artifact_type: "code_diff", validation_status: "passed" }, ++ { artifact_id: "art-2", produced_by_run_id: "run-other", file_path: "src/y.py", artifact_type: "code_diff", validation_status: "passed" }, ++ ], ++ }; ++ const populatedHtml = sandbox.renderOrgComboPanelContent(taskWithRun, "coder"); ++ check(populatedHtml.includes("run-1"), "combo content must name the resolved run_id"); ++ check(populatedHtml.includes('data-artifact-id="art-1"'), "artifact produced by the resolved run must be listed"); ++ check(!populatedHtml.includes('data-artifact-id="art-2"'), "artifact from a different run_id must be filtered out"); ++ ++ const taskNoArtifacts = { ++ work_graph_runs: [{ run_id: "run-2", org_node: "coder", status: "failed", graph_manifest: null }], ++ artifacts: [], ++ }; ++ const noArtifactsHtml = sandbox.renderOrgComboPanelContent(taskNoArtifacts, "coder"); ++ check(noArtifactsHtml.includes("暂无 Artifact"), "run with zero artifacts must show the existing empty-state text, not blank/error"); ++} ++ ++console.log( ++ "frontend/test_org_click_through_smoke.js: all " + assertionCount + " assertions passed" ++);