@hoardodile/host 0.0.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.
Files changed (144) hide show
  1. package/LICENSE +18 -0
  2. package/README.md +73 -0
  3. package/dist/chunks/worker-entry.mjs +422 -0
  4. package/dist/container-fKxefO7p.d.ts +58 -0
  5. package/dist/contract/index.d.ts +49 -0
  6. package/dist/contract/index.js +187 -0
  7. package/dist/contract/index.js.map +1 -0
  8. package/dist/hoard/index.d.ts +754 -0
  9. package/dist/hoard/index.js +1939 -0
  10. package/dist/hoard/index.js.map +1 -0
  11. package/dist/image-Dx2Be3Bj.d.ts +168 -0
  12. package/dist/index.d.ts +788 -0
  13. package/dist/index.js +3311 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/media/index.d.ts +96 -0
  16. package/dist/media/index.js +152 -0
  17. package/dist/media/index.js.map +1 -0
  18. package/dist/probe/index.d.ts +113 -0
  19. package/dist/probe/index.js +454 -0
  20. package/dist/probe/index.js.map +1 -0
  21. package/dist/probe-cache-BkRI7HEK.d.ts +25 -0
  22. package/dist/render/index.d.ts +125 -0
  23. package/dist/render/index.js +430 -0
  24. package/dist/render/index.js.map +1 -0
  25. package/package.json +103 -0
  26. package/src/__testutils__/zip-fixtures.ts +139 -0
  27. package/src/activation.ts +116 -0
  28. package/src/api-types.ts +60 -0
  29. package/src/api.test.ts +564 -0
  30. package/src/api.ts +501 -0
  31. package/src/archive/7z.test.ts +222 -0
  32. package/src/archive/7z.ts +261 -0
  33. package/src/archive/archive-ops.test.ts +654 -0
  34. package/src/archive/extract-archive.test.ts +237 -0
  35. package/src/archive/extract-archive.ts +534 -0
  36. package/src/archive/extract.ts +693 -0
  37. package/src/archive/format.ts +49 -0
  38. package/src/archive/index.ts +116 -0
  39. package/src/archive/listing.ts +121 -0
  40. package/src/archive/materialize.ts +88 -0
  41. package/src/archive/name-decode.test.ts +34 -0
  42. package/src/archive/name-decode.ts +287 -0
  43. package/src/archive/nested-cd-cache.ts +55 -0
  44. package/src/archive/nested-entry.test.ts +194 -0
  45. package/src/archive/nested-entry.ts +254 -0
  46. package/src/archive/pack.ts +40 -0
  47. package/src/archive/zip-entries.test.ts +56 -0
  48. package/src/archive/zip-entries.ts +336 -0
  49. package/src/capability-guard.ts +73 -0
  50. package/src/container.ts +62 -0
  51. package/src/contract/container-fixture.ts +74 -0
  52. package/src/contract/index.ts +10 -0
  53. package/src/contract/suite.test.ts +101 -0
  54. package/src/contract/suite.ts +205 -0
  55. package/src/define-plugin.test.ts +53 -0
  56. package/src/define-plugin.ts +12 -0
  57. package/src/dev-runner.test.ts +98 -0
  58. package/src/dev-runner.ts +73 -0
  59. package/src/directory-api.test.ts +229 -0
  60. package/src/directory-api.ts +42 -0
  61. package/src/directory-container.ts +141 -0
  62. package/src/discovery.ts +295 -0
  63. package/src/errors.ts +74 -0
  64. package/src/fixtures.ts +8 -0
  65. package/src/hash.test.ts +301 -0
  66. package/src/hash.ts +167 -0
  67. package/src/hoard/dir-size.ts +58 -0
  68. package/src/hoard/index.ts +84 -0
  69. package/src/hoard/migrate-script.test.ts +539 -0
  70. package/src/hoard/order-manifest.test.ts +115 -0
  71. package/src/hoard/order-manifest.ts +131 -0
  72. package/src/hoard/paths.test.ts +226 -0
  73. package/src/hoard/paths.ts +569 -0
  74. package/src/hoard/plugin-vault.test.ts +160 -0
  75. package/src/hoard/plugin-vault.ts +233 -0
  76. package/src/hoard/sanitize.test.ts +149 -0
  77. package/src/hoard/sanitize.ts +230 -0
  78. package/src/hoard/staging-dir.ts +133 -0
  79. package/src/hoard/version.test.ts +285 -0
  80. package/src/hoard/version.ts +216 -0
  81. package/src/hoard/versioned-folder-ops.ts +152 -0
  82. package/src/hoard/write-versioned.test.ts +67 -0
  83. package/src/hoard/write-versioned.ts +39 -0
  84. package/src/hooks.test.ts +528 -0
  85. package/src/hooks.ts +428 -0
  86. package/src/index.ts +135 -0
  87. package/src/loader.test.ts +442 -0
  88. package/src/loader.ts +213 -0
  89. package/src/media/index.ts +16 -0
  90. package/src/media/render-cache.ts +27 -0
  91. package/src/media/seekable.ts +35 -0
  92. package/src/media/thumb-input.test.ts +109 -0
  93. package/src/media/thumb-input.ts +194 -0
  94. package/src/nested-view.test.ts +223 -0
  95. package/src/nested-view.ts +239 -0
  96. package/src/probe/audio.test.ts +135 -0
  97. package/src/probe/audio.ts +140 -0
  98. package/src/probe/av.ts +84 -0
  99. package/src/probe/ffprobe.ts +197 -0
  100. package/src/probe/image.test.ts +251 -0
  101. package/src/probe/image.ts +336 -0
  102. package/src/probe/index.ts +54 -0
  103. package/src/probe/sniff.test.ts +72 -0
  104. package/src/probe/sniff.ts +67 -0
  105. package/src/probe/video.test.ts +41 -0
  106. package/src/probe/video.ts +116 -0
  107. package/src/probe-cache.test.ts +89 -0
  108. package/src/probe-cache.ts +65 -0
  109. package/src/render/ffmpeg.ts +78 -0
  110. package/src/render/index.ts +34 -0
  111. package/src/render/pipeline.test.ts +187 -0
  112. package/src/render/pipeline.ts +479 -0
  113. package/src/sandbox/call-timers.ts +68 -0
  114. package/src/sandbox/fixtures/api-error-plugin.mjs +11 -0
  115. package/src/sandbox/fixtures/asset-plugin.mjs +13 -0
  116. package/src/sandbox/fixtures/chatty-plugin.mjs +14 -0
  117. package/src/sandbox/fixtures/container-plugin.mjs +8 -0
  118. package/src/sandbox/fixtures/context-plugin.mjs +19 -0
  119. package/src/sandbox/fixtures/crash-plugin.mjs +6 -0
  120. package/src/sandbox/fixtures/echo-plugin.mjs +17 -0
  121. package/src/sandbox/fixtures/exit-plugin.mjs +6 -0
  122. package/src/sandbox/fixtures/fetch-plugin.mjs +8 -0
  123. package/src/sandbox/fixtures/flood-api-plugin.mjs +8 -0
  124. package/src/sandbox/fixtures/flood-log-plugin.mjs +8 -0
  125. package/src/sandbox/fixtures/hostile-dynamic-plugin.mjs +9 -0
  126. package/src/sandbox/fixtures/hostile-plugin.mjs +13 -0
  127. package/src/sandbox/fixtures/huge-result-plugin.mjs +8 -0
  128. package/src/sandbox/fixtures/logging-plugin.mjs +9 -0
  129. package/src/sandbox/fixtures/range-plugin.mjs +9 -0
  130. package/src/sandbox/fixtures/slow-api-plugin.mjs +11 -0
  131. package/src/sandbox/fixtures/spin-plugin.mjs +9 -0
  132. package/src/sandbox/fixtures/stuck-plugin.mjs +12 -0
  133. package/src/sandbox/fixtures/thrower-plugin.mjs +7 -0
  134. package/src/sandbox/host.test.ts +619 -0
  135. package/src/sandbox/host.ts +933 -0
  136. package/src/sandbox/host.unit.test.ts +330 -0
  137. package/src/sandbox/protocol.test.ts +85 -0
  138. package/src/sandbox/protocol.ts +147 -0
  139. package/src/sandbox/sandboxed-plugin.ts +30 -0
  140. package/src/sandbox/worker-entry.mjs +422 -0
  141. package/src/seed.test.ts +102 -0
  142. package/src/seed.ts +160 -0
  143. package/src/settings-store.ts +26 -0
  144. package/src/types.ts +21 -0
