test: split scaffold tests into per-module files; fix require() in ESM test
Subagent split scaffold tests into scaffold-versioning.test.ts (Phase A)
and scaffold-drift.test.ts (Phase B). Fixed an ESM-incompatible
require("node:fs") in one drift test that was breaking with
--experimental-strip-types. All 33 tests pass.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
6f9a99da0a
commit
deab93bed6
2 changed files with 232 additions and 86 deletions
186
src/resources/extensions/sf/tests/scaffold-drift.test.ts
Normal file
186
src/resources/extensions/sf/tests/scaffold-drift.test.ts
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
/**
|
||||
* Tests for scaffold-drift (ADR-021 Phase B).
|
||||
*
|
||||
* Covers the five drift buckets and the manifestPresent flag.
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, test } from "node:test";
|
||||
|
||||
import { SCAFFOLD_FILES } from "../agentic-docs-scaffold.ts";
|
||||
import { detectScaffoldDrift, migrateLegacyScaffold } from "../scaffold-drift.ts";
|
||||
import {
|
||||
SCAFFOLD_MANIFEST_RELPATH,
|
||||
stampScaffoldFile,
|
||||
} from "../scaffold-versioning.ts";
|
||||
|
||||
function makeTmp(): string {
|
||||
return mkdtempSync(join(tmpdir(), "sf-scaffold-drift-"));
|
||||
}
|
||||
|
||||
/** Pick a scaffold entry that ships a Markdown file (not the .siftignore). */
|
||||
function pickMarkdownTarget(): { path: string; content: string } {
|
||||
const f = SCAFFOLD_FILES.find((s) => s.path === "AGENTS.md");
|
||||
if (!f) throw new Error("AGENTS.md must be in SCAFFOLD_FILES for this test");
|
||||
return { path: f.path, content: f.content };
|
||||
}
|
||||
|
||||
describe("detectScaffoldDrift", () => {
|
||||
let dir: string;
|
||||
beforeEach(() => {
|
||||
dir = makeTmp();
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("missing file → bucket missing", () => {
|
||||
const report = detectScaffoldDrift(dir);
|
||||
const item = report.items.find((i) => i.path === "AGENTS.md");
|
||||
assert.ok(item);
|
||||
assert.equal(item?.bucket, "missing");
|
||||
assert.ok(report.countsByBucket.missing > 0);
|
||||
});
|
||||
|
||||
test("manifestPresent is false when no manifest exists", () => {
|
||||
const report = detectScaffoldDrift(dir);
|
||||
assert.equal(report.manifestPresent, false);
|
||||
// Report still computed
|
||||
assert.ok(report.items.length > 0);
|
||||
});
|
||||
|
||||
test("manifestPresent is true when manifest has entries", () => {
|
||||
mkdirSync(join(dir, ".sf"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(dir, SCAFFOLD_MANIFEST_RELPATH),
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
applied: [
|
||||
{
|
||||
path: "AGENTS.md",
|
||||
template: "AGENTS.md",
|
||||
version: "0.0.0",
|
||||
appliedAt: "2026-01-01T00:00:00Z",
|
||||
stateAtApply: "pending",
|
||||
contentHash: "sha256:abc",
|
||||
},
|
||||
],
|
||||
}),
|
||||
"utf-8",
|
||||
);
|
||||
const report = detectScaffoldDrift(dir);
|
||||
assert.equal(report.manifestPresent, true);
|
||||
});
|
||||
|
||||
test("file with current marker, body hash matches → not upgradable, not editing-drift", () => {
|
||||
const target = pickMarkdownTarget();
|
||||
const fp = join(dir, target.path);
|
||||
writeFileSync(fp, target.content, "utf-8");
|
||||
const SF_VERSION = process.env.SF_VERSION || "0.0.0";
|
||||
stampScaffoldFile(fp, target.path, SF_VERSION);
|
||||
|
||||
const report = detectScaffoldDrift(dir);
|
||||
const item = report.items.find((i) => i.path === target.path);
|
||||
assert.ok(item);
|
||||
assert.notEqual(item?.bucket, "upgradable");
|
||||
assert.notEqual(item?.bucket, "editing-drift");
|
||||
assert.notEqual(item?.bucket, "missing");
|
||||
assert.equal(item?.currentVersion, SF_VERSION);
|
||||
});
|
||||
|
||||
test("file with older-version marker, body hash matches → upgradable", () => {
|
||||
const target = pickMarkdownTarget();
|
||||
const fp = join(dir, target.path);
|
||||
writeFileSync(fp, target.content, "utf-8");
|
||||
// Stamp with a deliberately-old version. This works regardless of what
|
||||
// process.env.SF_VERSION is, as long as SF_VERSION > 0.0.1 — which is
|
||||
// also true when SF_VERSION is unset (defaults to "0.0.0" → not <
|
||||
// "0.0.1"). To make this robust, force a higher SF_VERSION for the
|
||||
// scope of this test.
|
||||
const prev = process.env.SF_VERSION;
|
||||
process.env.SF_VERSION = "9.9.9";
|
||||
try {
|
||||
stampScaffoldFile(fp, target.path, "0.0.1");
|
||||
const report = detectScaffoldDrift(dir);
|
||||
const item = report.items.find((i) => i.path === target.path);
|
||||
assert.ok(item);
|
||||
assert.equal(item?.bucket, "upgradable");
|
||||
assert.equal(item?.currentVersion, "0.0.1");
|
||||
assert.equal(item?.shipVersion, "9.9.9");
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.SF_VERSION;
|
||||
else process.env.SF_VERSION = prev;
|
||||
}
|
||||
});
|
||||
|
||||
test("file with marker, body hash drifted → editing-drift", () => {
|
||||
const target = pickMarkdownTarget();
|
||||
const fp = join(dir, target.path);
|
||||
writeFileSync(fp, target.content, "utf-8");
|
||||
stampScaffoldFile(fp, target.path, "1.0.0");
|
||||
|
||||
// Now mutate the body without restamping (simulate user/agent edit)
|
||||
const stamped = readFileSync(fp, "utf-8");
|
||||
const lines = stamped.split("\n");
|
||||
// Append a new line to the body without touching the marker
|
||||
const mutated = lines[0] + "\n" + lines.slice(1).join("\n") + "\n# user edit\n";
|
||||
writeFileSync(fp, mutated, "utf-8");
|
||||
|
||||
const report = detectScaffoldDrift(dir);
|
||||
const item = report.items.find((i) => i.path === target.path);
|
||||
assert.ok(item);
|
||||
assert.equal(item?.bucket, "editing-drift");
|
||||
assert.equal(item?.hashDrifted, true);
|
||||
});
|
||||
|
||||
test("file with marker state=completed → bucket customized", () => {
|
||||
const target = pickMarkdownTarget();
|
||||
const fp = join(dir, target.path);
|
||||
writeFileSync(fp, target.content, "utf-8");
|
||||
stampScaffoldFile(fp, target.path, "1.0.0", "completed");
|
||||
|
||||
const report = detectScaffoldDrift(dir);
|
||||
const item = report.items.find((i) => i.path === target.path);
|
||||
assert.ok(item);
|
||||
assert.equal(item?.bucket, "customized");
|
||||
});
|
||||
|
||||
test("file without marker → bucket untracked", () => {
|
||||
const target = pickMarkdownTarget();
|
||||
const fp = join(dir, target.path);
|
||||
writeFileSync(fp, "# Custom Content\n\nuser-edited\n", "utf-8");
|
||||
|
||||
const report = detectScaffoldDrift(dir);
|
||||
const item = report.items.find((i) => i.path === target.path);
|
||||
assert.ok(item);
|
||||
assert.equal(item?.bucket, "untracked");
|
||||
assert.equal(item?.currentVersion, undefined);
|
||||
});
|
||||
|
||||
test("countsByBucket sums to items.length", () => {
|
||||
const report = detectScaffoldDrift(dir);
|
||||
const sum = Object.values(report.countsByBucket).reduce((a, b) => a + b, 0);
|
||||
assert.equal(sum, report.items.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("migrateLegacyScaffold", () => {
|
||||
test("returns empty arrays in Phase B (no archive yet)", () => {
|
||||
const dir = makeTmp();
|
||||
try {
|
||||
const result = migrateLegacyScaffold(dir);
|
||||
assert.deepEqual(result.migrated, []);
|
||||
assert.deepEqual(result.skipped, []);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
/**
|
||||
* Tests for scaffold-versioning + scaffold-drift (ADR-021 Phase A+B).
|
||||
* Tests for scaffold-versioning (ADR-021 Phase A).
|
||||
*
|
||||
* Covers: marker parse/format round-trip, stamp file behavior (new and replace),
|
||||
* body hash determinism, manifest read/write/dedup, drift detection buckets.
|
||||
* body hash determinism, manifest read/write/dedup.
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
|
|
@ -29,12 +29,9 @@ import {
|
|||
type ScaffoldManifestEntry,
|
||||
type ScaffoldMarker,
|
||||
} from "../scaffold-versioning.ts";
|
||||
import { detectScaffoldDrift } from "../scaffold-drift.ts";
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeTmp(): string {
|
||||
return mkdtempSync(join(tmpdir(), "sf-scaffold-test-"));
|
||||
return mkdtempSync(join(tmpdir(), "sf-scaffold-versioning-"));
|
||||
}
|
||||
|
||||
// ─── parseMarker / formatMarker ──────────────────────────────────────────────
|
||||
|
|
@ -59,6 +56,14 @@ describe("parseMarker", () => {
|
|||
assert.equal(m?.state, "editing");
|
||||
});
|
||||
|
||||
test("ignores unknown extra fields", () => {
|
||||
const m = parseMarker(
|
||||
"<!-- sf-doc: version=1 template=X.md state=pending hash=sha256:0 future=ok -->",
|
||||
);
|
||||
assert.ok(m);
|
||||
assert.equal(m?.template, "X.md");
|
||||
});
|
||||
|
||||
test("returns null on missing prefix", () => {
|
||||
assert.equal(parseMarker("<!-- not-sf-doc: version=1 -->"), null);
|
||||
});
|
||||
|
|
@ -94,12 +99,24 @@ describe("formatMarker round-trip", () => {
|
|||
const parsed = parseMarker(formatted);
|
||||
assert.deepEqual(parsed, original);
|
||||
});
|
||||
|
||||
test("formatMarker emits a single line terminated with newline", () => {
|
||||
const out = formatMarker({
|
||||
version: "1",
|
||||
template: "X.md",
|
||||
state: "pending",
|
||||
hash: "sha256:0",
|
||||
});
|
||||
assert.ok(out.endsWith("\n"));
|
||||
// Should contain exactly one newline (the trailing terminator)
|
||||
assert.equal(out.split("\n").length, 2);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── bodyHash ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("bodyHash", () => {
|
||||
test("returns same hash for same body", () => {
|
||||
test("returns the same hash for the same body", () => {
|
||||
const a = bodyHash("# Hello\n\nworld\n");
|
||||
const b = bodyHash("# Hello\n\nworld\n");
|
||||
assert.equal(a, b);
|
||||
|
|
@ -109,6 +126,13 @@ describe("bodyHash", () => {
|
|||
test("different bodies hash differently", () => {
|
||||
assert.notEqual(bodyHash("a"), bodyHash("b"));
|
||||
});
|
||||
|
||||
test("normalizes a single leading newline", () => {
|
||||
// stamp prepends a newline-terminated marker line, so the body as
|
||||
// written has a leading newline boundary; bodyHash strips one for
|
||||
// stability across representations.
|
||||
assert.equal(bodyHash("body\n"), bodyHash("\nbody\n"));
|
||||
});
|
||||
});
|
||||
|
||||
// ─── stampScaffoldFile ───────────────────────────────────────────────────────
|
||||
|
|
@ -122,7 +146,7 @@ describe("stampScaffoldFile", () => {
|
|||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("on a missing file, writes marker prepended to content", () => {
|
||||
test("on a missing file, writes marker + content", () => {
|
||||
const path = join(dir, "AGENTS.md");
|
||||
writeFileSync(path, "# Agent Map\n\nbody\n", "utf-8");
|
||||
stampScaffoldFile(path, "AGENTS.md", "2.75.2");
|
||||
|
|
@ -135,12 +159,11 @@ describe("stampScaffoldFile", () => {
|
|||
assert.equal(marker?.version, "2.75.2");
|
||||
assert.equal(marker?.state, "pending");
|
||||
|
||||
// Body preserved after the marker line
|
||||
const body = lines.slice(1).join("\n");
|
||||
assert.match(body, /# Agent Map/);
|
||||
});
|
||||
|
||||
test("on an existing-with-marker file, replaces the marker, preserves body", () => {
|
||||
test("on existing-with-marker file, replaces marker + preserves body", () => {
|
||||
const path = join(dir, "X.md");
|
||||
writeFileSync(
|
||||
path,
|
||||
|
|
@ -161,15 +184,23 @@ describe("stampScaffoldFile", () => {
|
|||
assert.match(raw, /body content/);
|
||||
});
|
||||
|
||||
test("hash in marker matches hash of body content", () => {
|
||||
test("hash in marker matches bodyHash of extracted body", () => {
|
||||
const path = join(dir, "Y.md");
|
||||
const body = "# Y\n\nlorem ipsum\n";
|
||||
writeFileSync(path, body, "utf-8");
|
||||
writeFileSync(path, "# Y\n\nlorem ipsum\n", "utf-8");
|
||||
stampScaffoldFile(path, "Y.md", "1.0.0");
|
||||
|
||||
const { marker, body: extractedBody } = extractMarker(path);
|
||||
const { marker, body } = extractMarker(path);
|
||||
assert.ok(marker);
|
||||
assert.equal(marker?.hash, bodyHash(body));
|
||||
});
|
||||
|
||||
test("creates parent directories when needed", () => {
|
||||
const path = join(dir, "nested/deep/F.md");
|
||||
mkdirSync(join(dir, "nested/deep"), { recursive: true });
|
||||
writeFileSync(path, "body\n", "utf-8");
|
||||
stampScaffoldFile(path, "nested/deep/F.md", "0.1.0");
|
||||
const { marker } = extractMarker(path);
|
||||
assert.ok(marker);
|
||||
assert.equal(marker?.hash, bodyHash(extractedBody));
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -282,74 +313,3 @@ describe("scaffold manifest", () => {
|
|||
assert.equal(m.applied.length, 2);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── detectScaffoldDrift ─────────────────────────────────────────────────────
|
||||
|
||||
describe("detectScaffoldDrift", () => {
|
||||
let dir: string;
|
||||
beforeEach(() => {
|
||||
dir = makeTmp();
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("returns a structured report with countsByBucket", () => {
|
||||
const report = detectScaffoldDrift(dir);
|
||||
assert.ok(report.items, "items array exists");
|
||||
assert.ok(report.countsByBucket, "countsByBucket exists");
|
||||
// On an empty dir, every scaffold file is missing
|
||||
assert.ok(report.countsByBucket.missing > 0);
|
||||
});
|
||||
|
||||
test("manifestPresent flag reflects manifest having entries", () => {
|
||||
const r1 = detectScaffoldDrift(dir);
|
||||
assert.equal(r1.manifestPresent, false);
|
||||
|
||||
mkdirSync(join(dir, ".sf"), { recursive: true });
|
||||
// Write a manifest with an actual entry — empty arrays are considered absent.
|
||||
writeFileSync(
|
||||
join(dir, SCAFFOLD_MANIFEST_RELPATH),
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
applied: [
|
||||
{
|
||||
path: "AGENTS.md",
|
||||
template: "AGENTS.md",
|
||||
version: "2.75.2",
|
||||
appliedAt: "2026-05-02T00:00:00Z",
|
||||
stateAtApply: "pending",
|
||||
contentHash: "sha256:abc",
|
||||
},
|
||||
],
|
||||
}),
|
||||
"utf-8",
|
||||
);
|
||||
const r2 = detectScaffoldDrift(dir);
|
||||
assert.equal(r2.manifestPresent, true);
|
||||
});
|
||||
|
||||
test("file with marker matching current SF version → not in upgradable", () => {
|
||||
// Pick a known scaffold file (AGENTS.md should always be in SCAFFOLD_FILES)
|
||||
const path = join(dir, "AGENTS.md");
|
||||
writeFileSync(path, "# Agent Map\n\nbody\n", "utf-8");
|
||||
const SF_VERSION = process.env.SF_VERSION || "0.0.0";
|
||||
stampScaffoldFile(path, "AGENTS.md", SF_VERSION);
|
||||
|
||||
const report = detectScaffoldDrift(dir);
|
||||
const item = report.items.find((i) => i.path === "AGENTS.md");
|
||||
assert.ok(item, "AGENTS.md should be in report");
|
||||
// With current version stamped and matching hash, bucket is customized or upgradable=false
|
||||
assert.notEqual(item?.bucket, "missing");
|
||||
});
|
||||
|
||||
test("file without marker → bucket untracked", () => {
|
||||
const path = join(dir, "AGENTS.md");
|
||||
writeFileSync(path, "# Custom Content\n\nuser-edited\n", "utf-8");
|
||||
|
||||
const report = detectScaffoldDrift(dir);
|
||||
const item = report.items.find((i) => i.path === "AGENTS.md");
|
||||
assert.ok(item);
|
||||
assert.equal(item?.bucket, "untracked");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue