e9193ae4be
B and D plans were written and executed but never staged. C is new, not yet implemented.
1143 lines
45 KiB
Markdown
1143 lines
45 KiB
Markdown
# Proof Gallery (C) Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** the screenshots QC agents capture become visible in the dashboard, grouped by feature (B's `lane_features` slug) and phase. Ported from Shipyard's real implementation (`~/MyDrive/Projects/ResearchAndDevelopment/AgentWorkflow/dashboard/server/{routes,services}/proof.js`, `src/components/ProofGallery.jsx`, `src/hooks/useProof.js`), adapted from Shipyard's flat `state/laneN/*.json` file model to CCAM's `lane_features` DB rows (Task B, already shipped).
|
|
|
|
**Architecture:** One new library module `server/lib/proof.js` (list/file/delete/link, all containment-checked against the lane's own proof root — never `LANES_ROOT`, since an adopted lane's `cwd` need not live under it). Routes added inline to `server/routes/lanes.js`, grouped with the other `/:id/*` sub-resources, same placement rule B's `/:id/features` used. CLI gains one primitive, `ccam lanes proof-link`, which is never auto-invoked — matching "CCAM does not orchestrate," a session or hook calls it explicitly. The Workspace UI adds a `ProofGallery` panel keyed off the **same `viewedFeatureSlug`** state Task B's picker already introduced — no second picker.
|
|
|
|
**Tech Stack:** Node `fs`/`path` (realpath-based containment, no new dependency), existing Express/route conventions, existing React state from Task B.
|
|
|
|
## Global Constraints
|
|
|
|
- Every applicable source file MUST start with the project's authorship header — verify with `bash .claude/skills/file-headers/scripts/check-headers.sh`.
|
|
- **Path containment is the entire security surface.** Every one of `proofFile`/`deleteProof`/`listProof` resolves + `fs.realpathSync`s + asserts the result is inside the lane's proof root (`base === p || p.startsWith(base + path.sep)`) before any read/write/delete reaches `fs`. No request path — slug, group, file, image name — ever reaches `fs` unresolved. Port Shipyard's `proofFile`/`deleteProof`/`badName` check nearly verbatim; do not "clean up" or abstract this logic, it is the one part of C worth reviewing line-by-line.
|
|
- **`deleteProof` never touches `ticket/`.** Whole-feature delete iterates every subdirectory except `ticket`; group delete refuses `group === "ticket"` outright.
|
|
- **The proof root is `<lane.cwd>/.playwright-mcp/proof`, not anything under `LANES_ROOT`.** An adopted lane's `cwd` can be anywhere the user can read; proof storage follows the lane, same as Shipyard's `.playwright-mcp/proof` living inside the worktree.
|
|
- **Grouping comes from `lane_features` (Task B), not from re-deriving "current" via a branch regex.** Shipyard's `useProof.js` parses `feat/<slug>` out of the git branch because it has no DB; CCAM already has `getFeature`/`active_feature_id` as the source of truth, so that derivation is deliberately dropped, not ported.
|
|
- **`ccam lanes proof-link` is a primitive, not automatic.** It runs `ensureProofLink` once, on request. Nothing in this plan calls it from `feature activate`, `lanes up`, or any hook — a session or a project's own hook script decides when to call it, same reasoning as every other "CCAM does not orchestrate" primitive.
|
|
- `DELETE /api/lanes/:id/proof/:slug` takes `{group?, images?}` in the **JSON body** (`express.json()` is already mounted globally in `server/index.js`), matching Shipyard's `deleteProof(n, req.body || {})` — not query params.
|
|
- Run `npm run test:server` (full suite) and `npm run test:client` (when a task touches `client/`) plus `bash .claude/skills/file-headers/scripts/check-headers.sh` before every commit.
|
|
- Never use `git add -A`. Stage exactly the files each task names.
|
|
|
|
---
|
|
|
|
### Task 1: `server/lib/proof.js` core
|
|
|
|
**Files:**
|
|
- Create: `server/lib/proof.js`
|
|
- Test: `server/__tests__/proof.test.js`
|
|
|
|
**Interfaces:**
|
|
- Produces: `proofRoot(lane)` → `string` (`<lane.cwd>/.playwright-mcp/proof`, NOT realpath'd — the directory may not exist yet); `ensureProofLink(lane)` → `{linked: boolean}` (idempotent; `false` when there was nothing to link, e.g. clone-root `proof/` already the correct symlink or absent); `listProof(laneId)` → `Array<{slug, groups: {[group]: string[]}, ticket_report: string, mtime: number}>` (mtime = latest file mtime under that slug's dir, seconds; sorted most-recently-touched first); `proofFile(laneId, slug, group, file)` → `string | null` (absolute realpath, or null if missing/outside root/wrong extension); `deleteProof(laneId, {slug, group, images} = {})` → `{deleted: number}`, throws `Object.assign(new Error(...), {code: "EBADPATH"})` for a bad slug/group/image name, `{code: "ENOFEATURE"}` when the slug's directory doesn't exist.
|
|
- `listProof` merges in every slug from `lane-features.js`'s `listFeatures(laneId)` that has no proof directory yet (so a just-activated feature with zero screenshots still appears, with empty `groups`) — the CCAM-native replacement for Shipyard's `_pending`/`_active` special-casing, since B's `lane_features` row already exists the moment `activate` is called.
|
|
|
|
**Step 1: Write the failing test**
|
|
|
|
Create `server/__tests__/proof.test.js`:
|
|
|
|
```js
|
|
/**
|
|
* @file Tests for server/lib/proof.js: proof-directory listing, containment-
|
|
* checked file resolution, delete granularities, and the clone-root symlink
|
|
* convergence (ensureProofLink).
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const os = require("node:os");
|
|
const path = require("node:path");
|
|
const fs = require("node:fs");
|
|
|
|
const SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-proof-"));
|
|
process.env.DASHBOARD_DB_PATH = path.join(SUITE_ROOT, "dashboard.db");
|
|
process.env.LANES_ROOT = path.join(SUITE_ROOT, "lanes");
|
|
|
|
const { describe, it, after } = require("node:test");
|
|
const assert = require("node:assert/strict");
|
|
|
|
const lanesLib = require("../lib/lanes");
|
|
const features = require("../lib/lane-features");
|
|
const proof = require("../lib/proof");
|
|
|
|
after(() => fs.rmSync(SUITE_ROOT, { recursive: true, force: true }));
|
|
|
|
let laneSeq = 0;
|
|
function makeLane() {
|
|
laneSeq += 1;
|
|
const cwd = path.join(SUITE_ROOT, `lane-cwd-${laneSeq}`);
|
|
fs.mkdirSync(cwd, { recursive: true });
|
|
return lanesLib.createLane({ title: `lane ${laneSeq}`, cwd, kind: "managed" });
|
|
}
|
|
|
|
function writeShot(lane, slug, group, name) {
|
|
const dir = path.join(lane.cwd, ".playwright-mcp", "proof", slug, group);
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
fs.writeFileSync(path.join(dir, name), "fake-png");
|
|
}
|
|
|
|
describe("listProof", () => {
|
|
it("groups screenshots by slug then phase group", () => {
|
|
const lane = makeLane();
|
|
writeShot(lane, "one", "qc-local", "a.png");
|
|
writeShot(lane, "one", "qc-local", "b.png");
|
|
writeShot(lane, "one", "qc-dev", "c.png");
|
|
const list = proof.listProof(lane.id);
|
|
const one = list.find((f) => f.slug === "one");
|
|
assert.deepEqual(one.groups["qc-local"].sort(), ["a.png", "b.png"]);
|
|
assert.deepEqual(one.groups["qc-dev"], ["c.png"]);
|
|
});
|
|
|
|
it("surfaces the ticket report path when present", () => {
|
|
const lane = makeLane();
|
|
const ticketDir = path.join(lane.cwd, ".playwright-mcp", "proof", "one", "ticket");
|
|
fs.mkdirSync(ticketDir, { recursive: true });
|
|
fs.writeFileSync(path.join(ticketDir, "REPORT.html"), "<html></html>");
|
|
const one = proof.listProof(lane.id).find((f) => f.slug === "one");
|
|
assert.equal(one.ticket_report, "one/ticket/REPORT.html");
|
|
});
|
|
|
|
it("never lists the ticket dir as a screenshot group", () => {
|
|
const lane = makeLane();
|
|
const ticketDir = path.join(lane.cwd, ".playwright-mcp", "proof", "one", "ticket");
|
|
fs.mkdirSync(ticketDir, { recursive: true });
|
|
fs.writeFileSync(path.join(ticketDir, "REPORT.html"), "<html></html>");
|
|
const one = proof.listProof(lane.id).find((f) => f.slug === "one");
|
|
assert.equal(one.groups.ticket, undefined);
|
|
});
|
|
|
|
it("surfaces an activated feature with zero screenshots yet, groups empty", () => {
|
|
const lane = makeLane();
|
|
features.activateFeature(lane.id, "brand-new");
|
|
const list = proof.listProof(lane.id);
|
|
const f = list.find((x) => x.slug === "brand-new");
|
|
assert.ok(f);
|
|
assert.deepEqual(f.groups, {});
|
|
});
|
|
});
|
|
|
|
describe("proofFile", () => {
|
|
it("resolves a real screenshot inside the proof root", () => {
|
|
const lane = makeLane();
|
|
writeShot(lane, "one", "qc-local", "a.png");
|
|
const p = proof.proofFile(lane.id, "one", "qc-local", "a.png");
|
|
assert.ok(p);
|
|
assert.ok(fs.existsSync(p));
|
|
});
|
|
|
|
it("refuses a traversal attempt via ..", () => {
|
|
const lane = makeLane();
|
|
writeShot(lane, "one", "qc-local", "a.png");
|
|
assert.equal(proof.proofFile(lane.id, "one", "..", "..%2fsecret"), null);
|
|
assert.equal(proof.proofFile(lane.id, "../../etc", "x", "passwd"), null);
|
|
});
|
|
|
|
it("refuses a symlink that escapes the proof root", () => {
|
|
const lane = makeLane();
|
|
const dir = path.join(lane.cwd, ".playwright-mcp", "proof", "one", "qc-local");
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
const outside = path.join(SUITE_ROOT, "outside-secret.png");
|
|
fs.writeFileSync(outside, "secret");
|
|
fs.symlinkSync(outside, path.join(dir, "escape.png"));
|
|
assert.equal(proof.proofFile(lane.id, "one", "qc-local", "escape.png"), null);
|
|
});
|
|
|
|
it("refuses a non-image, non-html extension", () => {
|
|
const lane = makeLane();
|
|
const dir = path.join(lane.cwd, ".playwright-mcp", "proof", "one", "qc-local");
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
fs.writeFileSync(path.join(dir, "a.txt"), "not an image");
|
|
assert.equal(proof.proofFile(lane.id, "one", "qc-local", "a.txt"), null);
|
|
});
|
|
|
|
it("returns null for a missing lane", () => {
|
|
assert.equal(proof.proofFile(999999, "one", "qc-local", "a.png"), null);
|
|
});
|
|
});
|
|
|
|
describe("deleteProof", () => {
|
|
it("deletes listed images and prunes the group dir if it becomes empty", () => {
|
|
const lane = makeLane();
|
|
writeShot(lane, "one", "qc-local", "a.png");
|
|
const result = proof.deleteProof(lane.id, { slug: "one", group: "qc-local", images: ["a.png"] });
|
|
assert.equal(result.deleted, 1);
|
|
assert.equal(
|
|
fs.existsSync(path.join(lane.cwd, ".playwright-mcp", "proof", "one", "qc-local")),
|
|
false
|
|
);
|
|
});
|
|
|
|
it("deletes a whole group, leaves siblings intact", () => {
|
|
const lane = makeLane();
|
|
writeShot(lane, "one", "qc-local", "a.png");
|
|
writeShot(lane, "one", "qc-dev", "b.png");
|
|
proof.deleteProof(lane.id, { slug: "one", group: "qc-local" });
|
|
assert.equal(
|
|
fs.existsSync(path.join(lane.cwd, ".playwright-mcp", "proof", "one", "qc-local")),
|
|
false
|
|
);
|
|
assert.ok(
|
|
fs.existsSync(path.join(lane.cwd, ".playwright-mcp", "proof", "one", "qc-dev", "b.png"))
|
|
);
|
|
});
|
|
|
|
it("refuses to delete the ticket group by name", () => {
|
|
const lane = makeLane();
|
|
const ticketDir = path.join(lane.cwd, ".playwright-mcp", "proof", "one", "ticket");
|
|
fs.mkdirSync(ticketDir, { recursive: true });
|
|
fs.writeFileSync(path.join(ticketDir, "REPORT.html"), "<html></html>");
|
|
assert.throws(
|
|
() => proof.deleteProof(lane.id, { slug: "one", group: "ticket" }),
|
|
(err) => err.code === "EBADPATH"
|
|
);
|
|
});
|
|
|
|
it("whole-feature delete removes every group but keeps ticket/", () => {
|
|
const lane = makeLane();
|
|
writeShot(lane, "one", "qc-local", "a.png");
|
|
const ticketDir = path.join(lane.cwd, ".playwright-mcp", "proof", "one", "ticket");
|
|
fs.mkdirSync(ticketDir, { recursive: true });
|
|
fs.writeFileSync(path.join(ticketDir, "REPORT.html"), "<html></html>");
|
|
proof.deleteProof(lane.id, { slug: "one" });
|
|
assert.equal(
|
|
fs.existsSync(path.join(lane.cwd, ".playwright-mcp", "proof", "one", "qc-local")),
|
|
false
|
|
);
|
|
assert.ok(fs.existsSync(path.join(ticketDir, "REPORT.html")));
|
|
});
|
|
|
|
it("refuses a slug containing a path separator or ..", () => {
|
|
const lane = makeLane();
|
|
assert.throws(
|
|
() => proof.deleteProof(lane.id, { slug: "../escape" }),
|
|
(err) => err.code === "EBADPATH"
|
|
);
|
|
});
|
|
|
|
it("throws ENOFEATURE for a slug with no proof directory", () => {
|
|
const lane = makeLane();
|
|
assert.throws(
|
|
() => proof.deleteProof(lane.id, { slug: "never-had-proof" }),
|
|
(err) => err.code === "ENOFEATURE"
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("ensureProofLink", () => {
|
|
it("creates the symlink when neither clone-root proof/ nor the canonical dir exist yet", () => {
|
|
const lane = makeLane();
|
|
const result = proof.ensureProofLink(lanesLib.getLane(lane.id));
|
|
assert.equal(result.linked, true);
|
|
const strayPath = path.join(lane.cwd, "proof");
|
|
assert.equal(fs.lstatSync(strayPath).isSymbolicLink(), true);
|
|
assert.equal(fs.readlinkSync(strayPath), path.join(".playwright-mcp", "proof"));
|
|
});
|
|
|
|
it("merges a pre-existing real proof/ dir into the canonical root, then links it", () => {
|
|
const lane = makeLane();
|
|
const strayDir = path.join(lane.cwd, "proof", "stranded", "qc-local");
|
|
fs.mkdirSync(strayDir, { recursive: true });
|
|
fs.writeFileSync(path.join(strayDir, "old.png"), "stranded shot");
|
|
|
|
proof.ensureProofLink(lanesLib.getLane(lane.id));
|
|
|
|
const merged = path.join(
|
|
lane.cwd,
|
|
".playwright-mcp",
|
|
"proof",
|
|
"stranded",
|
|
"qc-local",
|
|
"old.png"
|
|
);
|
|
assert.ok(fs.existsSync(merged));
|
|
assert.equal(fs.lstatSync(path.join(lane.cwd, "proof")).isSymbolicLink(), true);
|
|
});
|
|
|
|
it("is a no-op (linked: false) when the symlink already points at the right place", () => {
|
|
const lane = makeLane();
|
|
proof.ensureProofLink(lanesLib.getLane(lane.id));
|
|
const second = proof.ensureProofLink(lanesLib.getLane(lane.id));
|
|
assert.equal(second.linked, false);
|
|
});
|
|
});
|
|
```
|
|
|
|
**Step 2: Run test to verify it fails**
|
|
|
|
Run: `node --test server/__tests__/proof.test.js`
|
|
Expected: FAIL — `Cannot find module '../lib/proof'`
|
|
|
|
**Step 3: Write `server/lib/proof.js`**
|
|
|
|
Read `server/lib/lane-features.js` first (already committed) to match its hydration/error-code conventions, and Shipyard's `dashboard/server/services/proof.js` (`~/MyDrive/Projects/ResearchAndDevelopment/AgentWorkflow/dashboard/server/services/proof.js`) for the exact containment/delete logic being ported — line up `badName`, `inBase`, `proofFile`, `deleteProof` against it directly rather than re-deriving them from scratch.
|
|
|
|
```js
|
|
/**
|
|
* @file Proof gallery (C): the screenshots a QC agent captures live at
|
|
* `<lane.cwd>/.playwright-mcp/proof/<slug>/<phase-group>/*.png` (plus a
|
|
* `ticket/REPORT.html`), grouped by feature slug — the same slug Task B's
|
|
* `lane_features` already tracks. Ported from Shipyard's
|
|
* `dashboard/server/services/proof.js`; the "current feature" special-casing
|
|
* that file needed (it has no DB, so it parsed a branch regex) is dropped —
|
|
* CCAM's `lane_features` row already IS that source of truth.
|
|
*
|
|
* Path containment is the entire security surface here: every path this
|
|
* module touches is resolved, realpath'd, and checked to be inside the
|
|
* lane's own proof root before any read/write/delete reaches `fs`. Ported
|
|
* near-verbatim from Shipyard rather than "cleaned up" — this is the one part
|
|
* of C worth reviewing line-by-line, not abstracting.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const fs = require("node:fs");
|
|
const path = require("node:path");
|
|
|
|
const lanesLib = require("./lanes");
|
|
const laneFeatures = require("./lane-features");
|
|
|
|
const IMAGE_RE = /\.(png|jpg|jpeg)$/i;
|
|
const SERVEABLE_RE = /\.(png|jpg|jpeg|html)$/i;
|
|
|
|
/** Where a lane's proof lives — inside its OWN cwd, not LANES_ROOT (an
|
|
* adopted lane's cwd need not be under LANES_ROOT at all). */
|
|
function proofRoot(lane) {
|
|
return path.join(lane.cwd, ".playwright-mcp", "proof");
|
|
}
|
|
|
|
const badName = (s) =>
|
|
!s || typeof s !== "string" || s.includes("/") || s.includes("\\") || s.includes("..");
|
|
|
|
function countFiles(dir) {
|
|
let c = 0;
|
|
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
if (e.isDirectory()) c += countFiles(path.join(dir, e.name));
|
|
else c++;
|
|
}
|
|
return c;
|
|
}
|
|
|
|
function latestMtime(dir) {
|
|
let mt = 0;
|
|
const walk = (d) => {
|
|
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
|
|
const full = path.join(d, e.name);
|
|
if (e.isDirectory()) walk(full);
|
|
else {
|
|
const s = fs.statSync(full);
|
|
if (s.mtimeMs > mt) mt = s.mtimeMs;
|
|
}
|
|
}
|
|
};
|
|
try {
|
|
walk(dir);
|
|
} catch {
|
|
/* best effort */
|
|
}
|
|
return mt / 1000;
|
|
}
|
|
|
|
/**
|
|
* Every feature slug with proof (or an activated-but-not-yet-shot feature
|
|
* from `lane_features`), most recently touched first.
|
|
*
|
|
* @param {number} laneId
|
|
* @returns {Array<{slug: string, groups: object, ticket_report: string, mtime: number}>}
|
|
*/
|
|
function listProof(laneId) {
|
|
const lane = lanesLib.getLane(laneId);
|
|
if (!lane) return [];
|
|
const base = proofRoot(lane);
|
|
const feats = [];
|
|
|
|
if (fs.existsSync(base) && fs.statSync(base).isDirectory()) {
|
|
for (const slug of fs.readdirSync(base).sort()) {
|
|
const d = path.join(base, slug);
|
|
try {
|
|
if (!fs.statSync(d).isDirectory()) continue;
|
|
} catch {
|
|
continue;
|
|
}
|
|
|
|
const groups = {};
|
|
for (const grp of fs.readdirSync(d).sort()) {
|
|
if (grp === "ticket") continue;
|
|
const g = path.join(d, grp);
|
|
try {
|
|
if (!fs.statSync(g).isDirectory()) continue;
|
|
} catch {
|
|
continue;
|
|
}
|
|
const imgs = fs.readdirSync(g).filter((f) => IMAGE_RE.test(f)).sort();
|
|
if (imgs.length) groups[grp] = imgs;
|
|
}
|
|
|
|
let ticketReport = "";
|
|
if (fs.existsSync(path.join(d, "ticket", "REPORT.html"))) {
|
|
ticketReport = `${slug}/ticket/REPORT.html`;
|
|
}
|
|
|
|
feats.push({ slug, groups, ticket_report: ticketReport, mtime: latestMtime(d) });
|
|
}
|
|
}
|
|
|
|
// Surface an activated feature with no proof dir yet.
|
|
const have = new Set(feats.map((f) => f.slug));
|
|
for (const f of laneFeatures.listFeatures(laneId)) {
|
|
if (!have.has(f.slug)) {
|
|
feats.push({ slug: f.slug, groups: {}, ticket_report: "", mtime: 0 });
|
|
}
|
|
}
|
|
|
|
feats.sort((a, b) => (b.mtime || 0) - (a.mtime || 0));
|
|
return feats;
|
|
}
|
|
|
|
/**
|
|
* Resolve one proof file, containment-checked. Returns the absolute realpath
|
|
* or null on any failure — missing lane, missing file, traversal, symlink
|
|
* escape, or wrong extension.
|
|
*/
|
|
function proofFile(laneId, slug, group, file) {
|
|
const lane = lanesLib.getLane(laneId);
|
|
if (!lane) return null;
|
|
|
|
let base;
|
|
try {
|
|
base = fs.realpathSync(proofRoot(lane));
|
|
} catch {
|
|
return null;
|
|
}
|
|
|
|
let p;
|
|
try {
|
|
p = fs.realpathSync(path.join(base, slug, group, file));
|
|
} catch {
|
|
return null;
|
|
}
|
|
|
|
if (
|
|
(p === base || p.startsWith(base + path.sep)) &&
|
|
fs.existsSync(p) &&
|
|
fs.statSync(p).isFile() &&
|
|
SERVEABLE_RE.test(p)
|
|
) {
|
|
return p;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Delete proof screenshots at one of three granularities:
|
|
* { slug, group, images: [...] } -> unlink those files, prune the group dir if now empty
|
|
* { slug, group } -> remove the whole group dir
|
|
* { slug } -> remove every group dir EXCEPT ticket/
|
|
* Never touches `ticket/`, never reaches outside the lane's proof root.
|
|
*
|
|
* @returns {{deleted: number}}
|
|
*/
|
|
function deleteProof(laneId, { slug, group, images } = {}) {
|
|
const lane = lanesLib.getLane(laneId);
|
|
if (!lane) throw Object.assign(new Error(`no lane ${laneId}`), { code: "ENOLANE" });
|
|
if (badName(slug)) throw Object.assign(new Error("invalid slug"), { code: "EBADPATH" });
|
|
|
|
let base;
|
|
try {
|
|
base = fs.realpathSync(proofRoot(lane));
|
|
} catch {
|
|
throw Object.assign(new Error("no proof for this lane"), { code: "ENOFEATURE" });
|
|
}
|
|
const inBase = (p) => p === base || p.startsWith(base + path.sep);
|
|
|
|
let featDir;
|
|
try {
|
|
featDir = fs.realpathSync(path.join(base, slug));
|
|
} catch {
|
|
throw Object.assign(new Error("feature not found"), { code: "ENOFEATURE" });
|
|
}
|
|
if (!inBase(featDir) || !fs.statSync(featDir).isDirectory()) {
|
|
throw Object.assign(new Error("invalid feature dir"), { code: "EBADPATH" });
|
|
}
|
|
|
|
let deleted = 0;
|
|
|
|
if (group !== undefined) {
|
|
if (badName(group) || group === "ticket") {
|
|
throw Object.assign(new Error("invalid group"), { code: "EBADPATH" });
|
|
}
|
|
let grpDir;
|
|
try {
|
|
grpDir = fs.realpathSync(path.join(featDir, group));
|
|
} catch {
|
|
throw Object.assign(new Error("group not found"), { code: "ENOFEATURE" });
|
|
}
|
|
if (!inBase(grpDir) || !fs.statSync(grpDir).isDirectory()) {
|
|
throw Object.assign(new Error("invalid group dir"), { code: "EBADPATH" });
|
|
}
|
|
|
|
if (Array.isArray(images) && images.length) {
|
|
for (const img of images) {
|
|
if (badName(img) || !IMAGE_RE.test(img)) {
|
|
throw Object.assign(new Error("invalid image"), { code: "EBADPATH" });
|
|
}
|
|
let f;
|
|
try {
|
|
f = fs.realpathSync(path.join(grpDir, img));
|
|
} catch {
|
|
continue;
|
|
}
|
|
if (inBase(f) && fs.statSync(f).isFile() && IMAGE_RE.test(f)) {
|
|
fs.unlinkSync(f);
|
|
deleted++;
|
|
}
|
|
}
|
|
try {
|
|
if (!fs.readdirSync(grpDir).length) fs.rmdirSync(grpDir);
|
|
} catch {
|
|
/* ok */
|
|
}
|
|
} else {
|
|
deleted = countFiles(grpDir);
|
|
fs.rmSync(grpDir, { recursive: true, force: true });
|
|
}
|
|
} else {
|
|
for (const e of fs.readdirSync(featDir, { withFileTypes: true })) {
|
|
if (!e.isDirectory() || e.name === "ticket") continue;
|
|
const g = path.join(featDir, e.name);
|
|
deleted += countFiles(g);
|
|
fs.rmSync(g, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
return { deleted };
|
|
}
|
|
|
|
/**
|
|
* Port of Shipyard's `ensure_proof_link`: converge the clone-root `proof/`
|
|
* onto `.playwright-mcp/proof` so every proof write lands in one place
|
|
* regardless of an MCP's `--output-dir` convention. A pre-existing REAL
|
|
* `proof/` dir is merged into the canonical root first (no clobber), then
|
|
* replaced with a relative symlink. Idempotent — a no-op when the symlink
|
|
* already points at the right place.
|
|
*
|
|
* Never auto-invoked by this codebase; exposed only as the explicit
|
|
* `ccam lanes proof-link` primitive.
|
|
*
|
|
* @param {object} lane - A hydrated lane row (must have `cwd`).
|
|
* @returns {{linked: boolean}} `linked: false` when nothing changed.
|
|
*/
|
|
function ensureProofLink(lane) {
|
|
const canon = proofRoot(lane);
|
|
const stray = path.join(lane.cwd, "proof");
|
|
const relTarget = path.join(".playwright-mcp", "proof");
|
|
|
|
let strayStat = null;
|
|
try {
|
|
strayStat = fs.lstatSync(stray);
|
|
} catch {
|
|
/* doesn't exist yet */
|
|
}
|
|
|
|
if (strayStat && strayStat.isSymbolicLink()) {
|
|
if (fs.readlinkSync(stray) === relTarget) return { linked: false };
|
|
fs.rmSync(stray, { force: true });
|
|
} else if (strayStat && strayStat.isDirectory()) {
|
|
fs.mkdirSync(canon, { recursive: true });
|
|
mergeDirInto(stray, canon);
|
|
fs.rmSync(stray, { recursive: true, force: true });
|
|
} else if (strayStat) {
|
|
// A stray file named `proof` — refuse to clobber; leave it alone.
|
|
return { linked: false };
|
|
}
|
|
|
|
fs.mkdirSync(canon, { recursive: true });
|
|
fs.symlinkSync(relTarget, stray);
|
|
return { linked: true };
|
|
}
|
|
|
|
/** Recursively copy `src`'s contents into `dest`, never overwriting an
|
|
* existing file at the destination (no-clobber merge). */
|
|
function mergeDirInto(src, dest) {
|
|
for (const e of fs.readdirSync(src, { withFileTypes: true })) {
|
|
const s = path.join(src, e.name);
|
|
const d = path.join(dest, e.name);
|
|
if (e.isDirectory()) {
|
|
fs.mkdirSync(d, { recursive: true });
|
|
mergeDirInto(s, d);
|
|
} else if (!fs.existsSync(d)) {
|
|
fs.copyFileSync(s, d);
|
|
}
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
proofRoot,
|
|
listProof,
|
|
proofFile,
|
|
deleteProof,
|
|
ensureProofLink,
|
|
};
|
|
```
|
|
|
|
**Step 4: Run test to verify it passes**
|
|
|
|
Run: `node --test server/__tests__/proof.test.js`
|
|
Expected: PASS (17 tests)
|
|
|
|
**Step 5: Run the full suite and header audit**
|
|
|
|
Run: `npm run test:server && bash .claude/skills/file-headers/scripts/check-headers.sh`
|
|
|
|
**Step 6: Commit**
|
|
|
|
```bash
|
|
git add server/lib/proof.js server/__tests__/proof.test.js
|
|
git commit -m "feat(lanes): add proof gallery core (list/file/delete/link) (C)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: `GET/DELETE /api/lanes/:id/proof…` + `POST /:id/proof-link` routes
|
|
|
|
**Files:**
|
|
- Modify: `server/routes/lanes.js`
|
|
- Test: `server/__tests__/proof-api.test.js`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `server/lib/proof.js`'s `listProof`, `proofFile`, `deleteProof`, `ensureProofLink` (Task 1, already committed).
|
|
|
|
Routes (register near the other `/:id/*` sub-resources — after `/:id/features/...`, before `/:id/git`, same file):
|
|
```
|
|
GET /api/lanes/:id/proof -> { features: [ProofFeature, ...] }
|
|
GET /api/lanes/:id/proof/:slug/:group/:file -> image or REPORT.html (Content-Type by extension), 404 if not found/contained
|
|
DELETE /api/lanes/:id/proof/:slug body: { group?: string, images?: string[] }
|
|
-> { deleted: N }
|
|
-> 404 { error: { code: "ENOFEATURE", message } }
|
|
-> 400 { error: { code: "EBADPATH", message } }
|
|
POST /api/lanes/:id/proof-link -> { linked: boolean }
|
|
```
|
|
`ProofFeature` = `{slug, groups: {[group]: string[]}, ticket_report: string, mtime: number}`, exactly `listProof`'s shape — no extra computed fields needed (unlike B's `featurePayload`, there's no pipeline to compute here).
|
|
|
|
**Step 1: Write the failing test**
|
|
|
|
Create `server/__tests__/proof-api.test.js`:
|
|
|
|
```js
|
|
/**
|
|
* @file Tests for GET/DELETE /api/lanes/:id/proof… and POST
|
|
* /api/lanes/:id/proof-link — the HTTP surface over server/lib/proof.js.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const os = require("node:os");
|
|
const path = require("node:path");
|
|
const fs = require("node:fs");
|
|
const http = require("node:http");
|
|
|
|
const SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-proof-api-"));
|
|
process.env.DASHBOARD_DB_PATH = path.join(SUITE_ROOT, "dashboard.db");
|
|
process.env.LANES_ROOT = path.join(SUITE_ROOT, "lanes");
|
|
|
|
const { describe, it, before, after } = require("node:test");
|
|
const assert = require("node:assert/strict");
|
|
|
|
const { createApp } = require("../index");
|
|
const lanesLib = require("../lib/lanes");
|
|
|
|
let server;
|
|
let PORT;
|
|
|
|
before(async () => {
|
|
const app = createApp();
|
|
server = http.createServer(app);
|
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
PORT = server.address().port;
|
|
});
|
|
after(async () => {
|
|
await new Promise((resolve) => server.close(resolve));
|
|
fs.rmSync(SUITE_ROOT, { recursive: true, force: true });
|
|
});
|
|
|
|
function request(method, urlPath, body) {
|
|
return new Promise((resolve, reject) => {
|
|
const data = body ? JSON.stringify(body) : null;
|
|
const req = http.request(
|
|
{
|
|
method,
|
|
hostname: "127.0.0.1",
|
|
port: PORT,
|
|
path: urlPath,
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
...(data ? { "Content-Length": Buffer.byteLength(data) } : {}),
|
|
},
|
|
},
|
|
(res) => {
|
|
let raw = "";
|
|
res.on("data", (chunk) => (raw += chunk));
|
|
res.on("end", () => {
|
|
let json = null;
|
|
try {
|
|
json = JSON.parse(raw);
|
|
} catch {
|
|
/* binary body (image) is fine */
|
|
}
|
|
resolve({ status: res.statusCode, headers: res.headers, body: json, raw });
|
|
});
|
|
}
|
|
);
|
|
req.on("error", reject);
|
|
if (data) req.write(data);
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
let laneSeq = 0;
|
|
function makeLane() {
|
|
laneSeq += 1;
|
|
const cwd = path.join(SUITE_ROOT, `lane-cwd-${laneSeq}`);
|
|
fs.mkdirSync(cwd, { recursive: true });
|
|
return lanesLib.createLane({ title: `lane ${laneSeq}`, cwd, kind: "managed" });
|
|
}
|
|
|
|
function writeShot(lane, slug, group, name) {
|
|
const dir = path.join(lane.cwd, ".playwright-mcp", "proof", slug, group);
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
fs.writeFileSync(path.join(dir, name), "fake-png-bytes");
|
|
}
|
|
|
|
describe("GET /api/lanes/:id/proof", () => {
|
|
it("lists grouped screenshots", async () => {
|
|
const lane = makeLane();
|
|
writeShot(lane, "one", "qc-local", "a.png");
|
|
const res = await request("GET", `/api/lanes/${lane.id}/proof`);
|
|
assert.equal(res.status, 200);
|
|
const one = res.body.features.find((f) => f.slug === "one");
|
|
assert.deepEqual(one.groups["qc-local"], ["a.png"]);
|
|
});
|
|
});
|
|
|
|
describe("GET /api/lanes/:id/proof/:slug/:group/:file", () => {
|
|
it("serves an existing screenshot", async () => {
|
|
const lane = makeLane();
|
|
writeShot(lane, "one", "qc-local", "a.png");
|
|
const res = await request("GET", `/api/lanes/${lane.id}/proof/one/qc-local/a.png`);
|
|
assert.equal(res.status, 200);
|
|
assert.equal(res.raw, "fake-png-bytes");
|
|
});
|
|
|
|
it("404s a traversal attempt", async () => {
|
|
const lane = makeLane();
|
|
writeShot(lane, "one", "qc-local", "a.png");
|
|
const res = await request(
|
|
"GET",
|
|
`/api/lanes/${lane.id}/proof/one/qc-local/${encodeURIComponent("../../../etc/passwd")}`
|
|
);
|
|
assert.equal(res.status, 404);
|
|
});
|
|
});
|
|
|
|
describe("DELETE /api/lanes/:id/proof/:slug", () => {
|
|
it("deletes listed images", async () => {
|
|
const lane = makeLane();
|
|
writeShot(lane, "one", "qc-local", "a.png");
|
|
const res = await request("DELETE", `/api/lanes/${lane.id}/proof/one`, {
|
|
group: "qc-local",
|
|
images: ["a.png"],
|
|
});
|
|
assert.equal(res.status, 200);
|
|
assert.equal(res.body.deleted, 1);
|
|
});
|
|
|
|
it("400s EBADPATH for a slug containing ..", async () => {
|
|
const lane = makeLane();
|
|
const res = await request(
|
|
"DELETE",
|
|
`/api/lanes/${lane.id}/proof/${encodeURIComponent("../escape")}`,
|
|
{}
|
|
);
|
|
assert.equal(res.status, 400);
|
|
assert.equal(res.body.error.code, "EBADPATH");
|
|
});
|
|
|
|
it("404s ENOFEATURE for a slug with no proof dir", async () => {
|
|
const lane = makeLane();
|
|
const res = await request("DELETE", `/api/lanes/${lane.id}/proof/never-had-proof`, {});
|
|
assert.equal(res.status, 404);
|
|
assert.equal(res.body.error.code, "ENOFEATURE");
|
|
});
|
|
});
|
|
|
|
describe("POST /api/lanes/:id/proof-link", () => {
|
|
it("creates the symlink and reports linked: true", async () => {
|
|
const lane = makeLane();
|
|
const res = await request("POST", `/api/lanes/${lane.id}/proof-link`);
|
|
assert.equal(res.status, 200);
|
|
assert.equal(res.body.linked, true);
|
|
assert.equal(
|
|
fs.lstatSync(path.join(lane.cwd, "proof")).isSymbolicLink(),
|
|
true
|
|
);
|
|
});
|
|
});
|
|
```
|
|
|
|
**Step 2: Run test to verify it fails**
|
|
|
|
Run: `node --test server/__tests__/proof-api.test.js`
|
|
Expected: FAIL — 404s across the board (routes not registered).
|
|
|
|
**Step 3: Add the routes**
|
|
|
|
In `server/routes/lanes.js`, add near the top imports:
|
|
|
|
```js
|
|
const proofLib = require("../lib/proof");
|
|
```
|
|
|
|
Add the routes right after the `/:id/features/activate` route (search for that, added in Task B) and before `/:id/git`:
|
|
|
|
```js
|
|
router.get("/:id/proof", (req, res) => {
|
|
const lane = lanesLib.getLane(req.params.id);
|
|
if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
|
|
res.json({ features: proofLib.listProof(lane.id) });
|
|
});
|
|
|
|
router.get("/:id/proof/:slug/:group/:file", (req, res) => {
|
|
const lane = lanesLib.getLane(req.params.id);
|
|
if (!lane) return res.status(404).send("not found");
|
|
const p = proofLib.proofFile(lane.id, req.params.slug, req.params.group, req.params.file);
|
|
if (!p) return res.status(404).send("not found");
|
|
const ext = path.extname(p).toLowerCase();
|
|
const contentType =
|
|
ext === ".html" ? "text/html; charset=utf-8" : ext === ".png" ? "image/png" : "image/jpeg";
|
|
res.type(contentType).sendFile(p);
|
|
});
|
|
|
|
router.delete("/:id/proof/:slug", sameOriginGuard, (req, res) => {
|
|
const lane = lanesLib.getLane(req.params.id);
|
|
if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
|
|
try {
|
|
const result = proofLib.deleteProof(lane.id, {
|
|
slug: req.params.slug,
|
|
group: req.body?.group,
|
|
images: req.body?.images,
|
|
});
|
|
res.json(result);
|
|
} catch (err) {
|
|
if (err.code === "ENOFEATURE") {
|
|
return res.status(404).json({ error: { code: err.code, message: err.message } });
|
|
}
|
|
if (err.code === "EBADPATH") {
|
|
return res.status(400).json({ error: { code: err.code, message: err.message } });
|
|
}
|
|
res.status(500).json({ error: { code: err.code, message: err.message } });
|
|
}
|
|
});
|
|
|
|
router.post("/:id/proof-link", sameOriginGuard, (req, res) => {
|
|
const lane = lanesLib.getLane(req.params.id);
|
|
if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
|
|
res.json(proofLib.ensureProofLink(lane));
|
|
});
|
|
```
|
|
|
|
`path` (node:path) is already imported at the top of `server/routes/lanes.js` for the `/worktree` route — confirm with `grep -n "require(\"node:path\")\|require('path')" server/routes/lanes.js` before adding a second import.
|
|
|
|
**Step 4: Run test to verify it passes**
|
|
|
|
Run: `node --test server/__tests__/proof-api.test.js`
|
|
Expected: PASS (7 tests)
|
|
|
|
**Step 5: Run the full suite and header audit**
|
|
|
|
Run: `npm run test:server && bash .claude/skills/file-headers/scripts/check-headers.sh`
|
|
|
|
**Step 6: Commit**
|
|
|
|
```bash
|
|
git add server/routes/lanes.js server/__tests__/proof-api.test.js
|
|
git commit -m "feat(lanes): expose GET/DELETE /api/lanes/:id/proof + proof-link over proof.js (C)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: `ccam lanes proof-link` CLI
|
|
|
|
**Files:**
|
|
- Modify: `bin/ccam.js`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `POST /api/lanes/:id/proof-link` (Task 2, already committed) via the existing `post` helper.
|
|
- Consumes: `resolveLaneArg` (already defined in `bin/ccam.js`).
|
|
|
|
**Step 1: Add the command implementation**
|
|
|
|
In `bin/ccam.js`, near `cmdFeatureShow` (search for that function, added in Task B), add:
|
|
|
|
```js
|
|
/** `ccam lanes proof-link [<id>] [--cwd path]` — converge the clone-root
|
|
* `proof/` onto `.playwright-mcp/proof` (idempotent). Never run automatically
|
|
* by anything else in this codebase; a session or hook calls it explicitly. */
|
|
async function cmdLanesProofLink(args) {
|
|
const resolved = await resolveLaneArg(args);
|
|
if (!resolved) return;
|
|
const { linked } = await post(`/api/lanes/${resolved.laneId}/proof-link`);
|
|
console.log(
|
|
linked
|
|
? `${c.green("✔")} linked proof/ -> .playwright-mcp/proof`
|
|
: "proof/ already linked, nothing to do"
|
|
);
|
|
}
|
|
```
|
|
|
|
**Step 2: Wire the subcommand dispatch**
|
|
|
|
In `bin/ccam.js`'s `case "lanes":` block (search for other `lanes` subcommands like `up`/`down`/`hook`), add:
|
|
|
|
```js
|
|
if (sub === "proof-link") return cmdLanesProofLink(rest.slice(1));
|
|
```
|
|
|
|
**Step 3: Add the help-table entry**
|
|
|
|
In `bin/ccam.js`'s `COMMAND_GROUPS`, in the `"Lanes"` group, add after the `lanes hook` row:
|
|
|
|
```js
|
|
["lanes proof-link", "[<id>]", "Converge clone-root proof/ onto .playwright-mcp/proof (idempotent, never automatic)"],
|
|
```
|
|
|
|
**Step 4: Manual smoke test**
|
|
|
|
```bash
|
|
ccam lanes add --cwd $(pwd) --title "smoke"
|
|
ccam lanes proof-link
|
|
ls -la proof # should be a symlink -> .playwright-mcp/proof
|
|
ccam lanes proof-link # second run: "already linked, nothing to do"
|
|
```
|
|
|
|
**Step 5: Commit**
|
|
|
|
```bash
|
|
git add bin/ccam.js
|
|
git commit -m "feat(lanes): add ccam lanes proof-link CLI (C)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4: Workspace UI — proof gallery panel
|
|
|
|
**Files:**
|
|
- Modify: `client/src/lib/api.ts`
|
|
- Modify: `client/src/lib/types.ts`
|
|
- Modify: `client/src/pages/Workspace.tsx`
|
|
- Modify: `client/src/i18n/locales/en/lanes.json`, `client/src/i18n/locales/vi/lanes.json`
|
|
- Test: `client/src/pages/__tests__/Workspace.test.tsx`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `GET /api/lanes/:id/proof`, `DELETE /api/lanes/:id/proof/:slug` (Task 2, already committed). Image `src` attributes point directly at `GET /api/lanes/:id/proof/:slug/:group/:file` (no client fetch needed for the images themselves — the `<img>` tag hits it).
|
|
- Keys off the **same `viewedFeatureSlug`** state Task B's `Workspace.tsx` already introduced — no second picker, no separate "current feature" derivation.
|
|
|
|
**Read `client/src/lib/api.ts`, `client/src/lib/types.ts`, and Task B's feature-picker code in `client/src/pages/Workspace.tsx` FIRST** (search for `viewedFeatureSlug`) before writing anything below — this task is additive to that state, not a parallel selector. Also skim Shipyard's `ProofGallery.jsx` (`~/MyDrive/Projects/ResearchAndDevelopment/AgentWorkflow/dashboard/src/components/ProofGallery.jsx`) for the rendering shape (grouped thumbnails, `+N` overflow at `CAP = 8`, cleanup-mode multi-select) — port its JSX structure, not its `_active`/`useProof.js` state bookkeeping, which Task B's picker already replaces.
|
|
|
|
**Step 1: Add types and API client methods**
|
|
|
|
In `client/src/lib/types.ts`, add near `LaneFeature` (Task B):
|
|
|
|
```ts
|
|
export interface ProofFeature {
|
|
slug: string;
|
|
groups: Record<string, string[]>;
|
|
ticket_report: string;
|
|
mtime: number;
|
|
}
|
|
```
|
|
|
|
In `client/src/lib/api.ts`, add to the `lanes` API object, alongside `features`:
|
|
|
|
```ts
|
|
proof: {
|
|
list: (laneId: number): Promise<{ features: ProofFeature[] }> =>
|
|
/* the real fetch helper */(`/api/lanes/${laneId}/proof`),
|
|
imageUrl: (laneId: number, slug: string, group: string, file: string): string =>
|
|
`/api/lanes/${laneId}/proof/${encodeURIComponent(slug)}/${encodeURIComponent(group)}/${encodeURIComponent(file)}`,
|
|
delete: (
|
|
laneId: number,
|
|
slug: string,
|
|
body: { group?: string; images?: string[] }
|
|
): Promise<{ deleted: number }> =>
|
|
/* the real fetch helper, DELETE method, JSON body */(`/api/lanes/${laneId}/proof/${encodeURIComponent(slug)}`, body),
|
|
},
|
|
```
|
|
|
|
Match the exact fetch-helper name/signature this file already uses for a DELETE-with-body call (check how an existing DELETE-with-body client method is written, e.g. search for `method: "DELETE"` in this file) rather than guessing at the helper's calling convention.
|
|
|
|
**Step 2: Add the gallery panel to `Workspace.tsx`**
|
|
|
|
Inside the `Workspace` component, alongside Task B's `viewedFeatureSlug`/`features`/`viewedFeature` state, add:
|
|
|
|
```tsx
|
|
const [proofFeatures, setProofFeatures] = useState<ProofFeature[]>([]);
|
|
|
|
useEffect(() => {
|
|
if (!currentLane) {
|
|
setProofFeatures([]);
|
|
return;
|
|
}
|
|
api.lanes.proof
|
|
.list(currentLane.id)
|
|
.then((data) => setProofFeatures(data.features))
|
|
.catch(() => setProofFeatures([]));
|
|
}, [currentLane?.id, viewedFeatureSlug]);
|
|
|
|
const activeSlug = viewedFeatureSlug ?? currentLane?.active_feature_slug ?? null;
|
|
const proofFeature = activeSlug ? proofFeatures.find((f) => f.slug === activeSlug) : null;
|
|
```
|
|
|
|
(`currentLane.active_feature_slug` — verify the exact field name the live lane payload uses for "which feature slug is currently active"; Task B's `featurePayload`/`payload` may expose it under a different key such as `active_feature_id` requiring a lookup into `features` by id instead. Read `payload()` in `server/routes/lanes.js` to confirm before writing this line.)
|
|
|
|
Render the gallery below `PipelineMap` (near Task B's `feature-viewer-banner`):
|
|
|
|
```tsx
|
|
{proofFeature && (Object.keys(proofFeature.groups).length > 0 || proofFeature.ticket_report) && (
|
|
<div data-testid="proof-gallery" className="mt-2">
|
|
{proofFeature.ticket_report && (
|
|
<a
|
|
href={`/api/lanes/${currentLane.id}/proof/${proofFeature.ticket_report}`}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="text-xs text-fg-muted"
|
|
>
|
|
{tLanes("proof.ticketReport")}
|
|
</a>
|
|
)}
|
|
{Object.entries(proofFeature.groups).map(([group, images]) => (
|
|
<div key={group} className="mt-1">
|
|
<span className="text-xs text-fg-muted">{group} · {images.length}</span>
|
|
<div className="flex flex-wrap gap-1">
|
|
{images.slice(0, 8).map((img) => (
|
|
<img
|
|
key={img}
|
|
loading="lazy"
|
|
className="h-16 w-16 rounded object-cover"
|
|
src={api.lanes.proof.imageUrl(currentLane.id, proofFeature.slug, group, img)}
|
|
alt={img}
|
|
/>
|
|
))}
|
|
{images.length > 8 && (
|
|
<span className="text-xs text-fg-muted">+{images.length - 8}</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
```
|
|
|
|
Match this file's real conditional-rendering, className tokens, and `tLanes()` conventions — read the surrounding JSX first rather than transcribing this verbatim. Delete/cleanup-mode UI (multi-select + `api.lanes.proof.delete`) is a `ponytail`-eligible follow-up, not required for this task's verify step below — a viewer with no delete affordance is still a complete, useful C; add delete once the read path is confirmed working, same increment-by-increment approach the rest of this plan takes.
|
|
|
|
**Step 3: Add i18n strings**
|
|
|
|
Add to both `client/src/i18n/locales/en/lanes.json` and `vi/lanes.json`, under the `"features"` group added in Task B (or a sibling `"proof"` group, matching whichever this file's existing convention prefers for a related-but-distinct concern):
|
|
|
|
```json
|
|
"proof": {
|
|
"ticketReport": "Task report"
|
|
}
|
|
```
|
|
|
|
(Vietnamese translation matching this repo's existing tone in `vi/lanes.json`.)
|
|
|
|
**Step 4: Write a test**
|
|
|
|
In `client/src/pages/__tests__/Workspace.test.tsx`, find how Task B's test mocks `api.lanes.features.list`/`.show` and follow the same pattern for `api.lanes.proof.list`:
|
|
|
|
```tsx
|
|
it("shows the proof gallery for the selected feature", async () => {
|
|
vi.mocked(api.lanes.proof.list).mockResolvedValue({
|
|
features: [
|
|
{ slug: "one", groups: { "qc-local": ["a.png", "b.png"] }, ticket_report: "", mtime: 0 },
|
|
],
|
|
});
|
|
// ... render, select the lane (and "one" via Task B's picker if it isn't already active) ...
|
|
// assert screen.getByTestId("proof-gallery") appears with 2 images
|
|
});
|
|
|
|
it("shows no gallery panel when the selected feature has no proof", async () => {
|
|
vi.mocked(api.lanes.proof.list).mockResolvedValue({ features: [] });
|
|
// ... render, select the lane ...
|
|
// assert screen.queryByTestId("proof-gallery") is null
|
|
});
|
|
```
|
|
|
|
Read this file's existing render/selection helpers first and mirror them rather than guessing at the render setup.
|
|
|
|
**Step 5: Run the client test suite**
|
|
|
|
Run: `npm run test:client`
|
|
Expected: green, including the two new tests.
|
|
|
|
**Step 6: Commit**
|
|
|
|
```bash
|
|
git add client/src/lib/api.ts client/src/lib/types.ts client/src/pages/Workspace.tsx client/src/i18n/locales/en/lanes.json client/src/i18n/locales/vi/lanes.json client/src/pages/__tests__/Workspace.test.tsx
|
|
git commit -m "feat(lanes): show proof gallery panel in the Workspace page (C)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 5: Documentation
|
|
|
|
**Files:**
|
|
- Modify: `docs/LANES.md`
|
|
- Modify: `docs/CLI.md`
|
|
- Modify: `docs/API.md`
|
|
- Modify: `ARCHITECTURE.md`
|
|
- Modify: `docs/superpowers/plans/2026-08-03-shipyard-parity-lanes.md` (mark C done)
|
|
|
|
**Interfaces:** none — documentation only.
|
|
|
|
**Step 1: `docs/LANES.md`**
|
|
|
|
Add a new top-level section (after "Per-feature state and archive", before "Cross-lane named locks" — search for both headings) titled `## Proof gallery`, covering:
|
|
- Why: QC screenshots land on disk during a lane's work; this makes them visible in the dashboard, grouped by feature (Task B's slug) and phase.
|
|
- Storage layout: `<lane.cwd>/.playwright-mcp/proof/<slug>/{<phase-group>,ticket}/`.
|
|
- `ensure_proof_link`'s purpose and that it is **never automatic** — `ccam lanes proof-link` is an explicit primitive, same "CCAM does not orchestrate" reasoning as every other primitive in this project.
|
|
- The security invariant stated exactly: every proof route resolves + realpaths + checks containment before touching `fs`; `deleteProof` never removes `ticket/`.
|
|
- The Workspace panel reuses Task B's feature picker — no second selector.
|
|
|
|
**Step 2: `docs/CLI.md`**
|
|
|
|
Add to the `### Lanes` table, after the `lanes hook` row:
|
|
|
|
```markdown
|
|
| `ccam lanes proof-link [<id>]` | Converge clone-root `proof/` onto `.playwright-mcp/proof` (idempotent, never run automatically) |
|
|
```
|
|
|
|
**Step 3: `docs/API.md`**
|
|
|
|
Add a `### Lane proof gallery` section documenting `GET /api/lanes/:id/proof`, `GET /api/lanes/:id/proof/:slug/:group/:file`, `DELETE /api/lanes/:id/proof/:slug`, `POST /api/lanes/:id/proof-link` — request/response bodies and status codes exactly as specified in Task 2. Place it as its own subsection under `### Lanes`, near the "Lane features" section Task B added (confirm the insertion point with `grep -n "^### \|^#### " docs/API.md` first).
|
|
|
|
**Step 4: `ARCHITECTURE.md`**
|
|
|
|
Add a new row to the module responsibility table, near the `lib/lane-features.js` row:
|
|
|
|
```markdown
|
|
| `lib/proof.js` | (C) Proof gallery: lists/serves/deletes QC screenshots grouped by feature slug (Task B's `lane_features`) and phase, under `<lane.cwd>/.playwright-mcp/proof/<slug>/<group>/`. Every path is resolved, realpath'd, and containment-checked before touching `fs` — the entire security surface. `ensureProofLink` ports Shipyard's `ensure_proof_link` (converge a stray clone-root `proof/` onto the canonical dir); never auto-invoked, exposed only as `ccam lanes proof-link` |
|
|
```
|
|
|
|
**Step 5: Mark C done in the parent plan**
|
|
|
|
In `docs/superpowers/plans/2026-08-03-shipyard-parity-lanes.md`, update the status table row for `**C**` to `✅ **done** <today's date>`, and confirm `**E**`'s dependency line (`A2·B·C·D`) still reads correctly now that C is done too — do not mark E done, it is unstarted.
|
|
|
|
**Step 6: Verify and commit**
|
|
|
|
```bash
|
|
bash .claude/skills/file-headers/scripts/check-headers.sh
|
|
npm run test:server
|
|
```
|
|
|
|
```bash
|
|
git add docs/LANES.md docs/CLI.md docs/API.md ARCHITECTURE.md docs/superpowers/plans/2026-08-03-shipyard-parity-lanes.md
|
|
git commit -m "docs(lanes): document proof gallery (C)"
|
|
```
|