Skip to content

Commit 3e534ae

Browse files
committed
test: add comprehensive unit tests for pi-edit and fix ESM stubs for Node 18
- Fix ESM stubs: add type:module to package.json in test stub dirs for Node 18 compatibility (cli.test.js, resolve-bundled-pi.test.js) - Add 9 new test files covering 13 previously untested modules: hasher (7), replace-diff (16), utils (22), replace-normalize (14), file-kind (13), apply (16), fs-write (8), prompts (12), validation (8), file-reader (6) - Total: 160 tests, 0 failures
1 parent fd78334 commit 3e534ae

12 files changed

Lines changed: 1101 additions & 2 deletions

test/apply.test.js

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import assert from "node:assert/strict";
2+
import test from "node:test";
3+
4+
// Faithful JS mirror of plugin/pi-edit/src/hashline/apply.ts (pure functions)
5+
const HASH_SEP = "\u2502";
6+
7+
function buildIdx(content) {
8+
const fileLines = content.split("\n");
9+
const lineStarts = [];
10+
let offset = 0;
11+
for (let index = 0; index < fileLines.length; index++) {
12+
lineStarts.push(offset);
13+
offset += fileLines[index].length;
14+
if (index < fileLines.length - 1) offset += 1;
15+
}
16+
return { fileLines, lineStarts };
17+
}
18+
19+
function visLines(text) {
20+
if (text.length === 0) return [];
21+
const lines = text.split("\n");
22+
return text.endsWith("\n") ? lines.slice(0, -1) : lines;
23+
}
24+
25+
function changedRange(original, result) {
26+
if (original === result) return null;
27+
if (original.length === 0) return { firstChangedLine: 1, lastChangedLine: visLines(result).length };
28+
if (result.startsWith(original) && original.endsWith("\n")) {
29+
return { firstChangedLine: visLines(original).length + 1, lastChangedLine: visLines(result).length };
30+
}
31+
let firstDiff = 0;
32+
const minLen = Math.min(original.length, result.length);
33+
while (firstDiff < minLen && original[firstDiff] === result[firstDiff]) firstDiff++;
34+
if (firstDiff === minLen && original.length === result.length) return null;
35+
let lastOrig = original.length - 1;
36+
let lastRes = result.length - 1;
37+
while (lastOrig >= firstDiff && lastRes >= firstDiff && original[lastOrig] === result[lastRes]) {
38+
lastOrig--;
39+
lastRes--;
40+
}
41+
function idxToLine(charIdx, text) {
42+
let line = 1;
43+
for (let i = 0; i < charIdx && i < text.length; i++) if (text[i] === "\n") line++;
44+
return line;
45+
}
46+
const firstChangedLine = idxToLine(firstDiff + 1, result);
47+
let lastChangedLine;
48+
if (lastRes < firstDiff) {
49+
lastChangedLine = result.length === 0 ? 1 : visLines(result).length;
50+
} else if (firstDiff === 0 && original.length > 0 && result.endsWith(original)) {
51+
lastChangedLine = firstChangedLine;
52+
} else {
53+
lastChangedLine = idxToLine(lastRes + 1, result);
54+
}
55+
return { firstChangedLine, lastChangedLine };
56+
}
57+
58+
function fmtRegion(hashes, lines) {
59+
if (hashes.length !== lines.length) {
60+
throw new Error(`fmtRegion: hashes.length (${hashes.length}) must match lines.length (${lines.length}).`);
61+
}
62+
return lines.map((line, index) => `${hashes[index]}${HASH_SEP}${line}`).join("\n");
63+
}
64+
65+
test("buildIdx empty string", () => {
66+
const idx = buildIdx("");
67+
assert.deepEqual(idx.fileLines, [""]);
68+
assert.deepEqual(idx.lineStarts, [0]);
69+
});
70+
71+
test("buildIdx single line no newline", () => {
72+
const idx = buildIdx("hello");
73+
assert.deepEqual(idx.fileLines, ["hello"]);
74+
assert.deepEqual(idx.lineStarts, [0]);
75+
});
76+
77+
test("buildIdx two lines no trailing newline", () => {
78+
const idx = buildIdx("line1\nline2");
79+
assert.deepEqual(idx.fileLines, ["line1", "line2"]);
80+
assert.deepEqual(idx.lineStarts, [0, 6]);
81+
});
82+
83+
test("buildIdx three lines with trailing newline", () => {
84+
const idx = buildIdx("a\nb\nc\n");
85+
assert.deepEqual(idx.fileLines, ["a", "b", "c", ""]);
86+
assert.deepEqual(idx.lineStarts, [0, 2, 4, 6]);
87+
});
88+
89+
test("buildIdx lineStarts offset account for newline separator", () => {
90+
const idx = buildIdx("ab\ncd\nef");
91+
// line 0 starts at 0, line 1 at 3 (2 chars + 1 newline), line 2 at 6
92+
assert.deepEqual(idx.lineStarts, [0, 3, 6]);
93+
});
94+
95+
test("changedRange identical returns null", () => {
96+
assert.equal(changedRange("same\ncontent\n", "same\ncontent\n"), null);
97+
});
98+
99+
test("changedRange empty original to content", () => {
100+
const range = changedRange("", "new content\n");
101+
assert.deepEqual(range, { firstChangedLine: 1, lastChangedLine: 1 });
102+
});
103+
104+
test("changedRange append at end with newline", () => {
105+
const original = "line1\nline2\n";
106+
const result = "line1\nline2\nline3\n";
107+
const range = changedRange(original, result);
108+
assert.deepEqual(range, { firstChangedLine: 3, lastChangedLine: 3 });
109+
});
110+
111+
test("changedRange prepend at start", () => {
112+
const original = "line2\nline3\n";
113+
const result = "line1\nline2\nline3\n";
114+
const range = changedRange(original, result);
115+
assert.deepEqual(range, { firstChangedLine: 1, lastChangedLine: 2 });
116+
});
117+
118+
119+
test("changedRange middle change", () => {
120+
const original = "line1\nline2\nline3\n";
121+
const result = "line1\nCHANGED\nline3\n";
122+
const range = changedRange(original, result);
123+
assert.deepEqual(range, { firstChangedLine: 2, lastChangedLine: 2 });
124+
});
125+
126+
test("changedRange full replacement", () => {
127+
const original = "a\nb\nc\n";
128+
const result = "x\ny\nz\n";
129+
const range = changedRange(original, result);
130+
assert.deepEqual(range, { firstChangedLine: 1, lastChangedLine: 3 });
131+
});
132+
133+
test("changedRange shrink (delete lines)", () => {
134+
const original = "a\nb\nc\nd\n";
135+
const result = "a\nd\n";
136+
const range = changedRange(original, result);
137+
// Line 2 changes from b to d
138+
assert.ok(range !== null);
139+
assert.equal(range.firstChangedLine, 2);
140+
});
141+
142+
test("fmtRegion formats with hash separator", () => {
143+
const result = fmtRegion(["h1", "h2"], ["line1", "line2"]);
144+
assert.equal(result, "h1\u2502line1\nh2\u2502line2");
145+
});
146+
147+
test("fmtRegion single line", () => {
148+
assert.equal(fmtRegion(["abc"], ["only line"]), "abc\u2502only line");
149+
});
150+
151+
test("fmtRegion mismatched lengths throws", () => {
152+
assert.throws(() => fmtRegion(["h1", "h2"], ["only one"]), /fmtRegion/);
153+
});
154+
155+
test("fmtRegion empty arrays", () => {
156+
assert.equal(fmtRegion([], []), "");
157+
});

