feat(lanes): add proof gallery core (list/file/delete/link) (C)
This commit is contained in:
@@ -0,0 +1,226 @@
|
|||||||
|
/**
|
||||||
|
* @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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
/**
|
||||||
|
* @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,
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user