@@ -0,0 +1,564 @@
1
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
2
+ import { tmpdir } from "node:os"
3
+ import { join } from "node:path"
4
+ import { Readable } from "node:stream"
5
+ import sharp from "sharp"
6
+ import { afterEach, beforeEach, describe, expect, test } from "vitest"
7
+ import { createImportResourceAPI, createPluginResourceAPI } from "./api.ts"
8
+ import type { ResourceContainer } from "./container.ts"
9
+ import { createResourceAPIFixture } from "./fixtures.ts"
10
+ import { createProbeCache } from "./probe-cache.ts"
11
+ import type { ResourceAPI } from "./types.ts"
12
+
13
+ /**
14
+ * Minimal ranged chunk reader equivalent to the authoring helper
15
+ * `readFileChunks` from @hoardodile/sdk-server — the host cannot
16
+ * depend on the authoring package, and this mirrors its contract.
17
+ */
18
+ async function* readChunks(
19
+ api: ResourceAPI,
20
+ path: string,
21
+ opts: { readonly chunkSize: number },
22
+ ): AsyncGenerator<Uint8Array> {
23
+ let offset = 0
24
+ for (;;) {
25
+ const chunk = await api.readFile(path, {
26
+ start: offset,
27
+ end: offset + opts.chunkSize,
28
+ })
29
+ if (chunk.byteLength === 0) return
30
+ yield chunk
31
+ if (chunk.byteLength < opts.chunkSize) return
32
+ offset += chunk.byteLength
33
+ }
34
+ }
35
+
36
+ describe("createImportResourceAPI", () => {
37
+ let rootDir: string
38
+
39
+ beforeEach(() => {
40
+ rootDir = mkdtempSync(join(tmpdir(), "import-api-test-"))
41
+ mkdirSync(join(rootDir, "sub"))
42
+ writeFileSync(join(rootDir, "inside.txt"), "inside")
43
+ writeFileSync(join(rootDir, "sub", "nested.txt"), "nested")
44
+ })
45
+
46
+ afterEach(() => {
47
+ rmSync(rootDir, { recursive: true, force: true })
48
+ })
49
+
50
+ test("readFile allows paths inside the directory", async () => {
51
+ const api = createImportResourceAPI(rootDir)
52
+ const data = await api.readFile("inside.txt")
53
+ expect(new TextDecoder().decode(data)).toBe("inside")
54
+ })
55
+
56
+ test("readFile rejects parent directory traversal", async () => {
57
+ const api = createImportResourceAPI(rootDir)
58
+ await expect(api.readFile("../outside.txt")).rejects.toThrow(
59
+ "escapes import directory",
60
+ )
61
+ })
62
+
63
+ test("readFile rejects nested traversal", async () => {
64
+ const api = createImportResourceAPI(rootDir)
65
+ await expect(api.readFile("sub/../../outside.txt")).rejects.toThrow(
66
+ "escapes import directory",
67
+ )
68
+ })
69
+
70
+ test("readFile rejects absolute paths", async () => {
71
+ const api = createImportResourceAPI(rootDir)
72
+ await expect(api.readFile("/etc/passwd")).rejects.toThrow(
73
+ "absolute paths are not allowed",
74
+ )
75
+ })
76
+
77
+ test("readFile rejects empty paths", async () => {
78
+ const api = createImportResourceAPI(rootDir)
79
+ await expect(api.readFile("")).rejects.toThrow("path is empty")
80
+ })
81
+
82
+ test("readFile rejects null bytes", async () => {
83
+ const api = createImportResourceAPI(rootDir)
84
+ await expect(api.readFile("inside.txt\0extra")).rejects.toThrow("null byte")
85
+ })
86
+
87
+ test("statFile validates paths and reports real sizes", async () => {
88
+ const api = createImportResourceAPI(rootDir)
89
+ await expect(api.statFile("../outside.txt")).rejects.toThrow(
90
+ "escapes import directory",
91
+ )
92
+ expect(await api.statFile("inside.txt")).toEqual({ sizeBytes: 6 })
93
+ })
94
+
95
+ test("listFileNames stays within the directory", async () => {
96
+ const api = createImportResourceAPI(rootDir)
97
+ const files = await api.listFileNames()
98
+ expect([...files].sort()).toEqual(["inside.txt", "sub/nested.txt"])
99
+ })
100
+ })
101
+
102
+ describe("createImportResourceAPI ranged reads", () => {
103
+ let rootDir: string
104
+
105
+ beforeEach(() => {
106
+ rootDir = mkdtempSync(join(tmpdir(), "import-api-range-"))
107
+ writeFileSync(join(rootDir, "data.bin"), Buffer.from([1, 2, 3, 4, 250]))
108
+ })
109
+
110
+ afterEach(() => {
111
+ rmSync(rootDir, { recursive: true, force: true })
112
+ })
113
+
114
+ test("range returns the requested slice", async () => {
115
+ const api = createImportResourceAPI(rootDir)
116
+ expect([...(await api.readFile("data.bin", { start: 1, end: 4 }))]).toEqual(
117
+ [2, 3, 4],
118
+ )
119
+ })
120
+
121
+ test("end defaults to file size and clamps", async () => {
122
+ const api = createImportResourceAPI(rootDir)
123
+ expect([...(await api.readFile("data.bin", { start: 3 }))]).toEqual([
124
+ 4, 250,
125
+ ])
126
+ expect([
127
+ ...(await api.readFile("data.bin", { start: 0, end: 10_000 })),
128
+ ]).toEqual([1, 2, 3, 4, 250])
129
+ })
130
+
131
+ test("start past the end returns empty", async () => {
132
+ const api = createImportResourceAPI(rootDir)
133
+ expect(
134
+ (await api.readFile("data.bin", { start: 100, end: 200 })).byteLength,
135
+ ).toBe(0)
136
+ })
137
+
138
+ test("full reads above the byte cap are rejected with guidance", async () => {
139
+ const api = createImportResourceAPI(rootDir, { maxReadFileBytes: 4 })
140
+ await expect(api.readFile("data.bin")).rejects.toThrow(/byte range/)
141
+ })
142
+
143
+ test("ranged reads above the byte cap are rejected too", async () => {
144
+ const api = createImportResourceAPI(rootDir, { maxReadFileBytes: 3 })
145
+ await expect(
146
+ api.readFile("data.bin", { start: 0, end: 5 }),
147
+ ).rejects.toThrow(/byte range/)
148
+ await expect(
149
+ api.readFile("data.bin", { start: 0, end: 3 }),
150
+ ).resolves.toHaveLength(3)
151
+ })
152
+ })
153
+
154
+ describe("createPluginResourceAPI ranged reads", () => {
155
+ function stubView(content: readonly number[]): ResourceContainer {
156
+ const bytes = Buffer.from(content)
157
+ return {
158
+ listEntries: async () => ["blob.bin"],
159
+ readEntry: async () => bytes,
160
+ readEntrySlice: async (_relPath, start, end) =>
161
+ bytes.subarray(start, end),
162
+ openEntryStream: async () => {
163
+ throw new Error("not used in these tests")
164
+ },
165
+ resolveByteRange: async () => ({ size: bytes.byteLength }),
166
+ }
167
+ }
168
+
169
+ function stubApi(content: readonly number[], maxReadFileBytes?: number) {
170
+ return createPluginResourceAPI({
171
+ view: stubView(content),
172
+ probeImage: async () => undefined,
173
+ ...(maxReadFileBytes !== undefined ? { maxReadFileBytes } : {}),
174
+ })
175
+ }
176
+
177
+ test("full read delegates to readEntry", async () => {
178
+ const api = stubApi([1, 2, 3, 4, 250])
179
+ expect([...(await api.readFile("blob.bin"))]).toEqual([1, 2, 3, 4, 250])
180
+ })
181
+
182
+ test("ranged read maps to readEntrySlice with clamped end", async () => {
183
+ const api = stubApi([1, 2, 3, 4, 250])
184
+ expect([...(await api.readFile("blob.bin", { start: 2 }))]).toEqual([
185
+ 3, 4, 250,
186
+ ])
187
+ expect([
188
+ ...(await api.readFile("blob.bin", { start: 1, end: 10_000 })),
189
+ ]).toEqual([2, 3, 4, 250])
190
+ })
191
+
192
+ test("full read above the byte cap is rejected", async () => {
193
+ const api = stubApi([1, 2, 3, 4, 250], 4)
194
+ await expect(api.readFile("blob.bin")).rejects.toThrow(/byte range/)
195
+ })
196
+ })
197
+
198
+ describe("createPluginResourceAPI header-slice probes", () => {
199
+ function sliceView(head: Buffer, streams: string[]): ResourceContainer {
200
+ return {
201
+ listEntries: async () => ["a.png", "b.webp"],
202
+ readEntry: async () => Buffer.alloc(0),
203
+ readEntrySlice: async (relPath: string) =>
204
+ relPath === "a.png" ? head : Buffer.alloc(0),
205
+ openEntryStream: async (relPath: string) => {
206
+ streams.push(relPath)
207
+ return { stream: Readable.from([Buffer.from(relPath)]), size: 0 }
208
+ },
209
+ resolveByteRange: async () => ({ size: head.byteLength }),
210
+ }
211
+ }
212
+
213
+ test("static formats probe from a header slice, no stream", async () => {
214
+ const streams: string[] = []
215
+ let received: unknown
216
+ const api = createPluginResourceAPI({
217
+ view: sliceView(Buffer.from([1, 2, 3]), streams),
218
+ probeImage: async (source) => {
219
+ received = source
220
+ return { width: 10, height: 20, animated: false }
221
+ },
222
+ })
223
+ await expect(api.probe("a.png")).resolves.toEqual({
224
+ kind: "image",
225
+ mime: "image/png",
226
+ width: 10,
227
+ height: 20,
228
+ animated: false,
229
+ })
230
+ expect(received).toBeInstanceOf(Buffer)
231
+ expect(streams).toEqual([])
232
+ })
233
+
234
+ test("animation candidates always stream", async () => {
235
+ const streams: string[] = []
236
+ let received: unknown
237
+ const api = createPluginResourceAPI({
238
+ view: sliceView(Buffer.from([1, 2, 3]), streams),
239
+ probeImage: async (source) => {
240
+ received = source
241
+ return { width: 4, height: 4, animated: true }
242
+ },
243
+ })
244
+ // A truncated slice cannot complete a webp frame scan, so the
245
+ // probe must read the entry as a stream.
246
+ await expect(api.probe("b.webp")).resolves.toEqual({
247
+ kind: "image",
248
+ mime: "image/webp",
249
+ width: 4,
250
+ height: 4,
251
+ animated: true,
252
+ })
253
+ expect(received).not.toBeInstanceOf(Buffer)
254
+ expect((received as { pipe?: unknown } | null)?.pipe).toBeTypeOf("function")
255
+ expect(streams).toEqual(["b.webp"])
256
+ })
257
+
258
+ test("a decode failure is reported as failed, not as unavailable", async () => {
259
+ const streams: string[] = []
260
+ const api = createPluginResourceAPI({
261
+ view: sliceView(Buffer.from([1, 2, 3]), streams),
262
+ probeImage: async () => undefined,
263
+ })
264
+ await expect(api.probe("a.png")).resolves.toEqual({
265
+ kind: "unknown",
266
+ reason: "failed",
267
+ })
268
+ })
269
+
270
+ test("a host without an image probe reports unavailable", async () => {
271
+ const api = createPluginResourceAPI({
272
+ view: sliceView(Buffer.from([1, 2, 3]), []),
273
+ })
274
+ await expect(api.probe("a.png")).resolves.toEqual({
275
+ kind: "unknown",
276
+ reason: "unavailable",
277
+ })
278
+ })
279
+ })
280
+
281
+ describe("createPluginResourceAPI content sniffing", () => {
282
+ function contentView(entries: Readonly<Record<string, Buffer>>) {
283
+ return {
284
+ listEntries: async () => Object.keys(entries),
285
+ readEntry: async (relPath: string) => entries[relPath] ?? Buffer.alloc(0),
286
+ readEntrySlice: async (relPath: string, start: number, end: number) =>
287
+ (entries[relPath] ?? Buffer.alloc(0)).subarray(start, end),
288
+ openEntryStream: async (relPath: string) => {
289
+ const bytes = entries[relPath] ?? Buffer.alloc(0)
290
+ return { stream: Readable.from([bytes]), size: bytes.byteLength }
291
+ },
292
+ resolveByteRange: async (relPath: string) => ({
293
+ size: (entries[relPath] ?? Buffer.alloc(0)).byteLength,
294
+ }),
295
+ } satisfies ResourceContainer
296
+ }
297
+
298
+ test("magic bytes beat a lying extension", async () => {
299
+ const png = await sharp({
300
+ create: {
301
+ width: 8,
302
+ height: 8,
303
+ channels: 3,
304
+ background: { r: 1, g: 2, b: 3 },
305
+ },
306
+ })
307
+ .png()
308
+ .toBuffer()
309
+ const api = createPluginResourceAPI({
310
+ view: contentView({ "clip.mp4": png }),
311
+ probeImage: async (source, extHint) => {
312
+ expect(extHint).toBe(".png")
313
+ expect(source).toBeInstanceOf(Buffer)
314
+ return { width: 8, height: 8, animated: false }
315
+ },
316
+ probeAv: async () => {
317
+ throw new Error("a PNG must never reach the av probe")
318
+ },
319
+ })
320
+ await expect(api.sniff("clip.mp4")).resolves.toEqual({
321
+ mime: "image/png",
322
+ ext: ".png",
323
+ kind: "image",
324
+ source: "magic",
325
+ })
326
+ await expect(api.probe("clip.mp4")).resolves.toEqual({
327
+ kind: "image",
328
+ mime: "image/png",
329
+ width: 8,
330
+ height: 8,
331
+ animated: false,
332
+ })
333
+ })
334
+
335
+ test("signature-less formats fall back to the extension", async () => {
336
+ const api = createPluginResourceAPI({
337
+ view: contentView({ "notes.txt": Buffer.from("plain words") }),
338
+ })
339
+ await expect(api.sniff("notes.txt")).resolves.toEqual({
340
+ mime: "text/plain",
341
+ ext: ".txt",
342
+ kind: "other",
343
+ source: "extension",
344
+ })
345
+ // Identified and not media: a successful answer, not a failure.
346
+ await expect(api.probe("notes.txt")).resolves.toEqual({
347
+ kind: "other",
348
+ mime: "text/plain",
349
+ })
350
+ })
351
+
352
+ test("an unidentifiable entry is unsupported, and neither call rejects", async () => {
353
+ const api = createPluginResourceAPI({
354
+ view: contentView({ "blob.bin": Buffer.from([0, 1, 2, 3]) }),
355
+ })
356
+ await expect(api.sniff("blob.bin")).resolves.toBeUndefined()
357
+ await expect(api.probe("blob.bin")).resolves.toEqual({
358
+ kind: "unknown",
359
+ reason: "unsupported",
360
+ })
361
+ await expect(api.sniff("gone.bin")).resolves.toBeUndefined()
362
+ })
363
+ })
364
+
365
+ describe("createPluginResourceAPI probe caching", () => {
366
+ function stubViewWithStream(): ResourceContainer {
367
+ return {
368
+ listEntries: async () => ["a.jpg"],
369
+ readEntry: async () => Buffer.alloc(0),
370
+ readEntrySlice: async () => Buffer.alloc(0),
371
+ openEntryStream: async (relPath: string) => ({
372
+ stream: Readable.from([Buffer.from(relPath)]),
373
+ size: relPath.length,
374
+ }),
375
+ resolveByteRange: async () => ({ size: 0 }),
376
+ }
377
+ }
378
+
379
+ test("repeated probes of the same entry compute once", async () => {
380
+ let imageCalls = 0
381
+ const api = createPluginResourceAPI({
382
+ view: stubViewWithStream(),
383
+ probeImage: async () => {
384
+ imageCalls++
385
+ return { width: 10, height: 20, animated: false }
386
+ },
387
+ probeCache: createProbeCache(),
388
+ cacheScope: "res-1:0",
389
+ })
390
+ const expected = {
391
+ kind: "image",
392
+ mime: "image/jpeg",
393
+ width: 10,
394
+ height: 20,
395
+ animated: false,
396
+ }
397
+ await expect(api.probe("a.jpg")).resolves.toEqual(expected)
398
+ await expect(api.probe("a.jpg")).resolves.toEqual(expected)
399
+ expect(imageCalls).toBe(1)
400
+ })
401
+
402
+ test("probe reuses the cached sniff instead of re-reading the header", async () => {
403
+ let sliceReads = 0
404
+ const view: ResourceContainer = {
405
+ ...stubViewWithStream(),
406
+ readEntrySlice: async () => {
407
+ sliceReads++
408
+ return Buffer.alloc(0)
409
+ },
410
+ }
411
+ const api = createPluginResourceAPI({
412
+ view,
413
+ probeImage: async () => ({ width: 1, height: 1, animated: false }),
414
+ probeCache: createProbeCache(),
415
+ cacheScope: "res-1:0",
416
+ })
417
+ await api.sniff("a.jpg")
418
+ await api.probe("a.jpg")
419
+ // One header read for the sniff; the image branch of a jpeg reads
420
+ // its own larger slice, so exactly two reads total.
421
+ expect(sliceReads).toBe(2)
422
+ })
423
+
424
+ test("a different cache scope recomputes", async () => {
425
+ let imageCalls = 0
426
+ const cache = createProbeCache()
427
+ const build = (scope: string) =>
428
+ createPluginResourceAPI({
429
+ view: stubViewWithStream(),
430
+ probeImage: async () => {
431
+ imageCalls++
432
+ return { width: 1, height: 1, animated: false }
433
+ },
434
+ probeCache: cache,
435
+ cacheScope: scope,
436
+ })
437
+ await build("res-1:0").probe("a.jpg")
438
+ await build("res-1:1").probe("a.jpg")
439
+ expect(imageCalls).toBe(2)
440
+ })
441
+
442
+ test("without cache deps every probe computes", async () => {
443
+ let imageCalls = 0
444
+ const api = createPluginResourceAPI({
445
+ view: stubViewWithStream(),
446
+ probeImage: async () => {
447
+ imageCalls++
448
+ return undefined
449
+ },
450
+ })
451
+ await api.probe("a.jpg")
452
+ await api.probe("a.jpg")
453
+ expect(imageCalls).toBe(2)
454
+ })
455
+ })
456
+
457
+ describe("createPluginResourceAPI audio/video probes", () => {
458
+ function avView(opened: string[]): ResourceContainer {
459
+ return {
460
+ listEntries: async () => ["track.mp3", "clip.mp4", "notes.txt"],
461
+ readEntry: async () => Buffer.alloc(0),
462
+ readEntrySlice: async () => Buffer.alloc(0),
463
+ openEntryStream: async (relPath: string) => {
464
+ opened.push(relPath)
465
+ return { stream: Readable.from([Buffer.from(relPath)]), size: 0 }
466
+ },
467
+ resolveByteRange: async () => ({ size: 0 }),
468
+ }
469
+ }
470
+
471
+ test("streams the entry with the container hint derived from the MIME type", async () => {
472
+ const opened: string[] = []
473
+ let opts: { mime: string; inputFormat?: string } | undefined
474
+ const api = createPluginResourceAPI({
475
+ view: avView(opened),
476
+ probeAv: async (_source, received) => {
477
+ opts = received
478
+ return { kind: "audio", mime: received.mime, durationMs: 1000 }
479
+ },
480
+ })
481
+ await expect(api.probe("track.mp3")).resolves.toEqual({
482
+ kind: "audio",
483
+ mime: "audio/mpeg",
484
+ durationMs: 1000,
485
+ })
486
+ expect(opts).toEqual({ mime: "audio/mpeg", inputFormat: "mp3" })
487
+ expect(opened).toEqual(["track.mp3"])
488
+ })
489
+
490
+ test("non-media entries never reach the av probe", async () => {
491
+ const opened: string[] = []
492
+ let called = false
493
+ const api = createPluginResourceAPI({
494
+ view: avView(opened),
495
+ probeAv: async () => {
496
+ called = true
497
+ return { kind: "audio", mime: "audio/mpeg" }
498
+ },
499
+ })
500
+ await expect(api.probe("notes.txt")).resolves.toEqual({
501
+ kind: "other",
502
+ mime: "text/plain",
503
+ })
504
+ expect(called).toBe(false)
505
+ expect(opened).toEqual([])
506
+ })
507
+
508
+ test("repeated probes of the same entry compute once", async () => {
509
+ let calls = 0
510
+ const api = createPluginResourceAPI({
511
+ view: avView([]),
512
+ probeAv: async () => {
513
+ calls++
514
+ return { kind: "audio", mime: "audio/mpeg", durationMs: 1000 }
515
+ },
516
+ probeCache: createProbeCache(),
517
+ cacheScope: "res-1:0",
518
+ })
519
+ await api.probe("track.mp3")
520
+ await api.probe("track.mp3")
521
+ expect(calls).toBe(1)
522
+ })
523
+
524
+ test("reports unavailable when the host ships no av probe", async () => {
525
+ const api = createPluginResourceAPI({ view: avView([]) })
526
+ await expect(api.probe("track.mp3")).resolves.toEqual({
527
+ kind: "unknown",
528
+ reason: "unavailable",
529
+ })
530
+ })
531
+ })
532
+
533
+ describe("readFileChunks", () => {
534
+ test("yields the whole file in chunk-sized pieces", async () => {
535
+ const contents = Array.from({ length: 10 }, (_, i) => i)
536
+ const { api } = createResourceAPIFixture({
537
+ contents: { "big.bin": new Uint8Array(contents) },
538
+ })
539
+ const chunks: number[][] = []
540
+ for await (const chunk of readChunks(api, "big.bin", {
541
+ chunkSize: 4,
542
+ })) {
543
+ chunks.push([...chunk])
544
+ }
545
+ expect(chunks).toEqual([
546
+ [0, 1, 2, 3],
547
+ [4, 5, 6, 7],
548
+ [8, 9],
549
+ ])
550
+ })
551
+
552
+ test("empty file yields nothing", async () => {
553
+ const { api } = createResourceAPIFixture({
554
+ contents: { "empty.bin": new Uint8Array() },
555
+ })
556
+ const chunks: Uint8Array[] = []
557
+ for await (const chunk of readChunks(api, "empty.bin", {
558
+ chunkSize: 4,
559
+ })) {
560
+ chunks.push(chunk)
561
+ }
562
+ expect(chunks).toEqual([])
563
+ })
564
+ })