Skip to content

Commit 82b1320

Browse files
authored
Merge pull request #8 from fbosch/feature/multi-platform-tests
ci: add multi-platform test matrix
2 parents d3e599a + 0e4dca3 commit 82b1320

9 files changed

Lines changed: 136 additions & 7 deletions

File tree

.gitattributes

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
* text=auto eol=lf

.github/workflows/ci.yml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,11 @@ on:
99

1010
jobs:
1111
build:
12-
runs-on: ubuntu-latest
12+
runs-on: ${{ matrix.os }}
13+
strategy:
14+
fail-fast: false
15+
matrix:
16+
os: [ubuntu-latest, macos-latest, windows-latest]
1317
steps:
1418
- name: Checkout
1519
uses: actions/checkout@v4

src/api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,5 @@ export { pruneCache } from "./prune";
88
export { removeSources } from "./remove";
99
export { resolveRepoInput } from "./resolve-repo";
1010
export { printSyncPlan, runSync } from "./sync";
11+
export { applyTargetDir } from "./targets";
1112
export { verifyCache } from "./verify";

src/materialize.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,29 @@ export const materializeSource = async (params: MaterializeParams) => {
101101
const tempDir = await mkdtemp(
102102
path.join(params.cacheDir, `.tmp-${params.sourceId}-`),
103103
);
104+
let manifestStreamRef: ReturnType<typeof createWriteStream> | null = null;
105+
const closeManifestStream = async () => {
106+
const stream = manifestStreamRef;
107+
if (!stream || stream.closed || stream.destroyed) {
108+
return;
109+
}
110+
await new Promise<void>((resolve) => {
111+
const cleanup = () => {
112+
stream.off("close", onClose);
113+
stream.off("error", onError);
114+
resolve();
115+
};
116+
const onClose = () => cleanup();
117+
const onError = () => cleanup();
118+
stream.once("close", onClose);
119+
stream.once("error", onError);
120+
try {
121+
stream.end();
122+
} catch {
123+
cleanup();
124+
}
125+
});
126+
};
104127

105128
try {
106129
const files = await fg(params.include, {
@@ -132,6 +155,7 @@ export const materializeSource = async (params: MaterializeParams) => {
132155
const manifestStream = createWriteStream(manifestPath, {
133156
encoding: "utf8",
134157
});
158+
manifestStreamRef = manifestStream;
135159
const manifestHash = createHash("sha256");
136160
const writeManifestLine = async (line: string) => {
137161
return new Promise<void>((resolve, reject) => {
@@ -267,6 +291,11 @@ export const materializeSource = async (params: MaterializeParams) => {
267291
manifestSha256,
268292
};
269293
} catch (error) {
294+
try {
295+
await closeManifestStream();
296+
} catch {
297+
// Ignore cleanup errors to preserve root cause.
298+
}
270299
await rm(tempDir, { recursive: true, force: true });
271300
throw error;
272301
}

src/sync.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,7 @@ export const runSync = async (options: SyncOptions, deps: SyncDeps = {}) => {
304304
sourceDir: path.join(plan.cacheDir, source.id),
305305
targetDir: resolvedTarget,
306306
mode: source.targetMode ?? defaults.targetMode,
307+
explicitTargetMode: source.targetMode !== undefined,
307308
});
308309
}),
309310
);
@@ -388,6 +389,7 @@ export const runSync = async (options: SyncOptions, deps: SyncDeps = {}) => {
388389
sourceDir: path.join(plan.cacheDir, source.id),
389390
targetDir: resolvedTarget,
390391
mode: source.targetMode ?? defaults.targetMode,
392+
explicitTargetMode: source.targetMode !== undefined,
391393
});
392394
}
393395
result.bytes = stats.bytes;

src/targets.ts

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,61 @@
11
import { cp, mkdir, rm, symlink } from "node:fs/promises";
22
import path from "node:path";
33

4+
type TargetDeps = {
5+
cp: typeof cp;
6+
mkdir: typeof mkdir;
7+
rm: typeof rm;
8+
symlink: typeof symlink;
9+
stderr: NodeJS.WritableStream;
10+
};
11+
412
type TargetParams = {
513
sourceDir: string;
614
targetDir: string;
715
mode?: "symlink" | "copy";
16+
explicitTargetMode?: boolean;
17+
deps?: TargetDeps;
818
};
919

10-
const removeTarget = async (targetDir: string) => {
11-
await rm(targetDir, { recursive: true, force: true });
20+
const removeTarget = async (targetDir: string, deps: TargetDeps) => {
21+
await deps.rm(targetDir, { recursive: true, force: true });
1222
};
1323

1424
export const applyTargetDir = async (params: TargetParams) => {
25+
const deps = params.deps ?? {
26+
cp,
27+
mkdir,
28+
rm,
29+
symlink,
30+
stderr: process.stderr,
31+
};
1532
const parentDir = path.dirname(params.targetDir);
16-
await mkdir(parentDir, { recursive: true });
17-
await removeTarget(params.targetDir);
33+
await deps.mkdir(parentDir, { recursive: true });
34+
await removeTarget(params.targetDir, deps);
1835

1936
const defaultMode = process.platform === "win32" ? "copy" : "symlink";
2037
const mode = params.mode ?? defaultMode;
2138
if (mode === "copy") {
22-
await cp(params.sourceDir, params.targetDir, { recursive: true });
39+
await deps.cp(params.sourceDir, params.targetDir, { recursive: true });
2340
return;
2441
}
2542

2643
const type = process.platform === "win32" ? "junction" : "dir";
27-
await symlink(params.sourceDir, params.targetDir, type);
44+
try {
45+
await deps.symlink(params.sourceDir, params.targetDir, type);
46+
} catch (error) {
47+
const code = (error as NodeJS.ErrnoException).code;
48+
const fallbackCodes = new Set(["EPERM", "EACCES", "ENOTSUP", "EINVAL"]);
49+
if (code && fallbackCodes.has(code)) {
50+
if (params.explicitTargetMode) {
51+
const message = error instanceof Error ? error.message : String(error);
52+
deps.stderr.write(
53+
`Warning: Failed to create symlink at ${params.targetDir}. Falling back to copy. ${message}\n`,
54+
);
55+
}
56+
await deps.cp(params.sourceDir, params.targetDir, { recursive: true });
57+
return;
58+
}
59+
throw error;
60+
}
2861
};

tests/edge-cases-validation.test.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,14 @@ test("targetDir with Windows-style path is allowed", async () => {
114114
],
115115
});
116116

117+
if (process.platform === "win32") {
118+
await assert.rejects(
119+
() => loadConfig(configPath),
120+
/targetDir.*escapes project directory/i,
121+
);
122+
return;
123+
}
124+
117125
const { sources } = await loadConfig(configPath);
118126
assert.equal(sources[0].targetDir, "C:\\Users\\test\\docs");
119127
});

