@hoardodile/host-web 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 +31 -0
- package/dist/file-backends-Dyy2BxUM.d.ts +27 -0
- package/dist/index.d.ts +293 -0
- package/dist/index.js +532 -0
- package/dist/index.js.map +1 -0
- package/dist/node.d.ts +12 -0
- package/dist/node.js +25 -0
- package/dist/node.js.map +1 -0
- package/package.json +60 -0
- package/src/consent/consent-store.test.ts +105 -0
- package/src/consent/consent-store.ts +129 -0
- package/src/host-core/request-schemas.ts +85 -0
- package/src/host-core/router.test.ts +231 -0
- package/src/host-core/router.ts +234 -0
- package/src/index.ts +47 -0
- package/src/mock/file-backends.ts +69 -0
- package/src/mock/host.test.ts +188 -0
- package/src/mock/host.ts +370 -0
- package/src/mock/stores.ts +89 -0
- package/src/mock-node/index.ts +37 -0
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type HostResponse,
|
|
3
|
+
type PluginMessage,
|
|
4
|
+
PROTOCOL_VERSION,
|
|
5
|
+
} from "@hoardodile/sdk-web"
|
|
6
|
+
import type { z } from "zod"
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The shared host-side protocol core: message demux, method routing,
|
|
10
|
+
* per-method param validation, stale-request scoping and the response
|
|
11
|
+
* envelope. Both the real host (apps/web) and the offline mock host
|
|
12
|
+
* assemble on this module, so routing and validation never drift.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** What a registered iframe (or mock window) is bound to. */
|
|
16
|
+
export type HostBinding = {
|
|
17
|
+
readonly pluginId: string
|
|
18
|
+
readonly resId: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type HostRouterDeps = {
|
|
22
|
+
/**
|
|
23
|
+
* Authenticate an inbound message event: narrow it to a trusted source
|
|
24
|
+
* + binding, or return `undefined` to drop the message. The real host
|
|
25
|
+
* validates origin/source against its iframe registry; the mock
|
|
26
|
+
* validates against its own registered windows.
|
|
27
|
+
*/
|
|
28
|
+
readonly resolveSource: (
|
|
29
|
+
event: MessageEvent,
|
|
30
|
+
) => { readonly source: unknown; readonly record: HostBinding } | undefined
|
|
31
|
+
/** Send a response back to the source (the layer's postMessage exit). */
|
|
32
|
+
readonly respond: (source: unknown, response: HostResponse) => void
|
|
33
|
+
/** Record a subscription key for the source. */
|
|
34
|
+
readonly subscribe: (source: unknown, key: string) => void
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type HostHandlerContext = {
|
|
38
|
+
readonly source: unknown
|
|
39
|
+
/**
|
|
40
|
+
* The resource this request is scoped to: the source's current
|
|
41
|
+
* binding ("" only for never-bound sources). Requests stamped by the
|
|
42
|
+
* SDK with a different resource are dropped as stale before they
|
|
43
|
+
* reach a handler.
|
|
44
|
+
*/
|
|
45
|
+
readonly resId: string
|
|
46
|
+
readonly pluginId: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type HostHandlerEntry = {
|
|
50
|
+
readonly method: string
|
|
51
|
+
/** Param schema; validated in the router before the handler runs. */
|
|
52
|
+
readonly schema: z.ZodTypeAny | undefined
|
|
53
|
+
readonly handler: (
|
|
54
|
+
ctx: HostHandlerContext,
|
|
55
|
+
params: unknown,
|
|
56
|
+
) => Promise<unknown>
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function defineHandler<TReturn>(
|
|
60
|
+
method: string,
|
|
61
|
+
handler: (ctx: HostHandlerContext) => Promise<TReturn> | TReturn,
|
|
62
|
+
): HostHandlerEntry
|
|
63
|
+
|
|
64
|
+
export function defineHandler<TSchema extends z.ZodTypeAny, TReturn>(
|
|
65
|
+
method: string,
|
|
66
|
+
schema: TSchema,
|
|
67
|
+
handler: (
|
|
68
|
+
ctx: HostHandlerContext,
|
|
69
|
+
params: z.infer<TSchema>,
|
|
70
|
+
) => Promise<TReturn> | TReturn,
|
|
71
|
+
): HostHandlerEntry
|
|
72
|
+
|
|
73
|
+
export function defineHandler(
|
|
74
|
+
method: string,
|
|
75
|
+
schemaOrHandler:
|
|
76
|
+
| z.ZodTypeAny
|
|
77
|
+
| ((ctx: HostHandlerContext) => Promise<unknown> | unknown),
|
|
78
|
+
maybeHandler?: (
|
|
79
|
+
ctx: HostHandlerContext,
|
|
80
|
+
params: unknown,
|
|
81
|
+
) => Promise<unknown> | unknown,
|
|
82
|
+
): HostHandlerEntry {
|
|
83
|
+
if (typeof schemaOrHandler === "function") {
|
|
84
|
+
return {
|
|
85
|
+
method,
|
|
86
|
+
schema: undefined,
|
|
87
|
+
handler: async (ctx) => schemaOrHandler(ctx),
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (maybeHandler === undefined) {
|
|
91
|
+
throw new Error(`defineHandler("${method}") called without a handler`)
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
method,
|
|
95
|
+
schema: schemaOrHandler,
|
|
96
|
+
handler: async (ctx, params) => maybeHandler(ctx, params),
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Create the per-window message handler: authenticates the event,
|
|
102
|
+
* routes requests by method, validates params, drops stale scopes and
|
|
103
|
+
* wraps every outcome in the response envelope.
|
|
104
|
+
*/
|
|
105
|
+
export function createHostRouter(
|
|
106
|
+
handlers: readonly HostHandlerEntry[],
|
|
107
|
+
deps: HostRouterDeps,
|
|
108
|
+
): (event: MessageEvent) => void {
|
|
109
|
+
const registry = new Map<string, HostHandlerEntry["handler"]>()
|
|
110
|
+
const schemas = new Map<string, z.ZodTypeAny>()
|
|
111
|
+
for (const entry of handlers) {
|
|
112
|
+
if (registry.has(entry.method)) {
|
|
113
|
+
throw new Error(`Duplicate handler method: ${entry.method}`)
|
|
114
|
+
}
|
|
115
|
+
registry.set(entry.method, entry.handler)
|
|
116
|
+
if (entry.schema !== undefined) schemas.set(entry.method, entry.schema)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Warn once per source so a mismatched plugin build does not spam the
|
|
120
|
+
// console on every message, while still surfacing the problem loudly.
|
|
121
|
+
const warnedSources = new WeakSet<object>()
|
|
122
|
+
|
|
123
|
+
return function handleMessage(event: MessageEvent) {
|
|
124
|
+
const resolved = deps.resolveSource(event)
|
|
125
|
+
if (resolved === undefined) return
|
|
126
|
+
const { source, record } = resolved
|
|
127
|
+
|
|
128
|
+
const msg = event.data as PluginMessage
|
|
129
|
+
if (msg == null || typeof msg !== "object" || msg.type === undefined) {
|
|
130
|
+
return
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Protocol handshake: every SDK-build plugin stamps its messages
|
|
134
|
+
// with the protocol version it was built against. A mismatch (or a
|
|
135
|
+
// missing stamp — an old plugin build) means the wire contract may
|
|
136
|
+
// have drifted; the message still routes so old plugins keep
|
|
137
|
+
// working, but the developer gets a loud warning.
|
|
138
|
+
const proto = (msg as { proto?: unknown }).proto
|
|
139
|
+
if (proto !== PROTOCOL_VERSION && !warnedSources.has(source as object)) {
|
|
140
|
+
warnedSources.add(source as object)
|
|
141
|
+
if (proto === undefined) {
|
|
142
|
+
console.warn(
|
|
143
|
+
`[host-web] plugin iframe (${record.pluginId}) did not stamp its protocol version — it was built against an older SDK. Assuming PROTOCOL_VERSION ${PROTOCOL_VERSION}; rebuild the plugin to silence this warning.`,
|
|
144
|
+
)
|
|
145
|
+
} else {
|
|
146
|
+
console.warn(
|
|
147
|
+
`[host-web] plugin iframe (${record.pluginId}) speaks protocol version ${String(proto)} but this host speaks ${PROTOCOL_VERSION} — the plugin was built against a different SDK version and may misbehave.`,
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (msg.type === "subscribe") {
|
|
153
|
+
deps.subscribe(source, msg.key)
|
|
154
|
+
return
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (msg.type !== "request") return
|
|
158
|
+
|
|
159
|
+
function respond(response: HostResponse): void {
|
|
160
|
+
deps.respond(source, response)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// The SDK stamps each request with the resource it was issued for
|
|
164
|
+
// (PluginRequest.resId). A stamp that no longer matches the
|
|
165
|
+
// binding marks the request as stale — the tree that issued it is
|
|
166
|
+
// gone (e.g. an unmount flush racing a rebind) — so it is dropped
|
|
167
|
+
// silently instead of leaking into the wrong resource. Unstamped
|
|
168
|
+
// requests (older plugin builds) use the current binding, which
|
|
169
|
+
// outlives release, so late flushes after a close still land.
|
|
170
|
+
if (
|
|
171
|
+
typeof msg.resId === "string" &&
|
|
172
|
+
msg.resId !== "" &&
|
|
173
|
+
msg.resId !== record.resId
|
|
174
|
+
) {
|
|
175
|
+
respond({ type: "response", id: msg.id, ok: true })
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const ctx: HostHandlerContext = {
|
|
180
|
+
source,
|
|
181
|
+
resId: record.resId,
|
|
182
|
+
pluginId: record.pluginId,
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const handler = registry.get(msg.method)
|
|
186
|
+
if (handler === undefined) {
|
|
187
|
+
respond({
|
|
188
|
+
type: "response",
|
|
189
|
+
id: msg.id,
|
|
190
|
+
ok: false,
|
|
191
|
+
error: `Unknown method: ${msg.method}`,
|
|
192
|
+
})
|
|
193
|
+
return
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
let params: unknown
|
|
197
|
+
const schema = schemas.get(msg.method)
|
|
198
|
+
if (schema !== undefined) {
|
|
199
|
+
const parsed = schema.safeParse(msg.params)
|
|
200
|
+
if (!parsed.success) {
|
|
201
|
+
respond({
|
|
202
|
+
type: "response",
|
|
203
|
+
id: msg.id,
|
|
204
|
+
ok: false,
|
|
205
|
+
error: `Invalid params for ${msg.method}: ${parsed.error.message}`,
|
|
206
|
+
})
|
|
207
|
+
return
|
|
208
|
+
}
|
|
209
|
+
params = parsed.data
|
|
210
|
+
} else {
|
|
211
|
+
params = msg.params
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
handler(ctx, params)
|
|
215
|
+
.then((data) => {
|
|
216
|
+
respond({ type: "response", id: msg.id, ok: true, data })
|
|
217
|
+
})
|
|
218
|
+
.catch((err) => {
|
|
219
|
+
const errorName =
|
|
220
|
+
err instanceof Error && err.name !== "Error" ? err.name : undefined
|
|
221
|
+
respond({
|
|
222
|
+
type: "response",
|
|
223
|
+
id: msg.id,
|
|
224
|
+
ok: false,
|
|
225
|
+
error: err instanceof Error ? err.message : String(err),
|
|
226
|
+
// Machine-readable plugin error code (`DENIED`/…)
|
|
227
|
+
// travels as `errorCode`; `errorName` is the legacy
|
|
228
|
+
// alias (same value) so older SDK builds keep working.
|
|
229
|
+
errorCode: errorName,
|
|
230
|
+
errorName,
|
|
231
|
+
})
|
|
232
|
+
})
|
|
233
|
+
}
|
|
234
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @hoardodile/host-web — the browser-side plugin host runtime:
|
|
3
|
+
* the shared host-core protocol router (used by apps/web in production
|
|
4
|
+
* and by the offline mock) plus the mock host for component tests and
|
|
5
|
+
* the workbench. The wire protocol itself stays in
|
|
6
|
+
* @hoardodile/sdk-web — this package consumes it, never redefines
|
|
7
|
+
* it. Node file backends live in `@hoardodile/host-web/node`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export {
|
|
11
|
+
closeDownloadConsent,
|
|
12
|
+
type DownloadConsentEntry,
|
|
13
|
+
decideDownloadConsent,
|
|
14
|
+
enqueueDownloadConsent,
|
|
15
|
+
getDownloadConsentSnapshot,
|
|
16
|
+
rehydrateDownloadConsent,
|
|
17
|
+
requestDownloadConsent,
|
|
18
|
+
resetDownloadConsent,
|
|
19
|
+
subscribeDownloadConsent,
|
|
20
|
+
} from "./consent/consent-store.ts"
|
|
21
|
+
export { anchorData, requestSchemas } from "./host-core/request-schemas.ts"
|
|
22
|
+
export {
|
|
23
|
+
createHostRouter,
|
|
24
|
+
defineHandler,
|
|
25
|
+
type HostBinding,
|
|
26
|
+
type HostHandlerContext,
|
|
27
|
+
type HostHandlerEntry,
|
|
28
|
+
type HostRouterDeps,
|
|
29
|
+
} from "./host-core/router.ts"
|
|
30
|
+
export {
|
|
31
|
+
createInMemoryFileBackend,
|
|
32
|
+
type MockFileBackend,
|
|
33
|
+
type ReadFileRange,
|
|
34
|
+
} from "./mock/file-backends.ts"
|
|
35
|
+
export {
|
|
36
|
+
createMockHost,
|
|
37
|
+
type MockHost,
|
|
38
|
+
type MockHostLogger,
|
|
39
|
+
type MockHostOptions,
|
|
40
|
+
type PluginAssetVaultMock,
|
|
41
|
+
} from "./mock/host.ts"
|
|
42
|
+
export {
|
|
43
|
+
createMockDanmakuStore,
|
|
44
|
+
createMockMessageStore,
|
|
45
|
+
type MockDanmakuStore,
|
|
46
|
+
type MockMessageStore,
|
|
47
|
+
} from "./mock/stores.ts"
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { ReadFileRange } from "@hoardodile/sdk-types"
|
|
2
|
+
|
|
3
|
+
/** Shared read-range contract; defined once in `@hoardodile/sdk-types`. */
|
|
4
|
+
export type { ReadFileRange } from "@hoardodile/sdk-types"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* File backends for the offline mock host: the plugin's `listFiles` and
|
|
8
|
+
* `readFile` requests resolve against this interface, so a mock can be
|
|
9
|
+
* pointed at an in-memory map, a real directory (Node), or a read-only
|
|
10
|
+
* HTTP mount (workbench) without changing the host.
|
|
11
|
+
*/
|
|
12
|
+
export type MockFileBackend = {
|
|
13
|
+
readonly listFiles: (resId: string) => Promise<readonly string[]>
|
|
14
|
+
readonly readFile: (
|
|
15
|
+
resId: string,
|
|
16
|
+
path: string,
|
|
17
|
+
range?: ReadFileRange,
|
|
18
|
+
) => Promise<ArrayBuffer>
|
|
19
|
+
readonly statFile: (
|
|
20
|
+
resId: string,
|
|
21
|
+
path: string,
|
|
22
|
+
) => Promise<{ readonly sizeBytes: number } | undefined>
|
|
23
|
+
/**
|
|
24
|
+
* The rows the plugin's own `listFiles` hook produced, when the
|
|
25
|
+
* backend can obtain them (the workbench reads them from a sandboxed
|
|
26
|
+
* hook snapshot). In production the host answers `listFiles` with
|
|
27
|
+
* exactly these plugin-shaped entries; returning `undefined` falls
|
|
28
|
+
* back to generic `{filename, ext, sizeBytes}` rows.
|
|
29
|
+
*/
|
|
30
|
+
readonly listFileEntries?: (
|
|
31
|
+
resId: string,
|
|
32
|
+
) => Promise<readonly unknown[] | undefined>
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** In-memory file map backend for unit tests. `resId` is ignored. */
|
|
36
|
+
export function createInMemoryFileBackend(
|
|
37
|
+
files: Readonly<Record<string, string | Uint8Array>> = {},
|
|
38
|
+
): MockFileBackend {
|
|
39
|
+
return {
|
|
40
|
+
async listFiles() {
|
|
41
|
+
return Object.keys(files)
|
|
42
|
+
},
|
|
43
|
+
async readFile(_resId, path, range) {
|
|
44
|
+
const content = files[path]
|
|
45
|
+
if (content === undefined) {
|
|
46
|
+
throw new Error(`mock file backend has no entry ${path}`)
|
|
47
|
+
}
|
|
48
|
+
const bytes =
|
|
49
|
+
typeof content === "string"
|
|
50
|
+
? new TextEncoder().encode(content)
|
|
51
|
+
: content
|
|
52
|
+
// Mirrors host semantics: the range is clamped to the content
|
|
53
|
+
// size; a start at or past the end resolves to an empty result.
|
|
54
|
+
if (range === undefined) return bytes.slice().buffer
|
|
55
|
+
const start = Math.max(0, range.start ?? 0)
|
|
56
|
+
const end = Math.min(range.end ?? bytes.length, bytes.length)
|
|
57
|
+
return bytes.slice(start, end).buffer
|
|
58
|
+
},
|
|
59
|
+
async statFile(_resId, path) {
|
|
60
|
+
const content = files[path]
|
|
61
|
+
if (content === undefined) return undefined
|
|
62
|
+
const bytes =
|
|
63
|
+
typeof content === "string"
|
|
64
|
+
? new TextEncoder().encode(content)
|
|
65
|
+
: content
|
|
66
|
+
return { sizeBytes: bytes.byteLength }
|
|
67
|
+
},
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import type { PluginIframeContext } from "@hoardodile/sdk-web"
|
|
2
|
+
import { createIframeHostAPI, ensureHostBridge } from "@hoardodile/sdk-web"
|
|
3
|
+
import { afterEach, describe, expect, test, vi } from "vitest"
|
|
4
|
+
import { createInMemoryFileBackend } from "./file-backends.ts"
|
|
5
|
+
import { createMockHost } from "./host.ts"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Full bridge round-trip in jsdom: the plugin-side runtime posts to
|
|
9
|
+
* `window.parent` (itself here), the mock host listens on the same
|
|
10
|
+
* window, and responses come back through the same postMessage channel —
|
|
11
|
+
* the exact wire a real sandboxed iframe uses.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
function buildContext(
|
|
15
|
+
overrides: Partial<PluginIframeContext> = {},
|
|
16
|
+
): PluginIframeContext {
|
|
17
|
+
return {
|
|
18
|
+
pluginId: "test-plugin",
|
|
19
|
+
resId: "r-1",
|
|
20
|
+
resName: "Test Resource",
|
|
21
|
+
sourceMeta: undefined,
|
|
22
|
+
searchMeta: undefined,
|
|
23
|
+
fileStats: undefined,
|
|
24
|
+
contentPluginId: "test-plugin",
|
|
25
|
+
language: "en",
|
|
26
|
+
resolvedTheme: "dark",
|
|
27
|
+
palette: "parchment",
|
|
28
|
+
iconStyle: "duotone",
|
|
29
|
+
fonts: { family: "", cssPaths: [] },
|
|
30
|
+
initialPrefs: {},
|
|
31
|
+
initialCache: {},
|
|
32
|
+
fileToken: "",
|
|
33
|
+
assetToken: "",
|
|
34
|
+
...overrides,
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
// Ensure no global bridge listener leaks between tests.
|
|
40
|
+
window.parent.postMessage = window.parent.postMessage
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
describe("createMockHost bridge", () => {
|
|
44
|
+
test("listFiles and readFile round-trip through postMessage", async () => {
|
|
45
|
+
const host = createMockHost({
|
|
46
|
+
targetWindow: window,
|
|
47
|
+
files: createInMemoryFileBackend({ "a.txt": "hello", "b.bin": "beta" }),
|
|
48
|
+
})
|
|
49
|
+
host.register(window, { pluginId: "test-plugin", resId: "r-1" })
|
|
50
|
+
try {
|
|
51
|
+
ensureHostBridge()
|
|
52
|
+
const api = createIframeHostAPI(buildContext())
|
|
53
|
+
|
|
54
|
+
// No listFileEntries: the server's own fallback is bare,
|
|
55
|
+
// naturally sorted filenames.
|
|
56
|
+
expect(await api.listFiles()).toEqual(["a.txt", "b.bin"])
|
|
57
|
+
const data = await api.readFile("a.txt")
|
|
58
|
+
expect(new TextDecoder().decode(data)).toBe("hello")
|
|
59
|
+
} finally {
|
|
60
|
+
host.dispose()
|
|
61
|
+
}
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test("the listFiles fallback sorts naturally, not lexicographically", async () => {
|
|
65
|
+
const host = createMockHost({
|
|
66
|
+
targetWindow: window,
|
|
67
|
+
files: createInMemoryFileBackend({
|
|
68
|
+
"10.png": "a",
|
|
69
|
+
"2.png": "b",
|
|
70
|
+
"1.png": "c",
|
|
71
|
+
}),
|
|
72
|
+
})
|
|
73
|
+
host.register(window, { pluginId: "test-plugin", resId: "r-1" })
|
|
74
|
+
try {
|
|
75
|
+
ensureHostBridge()
|
|
76
|
+
const api = createIframeHostAPI(buildContext())
|
|
77
|
+
|
|
78
|
+
expect(await api.listFiles()).toEqual(["1.png", "2.png", "10.png"])
|
|
79
|
+
} finally {
|
|
80
|
+
host.dispose()
|
|
81
|
+
}
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test("listFileEntries serves the plugin's own file rows", async () => {
|
|
85
|
+
const pluginRows = [
|
|
86
|
+
{ filename: "01.jpg", type: "image", width: 800, height: 1200 },
|
|
87
|
+
{ filename: "clip.mp4", type: "video", durationMs: 4200 },
|
|
88
|
+
]
|
|
89
|
+
const host = createMockHost({
|
|
90
|
+
targetWindow: window,
|
|
91
|
+
files: {
|
|
92
|
+
...createInMemoryFileBackend({ "01.jpg": "a", "clip.mp4": "b" }),
|
|
93
|
+
listFileEntries: async () => pluginRows,
|
|
94
|
+
},
|
|
95
|
+
})
|
|
96
|
+
host.register(window, { pluginId: "test-plugin", resId: "r-1" })
|
|
97
|
+
try {
|
|
98
|
+
ensureHostBridge()
|
|
99
|
+
const api = createIframeHostAPI(buildContext())
|
|
100
|
+
|
|
101
|
+
expect(await api.listFiles()).toEqual(pluginRows)
|
|
102
|
+
} finally {
|
|
103
|
+
host.dispose()
|
|
104
|
+
}
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test("listFileEntries returning undefined falls back to bare filenames", async () => {
|
|
108
|
+
const host = createMockHost({
|
|
109
|
+
targetWindow: window,
|
|
110
|
+
files: {
|
|
111
|
+
...createInMemoryFileBackend({ "a.txt": "hello" }),
|
|
112
|
+
listFileEntries: async () => undefined,
|
|
113
|
+
},
|
|
114
|
+
})
|
|
115
|
+
host.register(window, { pluginId: "test-plugin", resId: "r-1" })
|
|
116
|
+
try {
|
|
117
|
+
ensureHostBridge()
|
|
118
|
+
const api = createIframeHostAPI(buildContext())
|
|
119
|
+
|
|
120
|
+
expect(await api.listFiles()).toEqual(["a.txt"])
|
|
121
|
+
} finally {
|
|
122
|
+
host.dispose()
|
|
123
|
+
}
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
test("messages round-trip into the in-memory store", async () => {
|
|
127
|
+
const host = createMockHost({
|
|
128
|
+
targetWindow: window,
|
|
129
|
+
files: createInMemoryFileBackend(),
|
|
130
|
+
})
|
|
131
|
+
host.register(window, { pluginId: "test-plugin", resId: "r-1" })
|
|
132
|
+
try {
|
|
133
|
+
ensureHostBridge()
|
|
134
|
+
const api = createIframeHostAPI(buildContext())
|
|
135
|
+
|
|
136
|
+
const created = await api.createMessage({
|
|
137
|
+
body: "hello world",
|
|
138
|
+
anchor: { data: { page: 2 } },
|
|
139
|
+
})
|
|
140
|
+
expect(created.body).toBe("hello world")
|
|
141
|
+
expect((created.anchor as { resId: string }).resId).toBe("r-1")
|
|
142
|
+
|
|
143
|
+
const list = await api.listMessages()
|
|
144
|
+
expect(list).toHaveLength(1)
|
|
145
|
+
expect(list[0]?.body).toBe("hello world")
|
|
146
|
+
} finally {
|
|
147
|
+
host.dispose()
|
|
148
|
+
}
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
test("prefs and cache writes land in the host maps", async () => {
|
|
152
|
+
const host = createMockHost({
|
|
153
|
+
targetWindow: window,
|
|
154
|
+
files: createInMemoryFileBackend(),
|
|
155
|
+
})
|
|
156
|
+
host.register(window, { pluginId: "test-plugin", resId: "r-1" })
|
|
157
|
+
try {
|
|
158
|
+
ensureHostBridge()
|
|
159
|
+
const api = createIframeHostAPI(buildContext())
|
|
160
|
+
|
|
161
|
+
// setPref/setCache are fire-and-forget on the wire — poll for
|
|
162
|
+
// the request to land in the host maps.
|
|
163
|
+
api.setPref("theme", "dark")
|
|
164
|
+
await vi.waitFor(() => expect(host.prefs.get("theme")).toBe("dark"))
|
|
165
|
+
api.setCache("scroll", "42")
|
|
166
|
+
await vi.waitFor(() => expect(host.cache.get("r-1:scroll")).toBe("42"))
|
|
167
|
+
} finally {
|
|
168
|
+
host.dispose()
|
|
169
|
+
}
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
test("requests from unregistered sources are dropped", async () => {
|
|
173
|
+
const host = createMockHost({
|
|
174
|
+
targetWindow: window,
|
|
175
|
+
files: createInMemoryFileBackend({ "a.txt": "hello" }),
|
|
176
|
+
})
|
|
177
|
+
try {
|
|
178
|
+
ensureHostBridge()
|
|
179
|
+
const api = createIframeHostAPI(buildContext())
|
|
180
|
+
|
|
181
|
+
// No registration: the request never resolves. The bridge's
|
|
182
|
+
// request timeout (10s) bounds the wait.
|
|
183
|
+
await expect(api.listFiles()).rejects.toThrow(/timed out/)
|
|
184
|
+
} finally {
|
|
185
|
+
host.dispose()
|
|
186
|
+
}
|
|
187
|
+
}, 15_000)
|
|
188
|
+
})
|