blockpatch 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTRIBUTING.md +9 -1
- package/README.md +100 -468
- package/conformance/README.md +29 -0
- package/conformance/cases/ambiguous-source/case.json +8 -0
- package/conformance/cases/ambiguous-target/case.json +8 -0
- package/conformance/cases/create-file/case.json +8 -0
- package/conformance/cases/crlf/case.json +10 -0
- package/conformance/cases/cross-file-relocation/case.json +12 -0
- package/conformance/cases/delete-existing-file/case.json +10 -0
- package/conformance/cases/insert-existing-file/case.json +10 -0
- package/conformance/cases/no-trailing-newline/case.json +10 -0
- package/conformance/cases/remove-file/case.json +9 -0
- package/conformance/cases/same-file-relocation/case.json +10 -0
- package/conformance/runner.mjs +221 -0
- package/dist/cli.js +967 -523
- package/docs/behavior.md +207 -0
- package/docs/commands.md +84 -0
- package/docs/spec.md +356 -0
- package/examples/README.md +15 -0
- package/examples/create-file/README.md +9 -0
- package/examples/create-file/expected/file.txt +1 -0
- package/examples/create-file/patch.blockpatch +8 -0
- package/examples/create-file/work/.gitkeep +1 -0
- package/examples/cross-file-relocation/README.md +10 -0
- package/examples/cross-file-relocation/expected/source.txt +2 -0
- package/examples/cross-file-relocation/expected/target.txt +6 -0
- package/examples/cross-file-relocation/patch.blockpatch +26 -0
- package/examples/cross-file-relocation/work/source.txt +5 -0
- package/examples/cross-file-relocation/work/target.txt +3 -0
- package/examples/delete-existing-file/README.md +9 -0
- package/examples/delete-existing-file/expected/file.txt +2 -0
- package/examples/delete-existing-file/patch.blockpatch +10 -0
- package/examples/delete-existing-file/work/file.txt +3 -0
- package/examples/failure-ambiguous-target/README.md +9 -0
- package/examples/failure-ambiguous-target/patch.blockpatch +14 -0
- package/examples/failure-ambiguous-target/work/file.txt +5 -0
- package/examples/insert-existing-file/README.md +9 -0
- package/examples/insert-existing-file/expected/file.txt +3 -0
- package/examples/insert-existing-file/patch.blockpatch +10 -0
- package/examples/insert-existing-file/work/file.txt +2 -0
- package/examples/remove-file/README.md +9 -0
- package/examples/remove-file/expected/.gitkeep +1 -0
- package/examples/remove-file/patch.blockpatch +8 -0
- package/examples/remove-file/work/file.txt +1 -0
- package/examples/reverse/README.md +9 -0
- package/examples/reverse/expected/file.txt +4 -0
- package/examples/reverse/patch.blockpatch +14 -0
- package/examples/reverse/work/file.txt +4 -0
- package/examples/same-file-relocation/README.md +9 -0
- package/examples/same-file-relocation/expected/file.txt +4 -0
- package/examples/same-file-relocation/patch.blockpatch +14 -0
- package/examples/same-file-relocation/work/file.txt +4 -0
- package/package.json +13 -5
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# blockpatch conformance
|
|
2
|
+
|
|
3
|
+
These cases exercise the public `.blockpatch` apply/check contract for independent implementations.
|
|
4
|
+
|
|
5
|
+
Run against an installed implementation:
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npx -p blockpatch blockpatch-conformance ./my-implementation
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
The shorter `npx blockpatch-conformance ./my-implementation` spelling would require publishing a separate npm package named `blockpatch-conformance`; this repository currently ships the runner as a `blockpatch` package binary.
|
|
12
|
+
|
|
13
|
+
Run against this repository after building:
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
npm run build
|
|
17
|
+
node conformance/runner.mjs node dist/cli.js
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
The runner invokes the implementation as:
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
<implementation> check <patch.blockpatch> --cwd <work> --json-output
|
|
24
|
+
<implementation> apply <patch.blockpatch> --cwd <work> --json-output
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Successful cases also verify retry idempotence and `apply --reverse`. Failure cases verify the expected JSON error code and that the work tree remains byte-identical.
|
|
28
|
+
|
|
29
|
+
Set `BLOCKPATCH_CONFORMANCE_KEEP=1` to keep the temporary work directory after a failure.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
{
|
|
2
|
+
"expect": "fail",
|
|
3
|
+
"error_code": "source_ambiguous",
|
|
4
|
+
"patch": "diff --blockpatch a/file.txt b/file.txt\nblockpatch version 1\nblockpatch move id=move-1 payload-sha256=f721166071c491fd38ac82a8432ecc349f39f537a969054ab2c8d3175c731e7e\n--- a/file.txt\n+++ b/file.txt\n\n@@ -1,3 +1,2 @@ blockpatch-source id=move-1\n alpha\n-move me\n omega\n\n@@ -4,1 +4,2 @@ blockpatch-target id=move-1\n target\n+move me\n",
|
|
5
|
+
"files": {
|
|
6
|
+
"file.txt": "alpha\nmove me\nomega\ntarget\nalpha\nmove me\nomega\n"
|
|
7
|
+
}
|
|
8
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
{
|
|
2
|
+
"expect": "fail",
|
|
3
|
+
"error_code": "target_ambiguous",
|
|
4
|
+
"patch": "diff --blockpatch a/file.txt b/file.txt\nblockpatch version 1\nblockpatch move id=move-1 payload-sha256=f721166071c491fd38ac82a8432ecc349f39f537a969054ab2c8d3175c731e7e\n--- a/file.txt\n+++ b/file.txt\n\n@@ -1,3 +1,2 @@ blockpatch-source id=move-1\n alpha\n-move me\n omega\n\n@@ -4,1 +4,2 @@ blockpatch-target id=move-1\n target\n+move me\n",
|
|
5
|
+
"files": {
|
|
6
|
+
"file.txt": "alpha\nmove me\nomega\ntarget\ntarget\n"
|
|
7
|
+
}
|
|
8
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
{
|
|
2
|
+
"expect": "pass",
|
|
3
|
+
"patch": "diff --blockpatch /dev/null b/file.txt\nblockpatch version 1\nblockpatch move id=move-1 payload-sha256=072a398e62ad50e40613b98e57f3e3236a471dcbd7c2b9f190440c22adc16a0f\n--- /dev/null\n+++ b/file.txt\n\n@@ -0,0 +1,1 @@ blockpatch-target id=move-1\n+created file\n",
|
|
4
|
+
"files": {},
|
|
5
|
+
"expected_files": {
|
|
6
|
+
"file.txt": "created file\n"
|
|
7
|
+
}
|
|
8
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"expect": "pass",
|
|
3
|
+
"patch": "diff --blockpatch a/file.txt b/file.txt\nblockpatch version 1\nblockpatch move id=move-1 payload-sha256=10d316fe0179a4ccaa97a509f75294785f941d7f9b0656684844edcdf1b5a01a\n--- a/file.txt\n+++ b/file.txt\n\n@@ -1,3 +1,2 @@ blockpatch-source id=move-1\n alpha\r\n-move me\r\n omega\r\n\n@@ -4,1 +4,2 @@ blockpatch-target id=move-1\n target\r\n+move me\r\n",
|
|
4
|
+
"files": {
|
|
5
|
+
"file.txt": "alpha\r\nmove me\r\nomega\r\ntarget\r\n"
|
|
6
|
+
},
|
|
7
|
+
"expected_files": {
|
|
8
|
+
"file.txt": "alpha\r\nomega\r\ntarget\r\nmove me\r\n"
|
|
9
|
+
}
|
|
10
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"expect": "pass",
|
|
3
|
+
"patch": "diff --blockpatch a/source.txt b/source.txt\nblockpatch version 1\nblockpatch move id=move-1 role=source payload-sha256=a990c0da5571138b0e2363af883a399fe214a137ad809c67f6530c618967a4e6\n--- a/source.txt\n+++ b/source.txt\n\n@@ -1,5 +1,2 @@ blockpatch-source id=move-1\n alpha()\n-function movedThing() {\n- return 42;\n-}\n omega()\n\ndiff --blockpatch a/target.txt b/target.txt\nblockpatch version 1\nblockpatch move id=move-1 role=target payload-sha256=a990c0da5571138b0e2363af883a399fe214a137ad809c67f6530c618967a4e6\n--- a/target.txt\n+++ b/target.txt\n\n@@ -1,3 +1,6 @@ blockpatch-target id=move-1\n class Target {\n method() {}\n+function movedThing() {\n+ return 42;\n+}\n }\n",
|
|
4
|
+
"files": {
|
|
5
|
+
"source.txt": "alpha()\nfunction movedThing() {\n return 42;\n}\nomega()\n",
|
|
6
|
+
"target.txt": "class Target {\n method() {}\n}\n"
|
|
7
|
+
},
|
|
8
|
+
"expected_files": {
|
|
9
|
+
"source.txt": "alpha()\nomega()\n",
|
|
10
|
+
"target.txt": "class Target {\n method() {}\nfunction movedThing() {\n return 42;\n}\n}\n"
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"expect": "pass",
|
|
3
|
+
"patch": "diff --blockpatch a/file.txt b/file.txt\nblockpatch version 1\nblockpatch move id=move-1 payload-sha256=5dbe6af8d3a2a31f6696ebdae9e590977a19b827ec8e0a22ea1e3decf1a79b77\n--- a/file.txt\n+++ b/file.txt\n\n@@ -1,3 +1,2 @@ blockpatch-source id=move-1\n alpha\n-remove me\n omega\n",
|
|
4
|
+
"files": {
|
|
5
|
+
"file.txt": "alpha\nremove me\nomega\n"
|
|
6
|
+
},
|
|
7
|
+
"expected_files": {
|
|
8
|
+
"file.txt": "alpha\nomega\n"
|
|
9
|
+
}
|
|
10
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"expect": "pass",
|
|
3
|
+
"patch": "diff --blockpatch a/file.txt b/file.txt\nblockpatch version 1\nblockpatch move id=move-1 payload-sha256=cbfeef0cd60ab2c01e036d23082a1f19ea3f5153d9cffde8790ad0152a3830f5\n--- a/file.txt\n+++ b/file.txt\n\n@@ -1,2 +1,3 @@ blockpatch-target id=move-1\n alpha\n+inserted line\n omega\n",
|
|
4
|
+
"files": {
|
|
5
|
+
"file.txt": "alpha\nomega\n"
|
|
6
|
+
},
|
|
7
|
+
"expected_files": {
|
|
8
|
+
"file.txt": "alpha\ninserted line\nomega\n"
|
|
9
|
+
}
|
|
10
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"expect": "pass",
|
|
3
|
+
"patch": "diff --blockpatch a/file.txt b/file.txt\nblockpatch version 1\nblockpatch move id=move-1 payload-sha256=f721166071c491fd38ac82a8432ecc349f39f537a969054ab2c8d3175c731e7e\n--- a/file.txt\n+++ b/file.txt\n\n@@ -1,3 +1,2 @@ blockpatch-source id=move-1\n alpha\n-move me\n omega\n\n@@ -4,1 +4,2 @@ blockpatch-target id=move-1\n+move me\n target\n\\n",
|
|
4
|
+
"files": {
|
|
5
|
+
"file.txt": "alpha\nmove me\nomega\ntarget"
|
|
6
|
+
},
|
|
7
|
+
"expected_files": {
|
|
8
|
+
"file.txt": "alpha\nomega\nmove me\ntarget"
|
|
9
|
+
}
|
|
10
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
{
|
|
2
|
+
"expect": "pass",
|
|
3
|
+
"patch": "diff --blockpatch a/file.txt /dev/null\nblockpatch version 1\nblockpatch move id=move-1 payload-sha256=4da5a83256114db88b111676cf3b2f4a04f202d3d7fc99417cac1ba5145713e2\n--- a/file.txt\n+++ /dev/null\n\n@@ -1,1 +0,0 @@ blockpatch-source id=move-1\n-obsolete file\n",
|
|
4
|
+
"files": {
|
|
5
|
+
"file.txt": "obsolete file\n"
|
|
6
|
+
},
|
|
7
|
+
"expected_files": {},
|
|
8
|
+
"expected_absent": ["file.txt"]
|
|
9
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"expect": "pass",
|
|
3
|
+
"patch": "diff --blockpatch a/file.txt b/file.txt\nblockpatch version 1\nblockpatch move id=move-1 payload-sha256=f721166071c491fd38ac82a8432ecc349f39f537a969054ab2c8d3175c731e7e\n--- a/file.txt\n+++ b/file.txt\n\n@@ -1,3 +1,2 @@ blockpatch-source id=move-1\n alpha\n-move me\n omega\n\n@@ -4,1 +4,2 @@ blockpatch-target id=move-1\n target\n+move me\n",
|
|
4
|
+
"files": {
|
|
5
|
+
"file.txt": "alpha\nmove me\nomega\ntarget\n"
|
|
6
|
+
},
|
|
7
|
+
"expected_files": {
|
|
8
|
+
"file.txt": "alpha\nomega\ntarget\nmove me\n"
|
|
9
|
+
}
|
|
10
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { constants } from "node:fs";
|
|
4
|
+
import { access, mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
|
|
9
|
+
const conformanceRoot = dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
const casesRoot = join(conformanceRoot, "cases");
|
|
11
|
+
const invocationCwd = process.cwd();
|
|
12
|
+
const keepTemp = process.env.BLOCKPATCH_CONFORMANCE_KEEP === "1";
|
|
13
|
+
const implementationArgv = process.argv.slice(2);
|
|
14
|
+
|
|
15
|
+
if (implementationArgv.length === 0 || implementationArgv[0] === "--help" || implementationArgv[0] === "-h") {
|
|
16
|
+
printUsage();
|
|
17
|
+
process.exit(implementationArgv.length === 0 ? 2 : 0);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const implementation = normalizeImplementation(implementationArgv);
|
|
21
|
+
const tempRoot = await mkdtemp(join(tmpdir(), "blockpatch-conformance-"));
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
const caseNames = await readCaseNames();
|
|
25
|
+
for (const caseName of caseNames) {
|
|
26
|
+
await runCase(caseName);
|
|
27
|
+
console.log(`ok ${caseName}`);
|
|
28
|
+
}
|
|
29
|
+
console.log(`ok ${caseNames.length} conformance cases`);
|
|
30
|
+
} catch (error) {
|
|
31
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
32
|
+
if (keepTemp) {
|
|
33
|
+
console.error(`kept temp directory: ${tempRoot}`);
|
|
34
|
+
}
|
|
35
|
+
process.exitCode = 1;
|
|
36
|
+
} finally {
|
|
37
|
+
if (!keepTemp) {
|
|
38
|
+
await rm(tempRoot, { recursive: true, force: true });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function printUsage() {
|
|
43
|
+
console.error("Usage: blockpatch-conformance <blockpatch-command> [args...]");
|
|
44
|
+
console.error("");
|
|
45
|
+
console.error("Examples:");
|
|
46
|
+
console.error(" blockpatch-conformance blockpatch");
|
|
47
|
+
console.error(" blockpatch-conformance node dist/cli.js");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function normalizeImplementation(argv) {
|
|
51
|
+
const [command, ...args] = argv;
|
|
52
|
+
const resolvedCommand = looksPathLike(command) ? resolve(invocationCwd, command) : command;
|
|
53
|
+
return { command: resolvedCommand, args };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function looksPathLike(command) {
|
|
57
|
+
return command.startsWith(".") || command.includes("/") || command.includes("\\") || isAbsolute(command);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function readCaseNames() {
|
|
61
|
+
const entries = await readdir(casesRoot, { withFileTypes: true });
|
|
62
|
+
return entries
|
|
63
|
+
.filter((entry) => entry.isDirectory())
|
|
64
|
+
.map((entry) => entry.name)
|
|
65
|
+
.sort();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function runCase(caseName) {
|
|
69
|
+
const caseDefinition = JSON.parse(await readFile(join(casesRoot, caseName, "case.json"), "utf8"));
|
|
70
|
+
const caseRoot = join(tempRoot, caseName);
|
|
71
|
+
const workRoot = join(caseRoot, "work");
|
|
72
|
+
const patchPath = join(caseRoot, "patch.blockpatch");
|
|
73
|
+
await mkdir(workRoot, { recursive: true });
|
|
74
|
+
await writeCaseFiles(workRoot, caseDefinition.files ?? {});
|
|
75
|
+
await writeFile(patchPath, Buffer.from(requiredString(caseDefinition.patch, caseName, "patch"), "utf8"));
|
|
76
|
+
|
|
77
|
+
const initialFiles = caseDefinition.files ?? {};
|
|
78
|
+
const expectedFiles = caseDefinition.expected_files ?? initialFiles;
|
|
79
|
+
const expectedAbsent = caseDefinition.expected_absent ?? [];
|
|
80
|
+
const expect = caseDefinition.expect ?? "pass";
|
|
81
|
+
|
|
82
|
+
if (expect === "pass") {
|
|
83
|
+
await expectSuccess(caseName, "check", [patchPath, "--cwd", workRoot]);
|
|
84
|
+
await expectTree(caseName, workRoot, initialFiles, []);
|
|
85
|
+
await expectSuccess(caseName, "apply", [patchPath, "--cwd", workRoot]);
|
|
86
|
+
await expectTree(caseName, workRoot, expectedFiles, expectedAbsent);
|
|
87
|
+
await expectSuccess(caseName, "retry", [patchPath, "--cwd", workRoot]);
|
|
88
|
+
await expectTree(caseName, workRoot, expectedFiles, expectedAbsent);
|
|
89
|
+
await expectSuccess(caseName, "reverse", [patchPath, "--cwd", workRoot, "--reverse"]);
|
|
90
|
+
await expectTree(caseName, workRoot, initialFiles, []);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (expect === "fail") {
|
|
95
|
+
await expectFailure(caseName, "check", [patchPath, "--cwd", workRoot], caseDefinition.error_code);
|
|
96
|
+
await expectTree(caseName, workRoot, initialFiles, []);
|
|
97
|
+
await expectFailure(caseName, "apply", [patchPath, "--cwd", workRoot], caseDefinition.error_code);
|
|
98
|
+
await expectTree(caseName, workRoot, initialFiles, []);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
throw new Error(`${caseName}: unknown expect value ${JSON.stringify(expect)}`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function expectSuccess(caseName, label, args) {
|
|
106
|
+
const result = runBlockpatch(commandArgs(label, args));
|
|
107
|
+
if (result.status !== 0) {
|
|
108
|
+
throw commandError(caseName, label, result);
|
|
109
|
+
}
|
|
110
|
+
const json = parseJson(caseName, label, result.stdout);
|
|
111
|
+
if (json.ok !== true) {
|
|
112
|
+
throw new Error(`${caseName}: ${label} returned non-ok JSON ${JSON.stringify(json)}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function expectFailure(caseName, label, args, errorCode) {
|
|
117
|
+
const result = runBlockpatch(commandArgs(label, args));
|
|
118
|
+
if (result.status === 0) {
|
|
119
|
+
throw new Error(`${caseName}: ${label} succeeded but expected failure`);
|
|
120
|
+
}
|
|
121
|
+
const json = parseJson(caseName, label, result.stderr || result.stdout);
|
|
122
|
+
if (json.ok !== false) {
|
|
123
|
+
throw new Error(`${caseName}: ${label} failure returned non-error JSON ${JSON.stringify(json)}`);
|
|
124
|
+
}
|
|
125
|
+
if (errorCode !== undefined && json.error?.code !== errorCode) {
|
|
126
|
+
throw new Error(`${caseName}: ${label} expected error ${errorCode}, got ${String(json.error?.code)}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function commandArgs(label, args) {
|
|
131
|
+
return [label === "check" ? "check" : "apply", ...args];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function runBlockpatch(args) {
|
|
135
|
+
return spawnSync(implementation.command, [...implementation.args, ...args, "--json-output"], {
|
|
136
|
+
cwd: invocationCwd,
|
|
137
|
+
encoding: "utf8",
|
|
138
|
+
maxBuffer: 1024 * 1024,
|
|
139
|
+
shell: process.platform === "win32"
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function commandError(caseName, label, result) {
|
|
144
|
+
if (result.error !== undefined) {
|
|
145
|
+
return result.error;
|
|
146
|
+
}
|
|
147
|
+
return new Error(
|
|
148
|
+
`${caseName}: ${label} failed\n` +
|
|
149
|
+
`exit: ${String(result.status)}\n` +
|
|
150
|
+
`stdout:\n${result.stdout}\n` +
|
|
151
|
+
`stderr:\n${result.stderr}`
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function parseJson(caseName, label, text) {
|
|
156
|
+
try {
|
|
157
|
+
return JSON.parse(text);
|
|
158
|
+
} catch (error) {
|
|
159
|
+
throw new Error(`${caseName}: ${label} did not return JSON: ${text.trim()}\n${String(error)}`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function writeCaseFiles(root, files) {
|
|
164
|
+
for (const [path, text] of Object.entries(files)) {
|
|
165
|
+
const absolute = join(root, path);
|
|
166
|
+
await mkdir(dirname(absolute), { recursive: true });
|
|
167
|
+
await writeFile(absolute, Buffer.from(requiredString(text, path, "file text"), "utf8"));
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function expectTree(caseName, root, expectedFiles, expectedAbsent) {
|
|
172
|
+
const actualPaths = await listFiles(root);
|
|
173
|
+
const expectedPaths = Object.keys(expectedFiles).sort();
|
|
174
|
+
assertEqual(caseName, "file list", actualPaths, expectedPaths);
|
|
175
|
+
|
|
176
|
+
for (const [path, text] of Object.entries(expectedFiles)) {
|
|
177
|
+
const expected = Buffer.from(requiredString(text, path, "expected file text"), "utf8");
|
|
178
|
+
const actual = await readFile(join(root, path));
|
|
179
|
+
if (!actual.equals(expected)) {
|
|
180
|
+
throw new Error(`${caseName}: ${path} bytes differ`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
for (const path of expectedAbsent) {
|
|
185
|
+
try {
|
|
186
|
+
await access(join(root, path), constants.F_OK);
|
|
187
|
+
} catch {
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
throw new Error(`${caseName}: expected ${path} to be absent`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function listFiles(root, prefix = "") {
|
|
195
|
+
const entries = await readdir(join(root, prefix), { withFileTypes: true });
|
|
196
|
+
const paths = [];
|
|
197
|
+
for (const entry of entries) {
|
|
198
|
+
const path = prefix === "" ? entry.name : `${prefix}/${entry.name}`;
|
|
199
|
+
if (entry.isDirectory()) {
|
|
200
|
+
paths.push(...await listFiles(root, path));
|
|
201
|
+
} else if (entry.isFile()) {
|
|
202
|
+
paths.push(path);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return paths.sort();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function requiredString(value, label, field) {
|
|
209
|
+
if (typeof value !== "string") {
|
|
210
|
+
throw new Error(`${label}: ${field} must be a string`);
|
|
211
|
+
}
|
|
212
|
+
return value;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function assertEqual(caseName, label, actual, expected) {
|
|
216
|
+
const actualJson = JSON.stringify(actual);
|
|
217
|
+
const expectedJson = JSON.stringify(expected);
|
|
218
|
+
if (actualJson !== expectedJson) {
|
|
219
|
+
throw new Error(`${caseName}: ${label} expected ${expectedJson}, got ${actualJson}`);
|
|
220
|
+
}
|
|
221
|
+
}
|