test/cli.test.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ function run(args) {
1212
function writePackage(root, name, files = {}) {
1313
const dir = path.join(root, "node_modules", ...name.split("/"));
1414
fs.mkdirSync(dir, { recursive: true });
15-
fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ name, version: "0.0.0" }));
15+
fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ name, version: "0.0.0", type: "module" }));
1616
for (const [file, content] of Object.entries(files)) {
1717
const target = path.join(dir, file);
1818
fs.mkdirSync(path.dirname(target), { recursive: true });
@@ -130,6 +130,8 @@ test("axum update reinstalls from main branch tarball", () => {
130130
const npmPath = path.join(stubDir, isWin ? "npm.cmd" : "npm");
131131
const argvFile = path.join(stubDir, "argv.json");
132132
const shebang = isWin ? "" : "#!/usr/bin/env node\n";
133+
// Node 18+ requires explicit ESM opt-in for .js files using import syntax.
134+
if (!isWin) fs.writeFileSync(path.join(stubDir, "package.json"), JSON.stringify({ type: "module" }));
133135
fs.writeFileSync(npmPath, `${shebang}import fs from "node:fs"; fs.writeFileSync(${JSON.stringify(argvFile)}, JSON.stringify(process.argv.slice(2)));\n`);
134136
if (!isWin) fs.chmodSync(npmPath, 0o755);
135137
const result = spawnSync(process.execPath, ["bin/axum.js", "update"], {

test/file-kind.test.js

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import assert from "node:assert/strict";
2+
import { Buffer } from "node:buffer";
3+
import test from "node:test";
4+
5+
// Faithful JS mirror of plugin/pi-edit/src/file-kind.ts (pure functions only)
6+
const IMG_SIGNATURES = [
7+
{ magic: [0xff, 0xd8, 0xff], mime: "image/jpeg" },
8+
{ magic: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], mime: "image/png" },
9+
{ magic: [0x47, 0x49, 0x46, 0x38], mime: "image/gif" },
10+
{ magic: [0x52, 0x49, 0x46, 0x46], mime: "image/webp" },
11+
];
12+
13+
function detectImageMime(buf) {
14+
for (const sig of IMG_SIGNATURES) {
15+
if (sig.mime === "image/webp") {
16+
if (buf.length >= 12 && buf.readUInt32BE(8) === 0x57454250) return "image/webp";
17+
continue;
18+
}
19+
if (buf.length >= sig.magic.length && sig.magic.every((b, i) => buf[i] === b)) {
20+
return sig.mime;
21+
}
22+
}
23+
return null;
24+
}
25+
26+
function isProbablyBinary(buf) {
27+
for (let i = 0; i < buf.length; i++) {
28+
if (buf[i] === 0) return true;
29+
}
30+
let nonText = 0;
31+
for (let i = 0; i < buf.length; i++) {
32+
const b = buf[i];
33+
if (b < 0x09 || (b > 0x0d && b < 0x20) || b > 0x7e) nonText++;
34+
}
35+
return buf.length > 0 && nonText / buf.length > 0.3;
36+
}
37+
38+
test("detectImageMime JPEG", () => {
39+
const buf = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]);
40+
assert.equal(detectImageMime(buf), "image/jpeg");
41+
});
42+
43+
test("detectImageMime PNG", () => {
44+
const buf = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00]);
45+
assert.equal(detectImageMime(buf), "image/png");
46+
});
47+
48+
test("detectImageMime GIF", () => {
49+
const buf = Buffer.from([0x47, 0x49, 0x46, 0x38, 0x39, 0x61]);
50+
assert.equal(detectImageMime(buf), "image/gif");
51+
});
52+
53+
test("detectImageMime WebP with RIFF...WEBP", () => {
54+
const buf = Buffer.alloc(12);
55+
buf[0] = 0x52; // R
56+
buf[1] = 0x49; // I
57+
buf[2] = 0x46; // F
58+
buf[3] = 0x46; // F
59+
buf.writeUInt32BE(0x57454250, 8); // WEBP
60+
assert.equal(detectImageMime(buf), "image/webp");
61+
});
62+
63+
test("detectImageMime RIFF without WEBP returns null", () => {
64+
const buf = Buffer.alloc(12);
65+
buf[0] = 0x52; // RIFF header
66+
buf[1] = 0x49;
67+
buf[2] = 0x46;
68+
buf[3] = 0x46;
69+
buf.writeUInt32BE(0x57415645, 8); // WAVE, not WEBP
70+
assert.equal(detectImageMime(buf), null);
71+
});
72+
73+
test("detectImageMime returns null for text", () => {
74+
const buf = Buffer.from("hello world", "utf8");
75+
assert.equal(detectImageMime(buf), null);
76+
});
77+
78+
test("detectImageMime returns null for empty buffer", () => {
79+
assert.equal(detectImageMime(Buffer.alloc(0)), null);
80+
});
81+
82+
test("isProbablyBinary false for ASCII text", () => {
83+
const buf = Buffer.from("hello world\nfoo bar baz\n", "utf8");
84+
assert.equal(isProbablyBinary(buf), false);
85+
});
86+
87+
test("isProbablyBinary true for null bytes", () => {
88+
const buf = Buffer.from([0x01, 0x00, 0x02, 0x03]);
89+
assert.equal(isProbablyBinary(buf), true);
90+
});
91+
92+
test("isProbablyBinary false for text with newlines and tabs", () => {
93+
const buf = Buffer.from("line1\nline2\ttab\rcarriage", "utf8");
94+
assert.equal(isProbablyBinary(buf), false);
95+
});
96+
97+
test("isProbablyBinary true for high non-text ratio", () => {
98+
// All bytes above 0x7e
99+
const buf = Buffer.from([0x80, 0x81, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89]);
100+
assert.equal(isProbablyBinary(buf), true);
101+
});
102+
103+
test("isProbablyBinary false for empty buffer", () => {
104+
assert.equal(isProbablyBinary(Buffer.alloc(0)), false);
105+
});
106+
107+
test("isProbablyBinary true for mixed content with null", () => {
108+
const buf = Buffer.from("text\0binary", "utf8");
109+
assert.equal(isProbablyBinary(buf), true);
110+
});

