@saeeol/core 7.3.2 → 7.3.5

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/test/npm.test.ts DELETED
@@ -1,91 +0,0 @@
1
- import fs from "fs/promises"
2
- import path from "path"
3
- import { describe, expect, test } from "bun:test"
4
- import { NodeFileSystem } from "@effect/platform-node"
5
- import { Effect, Layer, Option } from "effect"
6
- import { AppFileSystem } from "@saeeol/core/filesystem"
7
- import { Global } from "@saeeol/core/global"
8
- import { Npm } from "@saeeol/core/npm"
9
- import { EffectFlock } from "@saeeol/core/util/effect-flock"
10
- import { tmpdir } from "./fixture/tmpdir"
11
-
12
- const win = process.platform === "win32"
13
-
14
- const writePackage = (dir: string, pkg: Record<string, unknown>) =>
15
- Bun.write(
16
- path.join(dir, "package.json"),
17
- JSON.stringify({
18
- version: "1.0.0",
19
- ...pkg,
20
- }),
21
- )
22
-
23
- const npmLayer = (cache: string) =>
24
- Npm.layer.pipe(
25
- Layer.provide(EffectFlock.layer),
26
- Layer.provide(AppFileSystem.layer),
27
- Layer.provide(Global.layerWith({ cache, state: path.join(cache, "state") })),
28
- Layer.provide(NodeFileSystem.layer),
29
- )
30
-
31
- describe("Npm.sanitize", () => {
32
- test("keeps normal scoped package specs unchanged", () => {
33
- expect(Npm.sanitize("@saeeol/acme")).toBe("@saeeol/acme")
34
- expect(Npm.sanitize("@saeeol/acme@1.0.0")).toBe("@saeeol/acme@1.0.0")
35
- expect(Npm.sanitize("prettier")).toBe("prettier")
36
- })
37
-
38
- test("handles git https specs", () => {
39
- const spec = "acme@git+https://github.com/saeeol/acme.git"
40
- const expected = win ? "acme@git+https_//github.com/saeeol/acme.git" : spec
41
- expect(Npm.sanitize(spec)).toBe(expected)
42
- })
43
- })
44
-
45
- describe("Npm.add", () => {
46
- test("reifies when package cache directory exists without the package installed", async () => {
47
- await using tmp = await tmpdir()
48
- await fs.mkdir(path.join(tmp.path, "fixture-provider"))
49
- await writePackage(path.join(tmp.path, "fixture-provider"), {
50
- name: "fixture-provider",
51
- main: "index.js",
52
- })
53
- await Bun.write(path.join(tmp.path, "fixture-provider", "index.js"), "export const fixture = true\n")
54
-
55
- const spec = `fixture-provider@file:${path.join(tmp.path, "fixture-provider")}`
56
- await fs.mkdir(path.join(tmp.path, "cache", "packages", Npm.sanitize(spec)), { recursive: true })
57
-
58
- const entry = await Effect.gen(function* () {
59
- const npm = yield* Npm.Service
60
- return yield* npm.add(spec)
61
- }).pipe(Effect.scoped, Effect.provide(npmLayer(path.join(tmp.path, "cache"))), Effect.runPromise)
62
-
63
- expect(Option.isSome(entry.entrypoint)).toBe(true)
64
- })
65
- })
66
-
67
- describe("Npm.install", () => {
68
- test("respects omit from project .npmrc", async () => {
69
- await using tmp = await tmpdir()
70
-
71
- await writePackage(tmp.path, {
72
- name: "fixture",
73
- dependencies: {
74
- "prod-pkg": "file:./prod-pkg",
75
- },
76
- devDependencies: {
77
- "dev-pkg": "file:./dev-pkg",
78
- },
79
- })
80
- await Bun.write(path.join(tmp.path, ".npmrc"), "omit=dev\n")
81
- await fs.mkdir(path.join(tmp.path, "prod-pkg"))
82
- await fs.mkdir(path.join(tmp.path, "dev-pkg"))
83
- await writePackage(path.join(tmp.path, "prod-pkg"), { name: "prod-pkg" })
84
- await writePackage(path.join(tmp.path, "dev-pkg"), { name: "dev-pkg" })
85
-
86
- await Npm.install(tmp.path)
87
-
88
- await expect(fs.stat(path.join(tmp.path, "node_modules", "prod-pkg"))).resolves.toBeDefined()
89
- await expect(fs.stat(path.join(tmp.path, "node_modules", "dev-pkg"))).rejects.toThrow()
90
- })
91
- })
@@ -1,13 +0,0 @@
1
- import { describe, expect, test } from "bun:test"
2
- import { AppFileSystem } from "@saeeol/core/filesystem"
3
-
4
- describe("saeeol filesystem containment", () => {
5
- test("keeps dot-prefixed child names internal", () => {
6
- expect(AppFileSystem.contains("/a/b", "/a/b/..cache/file")).toBe(true)
7
- })
8
-
9
- test("rejects cross-drive paths on Windows", () => {
10
- if (process.platform !== "win32") return
11
- expect(AppFileSystem.contains("C:\\repo", "D:\\outside\\file.txt")).toBe(false)
12
- })
13
- })
@@ -1,13 +0,0 @@
1
- import { describe, expect, test } from "bun:test"
2
- import { AppFileSystem } from "@saeeol/core/filesystem"
3
-
4
- describe("saeeol filesystem containment", () => {
5
- test("keeps dot-prefixed child names internal", () => {
6
- expect(AppFileSystem.contains("/a/b", "/a/b/..cache/file")).toBe(true)
7
- })
8
-
9
- test("rejects cross-drive paths on Windows", () => {
10
- if (process.platform !== "win32") return
11
- expect(AppFileSystem.contains("C:\\repo", "D:\\outside\\file.txt")).toBe(false)
12
- })
13
- })
@@ -1,386 +0,0 @@
1
- import { describe, expect } from "bun:test"
2
- import { spawn } from "child_process"
3
- import fs from "fs/promises"
4
- import path from "path"
5
- import os from "os"
6
- import { Cause, Effect, Exit, Layer } from "effect"
7
- import { testEffect } from "../lib/effect"
8
- import { AppFileSystem } from "@saeeol/core/filesystem"
9
- import { EffectFlock } from "@saeeol/core/util/effect-flock"
10
- import { Global } from "@saeeol/core/global"
11
- import { Hash } from "@saeeol/core/util/hash"
12
-
13
- function lock(dir: string, key: string) {
14
- return path.join(dir, Hash.fast(key) + ".lock")
15
- }
16
-
17
- function sleep(ms: number) {
18
- return new Promise<void>((resolve) => setTimeout(resolve, ms))
19
- }
20
-
21
- async function exists(file: string) {
22
- return fs
23
- .stat(file)
24
- .then(() => true)
25
- .catch(() => false)
26
- }
27
-
28
- async function readJson<T>(p: string): Promise<T> {
29
- return JSON.parse(await fs.readFile(p, "utf8"))
30
- }
31
-
32
- // ---------------------------------------------------------------------------
33
- // Worker subprocess helpers
34
- // ---------------------------------------------------------------------------
35
-
36
- type Msg = {
37
- key: string
38
- dir: string
39
- holdMs?: number
40
- ready?: string
41
- active?: string
42
- done?: string
43
- }
44
-
45
- const root = path.join(import.meta.dir, "../..")
46
- const worker = path.join(import.meta.dir, "../fixture/effect-flock-worker.ts")
47
-
48
- function run(msg: Msg) {
49
- return new Promise<{ code: number; stdout: Buffer; stderr: Buffer }>((resolve) => {
50
- const proc = spawn(process.execPath, [worker, JSON.stringify(msg)], { cwd: root })
51
- const stdout: Buffer[] = []
52
- const stderr: Buffer[] = []
53
- proc.stdout?.on("data", (data) => stdout.push(Buffer.from(data)))
54
- proc.stderr?.on("data", (data) => stderr.push(Buffer.from(data)))
55
- proc.on("close", (code) => {
56
- resolve({ code: code ?? 1, stdout: Buffer.concat(stdout), stderr: Buffer.concat(stderr) })
57
- })
58
- })
59
- }
60
-
61
- function spawnWorker(msg: Msg) {
62
- return spawn(process.execPath, [worker, JSON.stringify(msg)], {
63
- cwd: root,
64
- stdio: ["ignore", "pipe", "pipe"],
65
- })
66
- }
67
-
68
- function stopWorker(proc: ReturnType<typeof spawnWorker>) {
69
- if (proc.exitCode !== null || proc.signalCode !== null) return Promise.resolve()
70
- if (process.platform !== "win32" || !proc.pid) {
71
- proc.kill()
72
- return Promise.resolve()
73
- }
74
- return new Promise<void>((resolve) => {
75
- const killProc = spawn("taskkill", ["/pid", String(proc.pid), "/T", "/F"])
76
- killProc.on("close", () => {
77
- proc.kill()
78
- resolve()
79
- })
80
- })
81
- }
82
-
83
- async function waitForFile(file: string, timeout = 3_000) {
84
- const stop = Date.now() + timeout
85
- while (Date.now() < stop) {
86
- if (await exists(file)) return
87
- await sleep(20)
88
- }
89
- throw new Error(`Timed out waiting for file: ${file}`)
90
- }
91
-
92
- // ---------------------------------------------------------------------------
93
- // Test layer
94
- // ---------------------------------------------------------------------------
95
-
96
- const testGlobal = Global.layerWith({
97
- home: os.homedir(),
98
- data: os.tmpdir(),
99
- cache: os.tmpdir(),
100
- config: os.tmpdir(),
101
- state: os.tmpdir(),
102
- bin: os.tmpdir(),
103
- log: os.tmpdir(),
104
- })
105
-
106
- const testLayer = EffectFlock.layer.pipe(Layer.provide(testGlobal), Layer.provide(AppFileSystem.defaultLayer))
107
-
108
- // ---------------------------------------------------------------------------
109
- // Tests
110
- // ---------------------------------------------------------------------------
111
-
112
- describe("util.effect-flock", () => {
113
- const it = testEffect(testLayer)
114
-
115
- it.live(
116
- "acquire and release via scoped Effect",
117
- Effect.gen(function* () {
118
- const flock = yield* EffectFlock.Service
119
- const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-")))
120
- const dir = path.join(tmp, "locks")
121
- const lockDir = lock(dir, "eflock:acquire")
122
-
123
- yield* Effect.scoped(flock.acquire("eflock:acquire", dir))
124
-
125
- expect(yield* Effect.promise(() => exists(lockDir))).toBe(false)
126
- yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true }))
127
- }),
128
- )
129
-
130
- it.live(
131
- "withLock data-first",
132
- Effect.gen(function* () {
133
- const flock = yield* EffectFlock.Service
134
- const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-")))
135
- const dir = path.join(tmp, "locks")
136
-
137
- let hit = false
138
- yield* flock.withLock(
139
- Effect.sync(() => {
140
- hit = true
141
- }),
142
- "eflock:df",
143
- dir,
144
- )
145
- expect(hit).toBe(true)
146
- yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true }))
147
- }),
148
- )
149
-
150
- it.live(
151
- "withLock pipeable",
152
- Effect.gen(function* () {
153
- const flock = yield* EffectFlock.Service
154
- const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-")))
155
- const dir = path.join(tmp, "locks")
156
-
157
- let hit = false
158
- yield* Effect.sync(() => {
159
- hit = true
160
- }).pipe(flock.withLock("eflock:pipe", dir))
161
- expect(hit).toBe(true)
162
- yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true }))
163
- }),
164
- )
165
-
166
- it.live(
167
- "writes owner metadata",
168
- Effect.gen(function* () {
169
- const flock = yield* EffectFlock.Service
170
- const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-")))
171
- const dir = path.join(tmp, "locks")
172
- const key = "eflock:meta"
173
- const file = path.join(lock(dir, key), "meta.json")
174
-
175
- yield* Effect.scoped(
176
- Effect.gen(function* () {
177
- yield* flock.acquire(key, dir)
178
- const json = yield* Effect.promise(() =>
179
- readJson<{ token?: unknown; pid?: unknown; hostname?: unknown; createdAt?: unknown }>(file),
180
- )
181
- expect(typeof json.token).toBe("string")
182
- expect(typeof json.pid).toBe("number")
183
- expect(typeof json.hostname).toBe("string")
184
- expect(typeof json.createdAt).toBe("string")
185
- }),
186
- )
187
- yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true }))
188
- }),
189
- )
190
-
191
- it.live(
192
- "breaks stale lock dirs",
193
- Effect.gen(function* () {
194
- const flock = yield* EffectFlock.Service
195
- const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-")))
196
- const dir = path.join(tmp, "locks")
197
- const key = "eflock:stale"
198
- const lockDir = lock(dir, key)
199
-
200
- yield* Effect.promise(async () => {
201
- await fs.mkdir(lockDir, { recursive: true })
202
- const old = new Date(Date.now() - 120_000)
203
- await fs.utimes(lockDir, old, old)
204
- })
205
-
206
- let hit = false
207
- yield* flock.withLock(
208
- Effect.sync(() => {
209
- hit = true
210
- }),
211
- key,
212
- dir,
213
- )
214
- expect(hit).toBe(true)
215
- yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true }))
216
- }),
217
- )
218
-
219
- it.live(
220
- "recovers from stale breaker",
221
- Effect.gen(function* () {
222
- const flock = yield* EffectFlock.Service
223
- const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-")))
224
- const dir = path.join(tmp, "locks")
225
- const key = "eflock:stale-breaker"
226
- const lockDir = lock(dir, key)
227
- const breaker = lockDir + ".breaker"
228
-
229
- yield* Effect.promise(async () => {
230
- await fs.mkdir(lockDir, { recursive: true })
231
- await fs.mkdir(breaker)
232
- const old = new Date(Date.now() - 120_000)
233
- await fs.utimes(lockDir, old, old)
234
- await fs.utimes(breaker, old, old)
235
- })
236
-
237
- let hit = false
238
- yield* flock.withLock(
239
- Effect.sync(() => {
240
- hit = true
241
- }),
242
- key,
243
- dir,
244
- )
245
- expect(hit).toBe(true)
246
- expect(yield* Effect.promise(() => exists(breaker))).toBe(false)
247
- yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true }))
248
- }),
249
- )
250
-
251
- it.live(
252
- "detects compromise when lock dir removed",
253
- Effect.gen(function* () {
254
- const flock = yield* EffectFlock.Service
255
- const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-")))
256
- const dir = path.join(tmp, "locks")
257
- const key = "eflock:compromised"
258
- const lockDir = lock(dir, key)
259
-
260
- const result = yield* flock
261
- .withLock(
262
- Effect.promise(() => fs.rm(lockDir, { recursive: true, force: true })),
263
- key,
264
- dir,
265
- )
266
- .pipe(Effect.exit)
267
-
268
- expect(Exit.isFailure(result)).toBe(true)
269
- expect(Exit.isFailure(result) ? Cause.pretty(result.cause) : "").toContain("missing")
270
- yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true }))
271
- }),
272
- )
273
-
274
- it.live(
275
- "detects token mismatch",
276
- Effect.gen(function* () {
277
- const flock = yield* EffectFlock.Service
278
- const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-")))
279
- const dir = path.join(tmp, "locks")
280
- const key = "eflock:token"
281
- const lockDir = lock(dir, key)
282
- const meta = path.join(lockDir, "meta.json")
283
-
284
- const result = yield* flock
285
- .withLock(
286
- Effect.promise(async () => {
287
- const json = await readJson<{ token?: string }>(meta)
288
- json.token = "tampered"
289
- await fs.writeFile(meta, JSON.stringify(json, null, 2))
290
- }),
291
- key,
292
- dir,
293
- )
294
- .pipe(Effect.exit)
295
-
296
- expect(Exit.isFailure(result)).toBe(true)
297
- expect(Exit.isFailure(result) ? Cause.pretty(result.cause) : "").toContain("token mismatch")
298
- expect(yield* Effect.promise(() => exists(lockDir))).toBe(true)
299
- yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true }))
300
- }),
301
- )
302
-
303
- it.live(
304
- "fails on unwritable lock roots",
305
- Effect.gen(function* () {
306
- if (process.platform === "win32") return
307
- const flock = yield* EffectFlock.Service
308
- const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-")))
309
- const dir = path.join(tmp, "locks")
310
-
311
- yield* Effect.promise(async () => {
312
- await fs.mkdir(dir, { recursive: true })
313
- await fs.chmod(dir, 0o500)
314
- })
315
-
316
- const result = yield* flock.withLock(Effect.void, "eflock:perm", dir).pipe(Effect.exit)
317
- // oxlint-disable-next-line no-base-to-string -- Exit has a useful toString for test assertions
318
- expect(String(result)).toContain("PermissionDenied")
319
- yield* Effect.promise(() => fs.chmod(dir, 0o700).then(() => fs.rm(tmp, { recursive: true, force: true })))
320
- }),
321
- )
322
-
323
- it.live(
324
- "enforces mutual exclusion under process contention",
325
- () =>
326
- Effect.promise(async () => {
327
- const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "eflock-stress-"))
328
- const dir = path.join(tmp, "locks")
329
- const done = path.join(tmp, "done.log")
330
- const active = path.join(tmp, "active")
331
- const n = 16
332
-
333
- try {
334
- const out = await Promise.all(
335
- Array.from({ length: n }, () => run({ key: "eflock:stress", dir, done, active, holdMs: 30 })),
336
- )
337
-
338
- expect(out.map((x) => x.code)).toEqual(Array.from({ length: n }, () => 0))
339
- expect(out.map((x) => x.stderr.toString()).filter(Boolean)).toEqual([])
340
-
341
- const lines = (await fs.readFile(done, "utf8"))
342
- .split("\n")
343
- .map((x) => x.trim())
344
- .filter(Boolean)
345
- expect(lines.length).toBe(n)
346
- } finally {
347
- await fs.rm(tmp, { recursive: true, force: true })
348
- }
349
- }),
350
- 60_000,
351
- )
352
-
353
- it.live(
354
- "recovers after a crashed lock owner",
355
- () =>
356
- Effect.promise(async () => {
357
- const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "eflock-crash-"))
358
- const dir = path.join(tmp, "locks")
359
- const ready = path.join(tmp, "ready")
360
-
361
- const proc = spawnWorker({ key: "eflock:crash", dir, ready, holdMs: 120_000 })
362
-
363
- try {
364
- await waitForFile(ready, 5_000)
365
- await stopWorker(proc)
366
- await new Promise((resolve) => proc.on("close", resolve))
367
-
368
- // Backdate lock files so they're past STALE_MS (60s)
369
- const lockDir = lock(dir, "eflock:crash")
370
- const old = new Date(Date.now() - 120_000)
371
- await fs.utimes(lockDir, old, old).catch(() => {})
372
- await fs.utimes(path.join(lockDir, "heartbeat"), old, old).catch(() => {})
373
- await fs.utimes(path.join(lockDir, "meta.json"), old, old).catch(() => {})
374
-
375
- const done = path.join(tmp, "done.log")
376
- const result = await run({ key: "eflock:crash", dir, done, holdMs: 10 })
377
- expect(result.code).toBe(0)
378
- expect(result.stderr.toString()).toBe("")
379
- } finally {
380
- await stopWorker(proc).catch(() => {})
381
- await fs.rm(tmp, { recursive: true, force: true })
382
- }
383
- }),
384
- 30_000,
385
- )
386
- })