@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,528 @@
1
+ import type { PluginManifestId } from "@hoardodile/sdk-types"
2
+ import { describe, expect, test, vi } from "vitest"
3
+ import type { PluginRegistry } from "./api-types.ts"
4
+ import { createPluginHooks } from "./hooks.ts"
5
+ import { buildRegistry } from "./loader.ts"
6
+ import type { PluginDefinition, ResourceAPI } from "./types.ts"
7
+
8
+ const PAGES_ID = "11111111-1111-4111-8111-111111111111" as PluginManifestId
9
+ const GALLERY_ID = "22222222-2222-4222-8222-222222222222" as PluginManifestId
10
+ const FILE_BUILTIN_ID =
11
+ "33333333-3333-4333-8333-333333333333" as PluginManifestId
12
+
13
+ function manifestFor(
14
+ id: PluginManifestId,
15
+ name: string,
16
+ permissions: Partial<{
17
+ sourceMeta: boolean
18
+ searchMeta: boolean
19
+ danmaku: boolean
20
+ message: boolean
21
+ imageHashes: boolean
22
+ }> = {},
23
+ ) {
24
+ return {
25
+ id,
26
+ name,
27
+ description: "",
28
+ version: "1.0.0",
29
+ permissions: {
30
+ sourceMeta: false,
31
+ searchMeta: false,
32
+ danmaku: false,
33
+ message: false,
34
+ imageHashes: false,
35
+ container: false,
36
+ download: false,
37
+ ...permissions,
38
+ },
39
+ }
40
+ }
41
+
42
+ function createPagesPlugin(): PluginDefinition {
43
+ return {
44
+ detect: async (api: ResourceAPI) => {
45
+ const files = await api.listFileNames()
46
+ if (files.length < 2) return { ok: false, reasons: ["page-image"] }
47
+ const allImages = files.every((name) =>
48
+ /\.(jpg|jpeg|png|webp|gif)$/i.test(name),
49
+ )
50
+ return allImages ? { ok: true } : { ok: false, reasons: ["page-image"] }
51
+ },
52
+ }
53
+ }
54
+
55
+ function createGalleryPlugin(): PluginDefinition {
56
+ return {
57
+ detect: async (api: ResourceAPI) => {
58
+ const files = await api.listFileNames()
59
+ const hasImage = files.some((name) =>
60
+ /\.(jpg|jpeg|png|webp|gif)$/i.test(name),
61
+ )
62
+ return hasImage ? { ok: true } : { ok: false, reasons: ["media-file"] }
63
+ },
64
+ }
65
+ }
66
+
67
+ function createFilePlugin(): PluginDefinition {
68
+ return {
69
+ detect: async () => ({ ok: true }) as const,
70
+ }
71
+ }
72
+
73
+ function createRegistry(): PluginRegistry {
74
+ return buildRegistry([
75
+ {
76
+ id: PAGES_ID,
77
+ manifest: manifestFor(PAGES_ID, "Pages"),
78
+ enabled: true,
79
+ priority: 50,
80
+ pinned: false,
81
+ color: "",
82
+ missing: false,
83
+ builtin: false,
84
+ dev: false,
85
+ plugin: createPagesPlugin(),
86
+ },
87
+ {
88
+ id: GALLERY_ID,
89
+ manifest: manifestFor(GALLERY_ID, "Gallery"),
90
+ enabled: true,
91
+ priority: 60,
92
+ pinned: false,
93
+ color: "",
94
+ missing: false,
95
+ builtin: false,
96
+ dev: false,
97
+ plugin: createGalleryPlugin(),
98
+ },
99
+ {
100
+ id: FILE_BUILTIN_ID,
101
+ manifest: manifestFor(FILE_BUILTIN_ID, "File"),
102
+ enabled: true,
103
+ priority: Number.MAX_SAFE_INTEGER,
104
+ pinned: false,
105
+ color: "",
106
+ missing: false,
107
+ builtin: true,
108
+ dev: false,
109
+ plugin: createFilePlugin(),
110
+ },
111
+ ])
112
+ }
113
+
114
+ function createAPI(files: readonly string[]): ResourceAPI {
115
+ return {
116
+ logInfo() {},
117
+ logWarn() {},
118
+ logError() {},
119
+ context: { detect: undefined },
120
+ listFileNames: async () => files,
121
+ readFile: async () => new Uint8Array(),
122
+ statFile: async () => ({ sizeBytes: 0 }),
123
+ statFiles: async (paths) => paths.map(() => ({ sizeBytes: 0 })),
124
+ sniff: async () => undefined,
125
+ probe: async () => ({ kind: "unknown", reason: "unavailable" }),
126
+ hashBytes: async () => "0",
127
+ computeImageHashes: async () => undefined,
128
+ listContainer: async () => ({ entries: [] }),
129
+ extractArchive: async () => ({ entries: [] }),
130
+ download: async () => {
131
+ throw new Error("stub: download not configured")
132
+ },
133
+ statAsset: async () => undefined,
134
+ readAsset: async () => new Uint8Array(),
135
+ deleteAsset: async () => ({ existed: false }),
136
+ }
137
+ }
138
+
139
+ function createHooks(registry: PluginRegistry = createRegistry()) {
140
+ return createPluginHooks({ getRegistry: () => registry })
141
+ }
142
+
143
+ describe("plugin hooks: revalidate", () => {
144
+ test("keeps the explicit plugin when it still matches", async () => {
145
+ const result = await createHooks().revalidate(
146
+ createAPI(["01.png", "02.png"]),
147
+ PAGES_ID,
148
+ )
149
+ expect(result).toBe(PAGES_ID)
150
+ })
151
+
152
+ test("falls back from a multi-image plugin to gallery for a single image", async () => {
153
+ const result = await createHooks().revalidate(
154
+ createAPI(["page.png"]),
155
+ PAGES_ID,
156
+ )
157
+ expect(result).toBe(GALLERY_ID)
158
+ })
159
+
160
+ test("falls back from gallery to file builtin when there is no media", async () => {
161
+ const result = await createHooks().revalidate(
162
+ createAPI(["notes.txt"]),
163
+ GALLERY_ID,
164
+ )
165
+ expect(result).toBe(FILE_BUILTIN_ID)
166
+ })
167
+
168
+ test("falls back to builtin when the explicit plugin is unknown", async () => {
169
+ const result = await createHooks().revalidate(
170
+ createAPI(["page.png"]),
171
+ "00000000-0000-4000-8000-000000000000" as PluginManifestId,
172
+ )
173
+ expect(result).toBe(FILE_BUILTIN_ID)
174
+ })
175
+ })
176
+
177
+ describe("plugin hooks: detectFirstMatch", () => {
178
+ test("returns the first matching plugin in priority order", async () => {
179
+ await expect(
180
+ createHooks().detectFirstMatch(createAPI(["01.png", "02.png"])),
181
+ ).resolves.toBe(PAGES_ID)
182
+ })
183
+
184
+ test("falls through to the builtin when nothing else matches", async () => {
185
+ await expect(
186
+ createHooks().detectFirstMatch(createAPI(["notes.txt"])),
187
+ ).resolves.toBe(FILE_BUILTIN_ID)
188
+ })
189
+ })
190
+
191
+ describe("plugin hooks: detectForImportDir", () => {
192
+ test("matches a non-builtin detector", async () => {
193
+ await expect(
194
+ createHooks().detectForImportDir(createAPI(["01.png", "02.png"])),
195
+ ).resolves.toBe(PAGES_ID)
196
+ })
197
+
198
+ test("falls back to builtin without invoking its detector", async () => {
199
+ await expect(
200
+ createHooks().detectForImportDir(createAPI(["notes.txt"])),
201
+ ).resolves.toBe(FILE_BUILTIN_ID)
202
+ })
203
+
204
+ test("a crashing detector is skipped instead of aborting the scan", async () => {
205
+ const crashId = "44444444-4444-4444-8444-444444444444" as PluginManifestId
206
+ const registry = buildRegistry([
207
+ {
208
+ id: crashId,
209
+ manifest: manifestFor(crashId, "Crash"),
210
+ enabled: true,
211
+ priority: 10,
212
+ pinned: false,
213
+ color: "",
214
+ missing: false,
215
+ builtin: false,
216
+ dev: false,
217
+ plugin: {
218
+ detect: async () => {
219
+ throw new Error("detector exploded")
220
+ },
221
+ },
222
+ },
223
+ ...createRegistry().getAll(),
224
+ ])
225
+ await expect(
226
+ createHooks(registry).detectForImportDir(createAPI(["01.png", "02.png"])),
227
+ ).resolves.toBe(PAGES_ID)
228
+ })
229
+ })
230
+
231
+ describe("plugin hooks: getEffectiveEntry", () => {
232
+ test("returns the stored plugin when it is healthy", () => {
233
+ expect(createHooks().getEffectiveEntry(PAGES_ID).id).toBe(PAGES_ID)
234
+ })
235
+
236
+ test("falls back to the builtin for a missing plugin", () => {
237
+ const registry = buildRegistry([
238
+ {
239
+ id: PAGES_ID,
240
+ manifest: manifestFor(PAGES_ID, "Pages"),
241
+ enabled: true,
242
+ priority: 50,
243
+ pinned: false,
244
+ color: "",
245
+ missing: true,
246
+ builtin: false,
247
+ dev: false,
248
+ plugin: {
249
+ detect: async () => ({ ok: false, reasons: ["missing"] }),
250
+ },
251
+ },
252
+ ...createRegistry().getAll(),
253
+ ])
254
+ expect(createHooks(registry).getEffectiveEntry(PAGES_ID).id).toBe(
255
+ FILE_BUILTIN_ID,
256
+ )
257
+ })
258
+
259
+ test("falls back to the builtin for a disabled plugin", () => {
260
+ const registry = buildRegistry([
261
+ {
262
+ ...createRegistry()
263
+ .getAll()
264
+ .find((e) => e.id === PAGES_ID)!,
265
+ enabled: false,
266
+ },
267
+ ...createRegistry()
268
+ .getAll()
269
+ .filter((e) => e.id !== PAGES_ID),
270
+ ])
271
+ expect(createHooks(registry).getEffectiveEntry(PAGES_ID).id).toBe(
272
+ FILE_BUILTIN_ID,
273
+ )
274
+ })
275
+
276
+ test("falls back to the builtin for an unknown plugin id", () => {
277
+ expect(
278
+ createHooks().getEffectiveEntry(
279
+ "00000000-0000-4000-8000-000000000000" as PluginManifestId,
280
+ ).id,
281
+ ).toBe(FILE_BUILTIN_ID)
282
+ })
283
+
284
+ test("returns the builtin for a null plugin id", () => {
285
+ expect(createHooks().getEffectiveEntry(null).id).toBe(FILE_BUILTIN_ID)
286
+ })
287
+
288
+ test("throws when no builtin is registered", () => {
289
+ const registry = buildRegistry(
290
+ createRegistry()
291
+ .getAll()
292
+ .filter((e) => !e.builtin),
293
+ )
294
+ expect(() => createHooks(registry).getEffectiveEntry(null)).toThrow(
295
+ /No builtin plugin/,
296
+ )
297
+ })
298
+ })
299
+
300
+ describe("plugin hooks: runMetaHooks", () => {
301
+ function createMetaRegistry(opts: {
302
+ sourceMeta: boolean
303
+ searchMeta: boolean
304
+ }): PluginRegistry {
305
+ const id = "55555555-5555-4555-8555-555555555555" as PluginManifestId
306
+ return buildRegistry([
307
+ {
308
+ id,
309
+ manifest: manifestFor(id, "Meta", opts),
310
+ enabled: true,
311
+ priority: 10,
312
+ pinned: false,
313
+ color: "",
314
+ missing: false,
315
+ builtin: false,
316
+ dev: false,
317
+ plugin: {
318
+ detect: async () => ({ ok: true }),
319
+ sourceMeta: async () => ({ coverKind: "image" }),
320
+ searchMeta: async () => undefined,
321
+ },
322
+ },
323
+ ])
324
+ }
325
+
326
+ test("runs permitted hooks and reports raw results", async () => {
327
+ const id = "55555555-5555-4555-8555-555555555555" as PluginManifestId
328
+ const hooks = createHooks(
329
+ createMetaRegistry({ sourceMeta: true, searchMeta: true }),
330
+ )
331
+ const results = await hooks.runMetaHooks(createAPI([]), id)
332
+ expect(results.sourceMeta?.value).toEqual({ coverKind: "image" })
333
+ // Hook ran but returned undefined — callers distinguish this from
334
+ // "did not run" via key presence.
335
+ expect(results.searchMeta).toBeDefined()
336
+ expect(results.searchMeta?.value).toBeUndefined()
337
+ })
338
+
339
+ test("skips hooks the manifest does not permit", async () => {
340
+ const id = "55555555-5555-4555-8555-555555555555" as PluginManifestId
341
+ const hooks = createHooks(
342
+ createMetaRegistry({ sourceMeta: false, searchMeta: false }),
343
+ )
344
+ const results = await hooks.runMetaHooks(createAPI([]), id)
345
+ expect(results.sourceMeta).toBeUndefined()
346
+ expect(results.searchMeta).toBeUndefined()
347
+ })
348
+
349
+ test("returns empty results for an unknown plugin", async () => {
350
+ const results = await createHooks().runMetaHooks(
351
+ createAPI([]),
352
+ "99999999-9999-4999-8999-999999999999" as PluginManifestId,
353
+ )
354
+ expect(results).toEqual({})
355
+ })
356
+
357
+ test("a failing meta hook is logged and skipped without blocking the other", async () => {
358
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
359
+ const id = "55555555-5555-4555-8555-555555555555" as PluginManifestId
360
+ const registry = buildRegistry([
361
+ {
362
+ id,
363
+ manifest: manifestFor(id, "Meta", {
364
+ sourceMeta: true,
365
+ searchMeta: true,
366
+ }),
367
+ enabled: true,
368
+ priority: 10,
369
+ pinned: false,
370
+ color: "",
371
+ missing: false,
372
+ builtin: false,
373
+ dev: false,
374
+ plugin: {
375
+ detect: async () => ({ ok: true }),
376
+ sourceMeta: async () => {
377
+ throw new Error("sourceMeta exploded")
378
+ },
379
+ searchMeta: async () => ({ tags: ["a"] }),
380
+ },
381
+ },
382
+ ])
383
+ try {
384
+ const results = await createHooks(registry).runMetaHooks(
385
+ createAPI([]),
386
+ id,
387
+ )
388
+ expect(results.sourceMeta).toBeUndefined()
389
+ expect(results.searchMeta?.value).toEqual({ tags: ["a"] })
390
+ expect(errorSpy).toHaveBeenCalledWith(
391
+ expect.stringContaining(`sourceMeta failed for plugin ${id}`),
392
+ )
393
+ } finally {
394
+ errorSpy.mockRestore()
395
+ }
396
+ })
397
+ })
398
+
399
+ describe("plugin hooks: buildFileList validation", () => {
400
+ test("rejects non-scalar values in file list items", async () => {
401
+ const id = "66666666-6666-4666-8666-666666666666" as PluginManifestId
402
+ const registry = buildRegistry([
403
+ {
404
+ id,
405
+ manifest: manifestFor(id, "Bad"),
406
+ enabled: true,
407
+ priority: 10,
408
+ pinned: false,
409
+ color: "",
410
+ missing: false,
411
+ builtin: false,
412
+ dev: false,
413
+ plugin: {
414
+ detect: async () => ({ ok: true }),
415
+ listFiles: async () => [{ filename: "a.png", nested: { bad: 1 } }],
416
+ },
417
+ },
418
+ ])
419
+ await expect(
420
+ createHooks(registry).buildFileList(createAPI([]), id),
421
+ ).rejects.toThrow(/invalid file list item value type/)
422
+ })
423
+ })
424
+
425
+ describe("plugin hooks: imageHashes", () => {
426
+ const HASHER_ID = "44444444-4444-4444-8444-444444444444" as PluginManifestId
427
+
428
+ function registryWithImageHashes(
429
+ imageHashes?: PluginDefinition["imageHashes"],
430
+ granted = true,
431
+ ): PluginRegistry {
432
+ return buildRegistry([
433
+ {
434
+ id: HASHER_ID,
435
+ manifest: manifestFor(HASHER_ID, "Hasher", { imageHashes: granted }),
436
+ enabled: true,
437
+ priority: 10,
438
+ pinned: false,
439
+ color: "",
440
+ missing: false,
441
+ builtin: false,
442
+ dev: false,
443
+ plugin: {
444
+ detect: async () => ({ ok: true }),
445
+ ...(imageHashes !== undefined ? { imageHashes } : {}),
446
+ },
447
+ },
448
+ ])
449
+ }
450
+
451
+ test("resolves undefined when the permission is not granted", async () => {
452
+ const hooks = createHooks(
453
+ registryWithImageHashes(async () => ({ hashes: [] }), false),
454
+ )
455
+ expect(hooks.supportsImageHashes(HASHER_ID)).toBe(false)
456
+ await expect(
457
+ hooks.runImageHashes(createAPI([]), HASHER_ID),
458
+ ).resolves.toBeUndefined()
459
+ })
460
+
461
+ test("resolves undefined when the plugin implements no hook", async () => {
462
+ const hooks = createHooks(registryWithImageHashes(undefined))
463
+ expect(hooks.supportsImageHashes(HASHER_ID)).toBe(false)
464
+ await expect(
465
+ hooks.runImageHashes(createAPI([]), HASHER_ID),
466
+ ).resolves.toBeUndefined()
467
+ })
468
+
469
+ test("passes valid entries through with defaulted bits", async () => {
470
+ const hooks = createHooks(
471
+ registryWithImageHashes(async () => ({
472
+ hashes: [{ scope: "1.jpg", type: "sha256", value: "ab" }],
473
+ })),
474
+ )
475
+ expect(hooks.supportsImageHashes(HASHER_ID)).toBe(true)
476
+ await expect(
477
+ hooks.runImageHashes(createAPI([]), HASHER_ID),
478
+ ).resolves.toEqual({
479
+ hashes: [{ scope: "1.jpg", type: "sha256", value: "ab", bits: 8 }],
480
+ })
481
+ })
482
+
483
+ test("drops malformed entries and keeps the valid ones", async () => {
484
+ // Deliberately out-of-contract input — the sanitizer is the guard.
485
+ const malformed = (async () => ({
486
+ hashes: [
487
+ { scope: "", type: "sha256", value: "ab" },
488
+ { scope: "2.jpg", type: "sha256", value: "AB" },
489
+ { scope: "3.jpg", type: "dhash", value: "cafe", bits: 64 },
490
+ { scope: "4.jpg", type: "sha256", value: "de" },
491
+ null,
492
+ "junk",
493
+ ],
494
+ })) as PluginDefinition["imageHashes"]
495
+ const hooks = createHooks(registryWithImageHashes(malformed))
496
+ await expect(
497
+ hooks.runImageHashes(createAPI([]), HASHER_ID),
498
+ ).resolves.toEqual({
499
+ hashes: [
500
+ { scope: "3.jpg", type: "dhash", value: "cafe", bits: 64 },
501
+ { scope: "4.jpg", type: "sha256", value: "de", bits: 8 },
502
+ ],
503
+ })
504
+ })
505
+
506
+ test("caps the entry count and preserves hook errors as undefined", async () => {
507
+ const huge = async () => ({
508
+ hashes: Array.from({ length: 2500 }, (_, i) => ({
509
+ scope: `${i}.jpg`,
510
+ type: "sha256",
511
+ value: "ab",
512
+ })),
513
+ })
514
+ const capped = await createHooks(
515
+ registryWithImageHashes(huge),
516
+ ).runImageHashes(createAPI([]), HASHER_ID)
517
+ expect(capped?.hashes.length).toBe(2000)
518
+
519
+ const throwing = createHooks(
520
+ registryWithImageHashes(async () => {
521
+ throw new Error("boom")
522
+ }),
523
+ )
524
+ await expect(
525
+ throwing.runImageHashes(createAPI([]), HASHER_ID),
526
+ ).resolves.toBeUndefined()
527
+ })
528
+ })