test/file-reader.test.js

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import assert from "node:assert/strict";
2+
import fs from "node:fs";
3+
import os from "node:os";
4+
import path from "node:path";
5+
import test from "node:test";
6+
7+
// Faithful JS mirror of plugin/pi-edit/src/file-reader.ts (pure functions)
8+
function fmtSnapId(canonicalPath, info) {
9+
return `v1|${canonicalPath}|${info.mtimeMs}|${info.size}`;
10+
}
11+
12+
test("fmtSnapId formats correctly with all fields", () => {
13+
const result = fmtSnapId("/abs/path/to/file.ts", { mtimeMs: 1700000000.5, size: 1024 });
14+
assert.equal(result, "v1|/abs/path/to/file.ts|1700000000.5|1024");
15+
});
16+
17+
test("fmtSnapId uses v1 prefix", () => {
18+
assert.ok(fmtSnapId("/x", { mtimeMs: 1, size: 1 }).startsWith("v1|"));
19+
});
20+
21+
test("fmtSnapId with size 0", () => {
22+
const result = fmtSnapId("/empty.txt", { mtimeMs: 0, size: 0 });
23+
assert.equal(result, "v1|/empty.txt|0|0");
24+
});
25+
26+
test("fmtSnapId with large mtimeMs", () => {
27+
const result = fmtSnapId("/a.ts", { mtimeMs: 9999999999999, size: 500 });
28+
assert.equal(result, "v1|/a.ts|9999999999999|500");
29+
});
30+
31+
test("fmtSnapId with special chars in path", () => {
32+
const result = fmtSnapId("/path with spaces/file (1).ts", { mtimeMs: 123, size: 42 });
33+
assert.equal(result, "v1|/path with spaces/file (1).ts|123|42");
34+
});
35+
36+
test("fmtSnapId separates with pipe consistently", () => {
37+
const result = fmtSnapId("/a/b.ts", { mtimeMs: 1, size: 2 });
38+
const parts = result.split("|");
39+
assert.equal(parts.length, 4);
40+
assert.equal(parts[0], "v1");
41+
assert.equal(parts[1], "/a/b.ts");
42+
assert.equal(parts[2], "1");
43+
assert.equal(parts[3], "2");
44+
});

0 commit comments

Comments
 (0)