tests/sync-targets.test.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,10 @@ test("sync applies targetDir with copy mode", async () => {
7272
});
7373

7474
test("sync applies targetDir with symlink mode", async () => {
75+
if (process.platform === "win32") {
76+
return;
77+
}
78+
7579
const tmpRoot = path.join(
7680
tmpdir(),
7781
`docs-cache-target-link-${Date.now().toString(36)}`,

tests/targets.test.js

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import assert from "node:assert/strict";
2+
import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
3+
import { tmpdir } from "node:os";
4+
import path from "node:path";
5+
import { test } from "node:test";
6+
7+
import { applyTargetDir } from "../dist/api.mjs";
8+
9+
test("applyTargetDir warns and falls back to copy when symlink fails", async () => {
10+
const tmpRoot = path.join(
11+
tmpdir(),
12+
`docs-cache-target-fallback-${Date.now().toString(36)}`,
13+
);
14+
const sourceDir = path.join(tmpRoot, "source");
15+
const targetDir = path.join(tmpRoot, "target");
16+
17+
await mkdir(sourceDir, { recursive: true });
18+
await writeFile(path.join(sourceDir, "README.md"), "hello", "utf8");
19+
20+
let stderr = "";
21+
await applyTargetDir({
22+
sourceDir,
23+
targetDir,
24+
mode: "symlink",
25+
explicitTargetMode: true,
26+
deps: {
27+
cp,
28+
mkdir,
29+
rm,
30+
symlink: async () => {
31+
const error = new Error("symlink blocked");
32+
error.code = "EPERM";
33+
throw error;
34+
},
35+
stderr: {
36+
write: (chunk) => {
37+
stderr += String(chunk);
38+
return true;
39+
},
40+
},
41+
},
42+
});
43+
44+
const data = await readFile(path.join(targetDir, "README.md"), "utf8");
45+
assert.equal(data, "hello");
46+
assert.match(stderr, /Warning: Failed to create symlink/i);
47+
});

0 commit comments

Comments
 (0)