@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.
- package/LICENSE +18 -0
- package/README.md +73 -0
- package/dist/chunks/worker-entry.mjs +422 -0
- package/dist/container-fKxefO7p.d.ts +58 -0
- package/dist/contract/index.d.ts +49 -0
- package/dist/contract/index.js +187 -0
- package/dist/contract/index.js.map +1 -0
- package/dist/hoard/index.d.ts +754 -0
- package/dist/hoard/index.js +1939 -0
- package/dist/hoard/index.js.map +1 -0
- package/dist/image-Dx2Be3Bj.d.ts +168 -0
- package/dist/index.d.ts +788 -0
- package/dist/index.js +3311 -0
- package/dist/index.js.map +1 -0
- package/dist/media/index.d.ts +96 -0
- package/dist/media/index.js +152 -0
- package/dist/media/index.js.map +1 -0
- package/dist/probe/index.d.ts +113 -0
- package/dist/probe/index.js +454 -0
- package/dist/probe/index.js.map +1 -0
- package/dist/probe-cache-BkRI7HEK.d.ts +25 -0
- package/dist/render/index.d.ts +125 -0
- package/dist/render/index.js +430 -0
- package/dist/render/index.js.map +1 -0
- package/package.json +103 -0
- package/src/__testutils__/zip-fixtures.ts +139 -0
- package/src/activation.ts +116 -0
- package/src/api-types.ts +60 -0
- package/src/api.test.ts +564 -0
- package/src/api.ts +501 -0
- package/src/archive/7z.test.ts +222 -0
- package/src/archive/7z.ts +261 -0
- package/src/archive/archive-ops.test.ts +654 -0
- package/src/archive/extract-archive.test.ts +237 -0
- package/src/archive/extract-archive.ts +534 -0
- package/src/archive/extract.ts +693 -0
- package/src/archive/format.ts +49 -0
- package/src/archive/index.ts +116 -0
- package/src/archive/listing.ts +121 -0
- package/src/archive/materialize.ts +88 -0
- package/src/archive/name-decode.test.ts +34 -0
- package/src/archive/name-decode.ts +287 -0
- package/src/archive/nested-cd-cache.ts +55 -0
- package/src/archive/nested-entry.test.ts +194 -0
- package/src/archive/nested-entry.ts +254 -0
- package/src/archive/pack.ts +40 -0
- package/src/archive/zip-entries.test.ts +56 -0
- package/src/archive/zip-entries.ts +336 -0
- package/src/capability-guard.ts +73 -0
- package/src/container.ts +62 -0
- package/src/contract/container-fixture.ts +74 -0
- package/src/contract/index.ts +10 -0
- package/src/contract/suite.test.ts +101 -0
- package/src/contract/suite.ts +205 -0
- package/src/define-plugin.test.ts +53 -0
- package/src/define-plugin.ts +12 -0
- package/src/dev-runner.test.ts +98 -0
- package/src/dev-runner.ts +73 -0
- package/src/directory-api.test.ts +229 -0
- package/src/directory-api.ts +42 -0
- package/src/directory-container.ts +141 -0
- package/src/discovery.ts +295 -0
- package/src/errors.ts +74 -0
- package/src/fixtures.ts +8 -0
- package/src/hash.test.ts +301 -0
- package/src/hash.ts +167 -0
- package/src/hoard/dir-size.ts +58 -0
- package/src/hoard/index.ts +84 -0
- package/src/hoard/migrate-script.test.ts +539 -0
- package/src/hoard/order-manifest.test.ts +115 -0
- package/src/hoard/order-manifest.ts +131 -0
- package/src/hoard/paths.test.ts +226 -0
- package/src/hoard/paths.ts +569 -0
- package/src/hoard/plugin-vault.test.ts +160 -0
- package/src/hoard/plugin-vault.ts +233 -0
- package/src/hoard/sanitize.test.ts +149 -0
- package/src/hoard/sanitize.ts +230 -0
- package/src/hoard/staging-dir.ts +133 -0
- package/src/hoard/version.test.ts +285 -0
- package/src/hoard/version.ts +216 -0
- package/src/hoard/versioned-folder-ops.ts +152 -0
- package/src/hoard/write-versioned.test.ts +67 -0
- package/src/hoard/write-versioned.ts +39 -0
- package/src/hooks.test.ts +528 -0
- package/src/hooks.ts +428 -0
- package/src/index.ts +135 -0
- package/src/loader.test.ts +442 -0
- package/src/loader.ts +213 -0
- package/src/media/index.ts +16 -0
- package/src/media/render-cache.ts +27 -0
- package/src/media/seekable.ts +35 -0
- package/src/media/thumb-input.test.ts +109 -0
- package/src/media/thumb-input.ts +194 -0
- package/src/nested-view.test.ts +223 -0
- package/src/nested-view.ts +239 -0
- package/src/probe/audio.test.ts +135 -0
- package/src/probe/audio.ts +140 -0
- package/src/probe/av.ts +84 -0
- package/src/probe/ffprobe.ts +197 -0
- package/src/probe/image.test.ts +251 -0
- package/src/probe/image.ts +336 -0
- package/src/probe/index.ts +54 -0
- package/src/probe/sniff.test.ts +72 -0
- package/src/probe/sniff.ts +67 -0
- package/src/probe/video.test.ts +41 -0
- package/src/probe/video.ts +116 -0
- package/src/probe-cache.test.ts +89 -0
- package/src/probe-cache.ts +65 -0
- package/src/render/ffmpeg.ts +78 -0
- package/src/render/index.ts +34 -0
- package/src/render/pipeline.test.ts +187 -0
- package/src/render/pipeline.ts +479 -0
- package/src/sandbox/call-timers.ts +68 -0
- package/src/sandbox/fixtures/api-error-plugin.mjs +11 -0
- package/src/sandbox/fixtures/asset-plugin.mjs +13 -0
- package/src/sandbox/fixtures/chatty-plugin.mjs +14 -0
- package/src/sandbox/fixtures/container-plugin.mjs +8 -0
- package/src/sandbox/fixtures/context-plugin.mjs +19 -0
- package/src/sandbox/fixtures/crash-plugin.mjs +6 -0
- package/src/sandbox/fixtures/echo-plugin.mjs +17 -0
- package/src/sandbox/fixtures/exit-plugin.mjs +6 -0
- package/src/sandbox/fixtures/fetch-plugin.mjs +8 -0
- package/src/sandbox/fixtures/flood-api-plugin.mjs +8 -0
- package/src/sandbox/fixtures/flood-log-plugin.mjs +8 -0
- package/src/sandbox/fixtures/hostile-dynamic-plugin.mjs +9 -0
- package/src/sandbox/fixtures/hostile-plugin.mjs +13 -0
- package/src/sandbox/fixtures/huge-result-plugin.mjs +8 -0
- package/src/sandbox/fixtures/logging-plugin.mjs +9 -0
- package/src/sandbox/fixtures/range-plugin.mjs +9 -0
- package/src/sandbox/fixtures/slow-api-plugin.mjs +11 -0
- package/src/sandbox/fixtures/spin-plugin.mjs +9 -0
- package/src/sandbox/fixtures/stuck-plugin.mjs +12 -0
- package/src/sandbox/fixtures/thrower-plugin.mjs +7 -0
- package/src/sandbox/host.test.ts +619 -0
- package/src/sandbox/host.ts +933 -0
- package/src/sandbox/host.unit.test.ts +330 -0
- package/src/sandbox/protocol.test.ts +85 -0
- package/src/sandbox/protocol.ts +147 -0
- package/src/sandbox/sandboxed-plugin.ts +30 -0
- package/src/sandbox/worker-entry.mjs +422 -0
- package/src/seed.test.ts +102 -0
- package/src/seed.ts +160 -0
- package/src/settings-store.ts +26 -0
- package/src/types.ts +21 -0
package/src/api.test.ts
ADDED
|
@@ -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
|
+
})
|