@dolphy-app/extension-sdk 0.4.0 → 0.5.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/dist/testing.js CHANGED
@@ -1,7 +1,94 @@
1
- import { t as createAnswerElementClass } from "./answer-element-BOQcuxYh.js";
2
- import { ANSWER_EVENT, COLOR_SETTING_PATTERN, EXTENSION_COMMAND_LIMITS, EXTENSION_NOTIFICATION_LIMITS, EXTENSION_SECRET_LIMITS, EXTENSION_STATS_LIMITS, EXTENSION_STORAGE_LIMITS, EXTENSION_TRANSFER_LIMITS, InvalidCommandResultError, InvalidTransferResultError, NotificationRateLimitError, PermissionError, SETTING_LIMITS, SecretsUnavailableError, StorageQuotaError, normalizeCommandResult, normalizeExportResult, normalizeImportResult } from "@dolphy-app/extension-api";
1
+ import { callRpc } from "./rpc.js";
2
+ import { COLOR_SETTING_PATTERN, DEFAULT_EXTENSION_ICON, EXTENSION_COMMAND_LIMITS, EXTENSION_HOOK_LIMITS, EXTENSION_HOOK_NAMES, EXTENSION_NOTIFICATION_LIMITS, EXTENSION_RPC_LIMITS, EXTENSION_SCHEDULE_LIMITS, EXTENSION_SECRET_LIMITS, EXTENSION_STATS_LIMITS, EXTENSION_STORAGE_LIMITS, EXTENSION_TRANSFER_LIMITS, INJECTION_LIMITS, INJECTION_POSITIONS, InvalidCommandResultError, InvalidTransferResultError, MARKDOWN_LANGUAGE_PATTERN, NotificationRateLimitError, RPC_NAME_PATTERN, SETTING_LIMITS, SecretsUnavailableError, StorageQuotaError, normalizeCommandResult, normalizeExportResult, normalizeImportResult } from "@dolphy-app/extension-api";
3
+ import { z } from "zod";
3
4
  import { Ajv2020 } from "ajv/dist/2020.js";
4
5
 
6
+ //#region packages/extension-api/src/hooks.ts
7
+ /** Limits on hooks; the host and the engine check the same numbers. */
8
+ const EXTENSION_HOOK_LIMITS$1 = Object.freeze({
9
+ /** Budget of one handler call, ms. */
10
+ timeoutMs: 3e4,
11
+ /** Hooks registered by one extension. */
12
+ hooks: 8,
13
+ /** Exercises in a `practice.batch` response. */
14
+ maxExercises: 500
15
+ });
16
+
17
+ //#endregion
18
+ //#region packages/extension-api/src/hook-schemas.ts
19
+ /**
20
+ * Request and response schemas of the hooks in `hooks.ts`. They live apart
21
+ * from the names and types so that code which only registers or types a hook
22
+ * (every browser bundle imports this package) does not pull `zod` in; the host,
23
+ * the engine and the test server import them from
24
+ * `@dolphy-app/extension-api/hook-schemas`.
25
+ */
26
+ const itemReason = z.enum([
27
+ "review",
28
+ "new",
29
+ "remediation"
30
+ ]);
31
+ /** `null` fields: the exercise has no attempts yet, so there is no memory state. */
32
+ const exerciseMemory = z.object({
33
+ /** Probability of recall right now, 0..1. */
34
+ retrievability: z.number().nullable(),
35
+ /** Unix epoch milliseconds of the newest attempt. */
36
+ lastAttemptAt: z.number().nullable(),
37
+ /** Attempts that count (cancelled ones excluded). */
38
+ attempts: z.number(),
39
+ /** FSRS stability in days. */
40
+ stability: z.number().nullable(),
41
+ /** FSRS difficulty. */
42
+ difficulty: z.number().nullable()
43
+ });
44
+ /**
45
+ * Request and response schema of every hook. The handlers of the extensions
46
+ * that registered a hook run one after another in ascending order of the
47
+ * extension id; each gets the response of the previous one (for a hook
48
+ * without a response, the same request). A thrown error, an invalid
49
+ * response or a handler exceeding `EXTENSION_HOOK_LIMITS.timeoutMs` cancels
50
+ * the operation and the error message reaches the user.
51
+ */
52
+ const EXTENSION_HOOKS = Object.freeze({
53
+ /**
54
+ * A learning session is about to start. The handler can only cancel the
55
+ * start by throwing.
56
+ */
57
+ "session.start": {
58
+ request: z.object({
59
+ /** Unix epoch milliseconds. */
60
+ now: z.number() }),
61
+ response: z.void()
62
+ },
63
+ /**
64
+ * A batch of exercises or a day plan is about to be handed to the learner.
65
+ * `exerciseIds` and `reasons` are parallel arrays; the response can
66
+ * reorder, remove and add exercises (every id must exist in the library;
67
+ * `exerciseIds` and `reasons` keep the same length, at most
68
+ * `EXTENSION_HOOK_LIMITS.maxExercises`).
69
+ */
70
+ "practice.batch": {
71
+ request: z.object({
72
+ /** The open session; `null` while none has started. */
73
+ sessionId: z.string().nullable(),
74
+ /** `batch` — `practice.getBatch`, `plan` — `plan.getDay`. */
75
+ source: z.enum(["batch", "plan"]),
76
+ exerciseIds: z.array(z.string()),
77
+ reasons: z.array(itemReason),
78
+ /**
79
+ * Parallel to `exerciseIds`: what the engine remembers of each
80
+ * exercise. Read-only; the FSRS state is not changed by the hook.
81
+ */
82
+ memory: z.array(exerciseMemory)
83
+ }),
84
+ response: z.object({
85
+ exerciseIds: z.array(z.string()).max(EXTENSION_HOOK_LIMITS$1.maxExercises),
86
+ reasons: z.array(itemReason).max(EXTENSION_HOOK_LIMITS$1.maxExercises)
87
+ }).refine(({ exerciseIds, reasons }) => exerciseIds.length === reasons.length, { message: "exerciseIds and reasons must have the same length" })
88
+ }
89
+ });
90
+
91
+ //#endregion
5
92
  //#region packages/extension-sdk/src/testing.ts
6
93
  const MAX_MESSAGES = 6;
7
94
  const MAX_REASON_CHARS = 100;
@@ -14,6 +101,10 @@ const silentLogger = {
14
101
  warn: () => void 0,
15
102
  error: () => void 0
16
103
  };
104
+ /** What a test did not provide: every property read throws an error that names the option to pass. */
105
+ const unprovided = (name, option) => new Proxy({}, { get: (_target, property) => {
106
+ throw new Error(`${name}.${String(property)} is not available: pass options.${option}`);
107
+ } });
17
108
  const createMemoryLibrary = (files) => {
18
109
  const entries = new Map(Object.entries(files));
19
110
  return {
@@ -133,14 +224,18 @@ const findSettingProblem = (definition, value) => {
133
224
  default: return typeof value === "string" && definition.options.some((option) => option.value === value) ? null : "must be one of the options";
134
225
  }
135
226
  };
136
- /** In-memory settings from manifest definitions; `initial` provides user values in place of `default`. */
137
- const createMemorySettings = (definitions, initial = {}) => {
138
- const byId = new Map(definitions.map((definition) => [definition.id, definition]));
139
- const values = new Map(definitions.map((definition) => [definition.id, storedForm(definition, definition.default)]));
227
+ /**
228
+ * In-memory settings. `definitions` are registered at once, more come through
229
+ * `register`; `initial` provides user values in place of `default`, applied
230
+ * when the setting with that id is registered.
231
+ */
232
+ const createMemorySettings = (definitions = [], initial = {}) => {
233
+ const byId = /* @__PURE__ */ new Map();
234
+ const values = /* @__PURE__ */ new Map();
140
235
  const handlers = /* @__PURE__ */ new Set();
141
236
  const known = (id) => {
142
237
  const definition = byId.get(id);
143
- if (definition === void 0) throw new Error(`setting '${id}' is not declared in the manifest`);
238
+ if (definition === void 0) throw new Error(`setting '${id}' is not registered`);
144
239
  return definition;
145
240
  };
146
241
  const checked = (id, value) => {
@@ -148,8 +243,24 @@ const createMemorySettings = (definitions, initial = {}) => {
148
243
  if (problem !== null) throw new Error(`setting '${id}' ${problem}`);
149
244
  return storedForm(known(id), value);
150
245
  };
151
- for (const [id, value] of Object.entries(initial)) values.set(id, checked(id, value));
246
+ const register = (added) => {
247
+ for (const definition of added) {
248
+ if (byId.has(definition.id)) throw new Error(`setting '${definition.id}' is already registered`);
249
+ byId.set(definition.id, definition);
250
+ values.set(definition.id, storedForm(definition, definition.default));
251
+ const user = initial[definition.id];
252
+ if (user !== void 0) values.set(definition.id, checked(definition.id, user));
253
+ }
254
+ return { dispose: () => {
255
+ for (const definition of added) {
256
+ byId.delete(definition.id);
257
+ values.delete(definition.id);
258
+ }
259
+ } };
260
+ };
261
+ register(definitions);
152
262
  return {
263
+ register,
153
264
  get: (id) => storedForm(known(id), values.get(id)),
154
265
  onDidChange(handler) {
155
266
  handlers.add(handler);
@@ -166,141 +277,6 @@ const createMemorySettings = (definitions, initial = {}) => {
166
277
  }
167
278
  };
168
279
  };
169
- /** In-memory learning event subscriptions: one handler per event, as in the host. */
170
- const createMemoryEvents = (options = {}) => {
171
- const handlers = /* @__PURE__ */ new Map();
172
- return {
173
- on(name, handler) {
174
- if (options.permitted === false) throw new PermissionError("learning.events");
175
- if (options.declared !== void 0 && !options.declared.includes(name)) throw new Error(`event '${name}' is not declared in the manifest`);
176
- if (handlers.has(name)) throw new Error(`event '${name}' is already subscribed`);
177
- handlers.set(name, handler);
178
- return { dispose: () => {
179
- if (handlers.get(name) === handler) handlers.delete(name);
180
- } };
181
- },
182
- async emit(name, payload) {
183
- await handlers.get(name)?.(payload);
184
- }
185
- };
186
- };
187
- /** In-memory commands: the same registration rules and result parsing as the host. */
188
- const createMemoryCommands = (options = {}) => {
189
- const handlers = /* @__PURE__ */ new Map();
190
- return {
191
- register(id, handler) {
192
- if (options.declaredCommands !== void 0 && !options.declaredCommands.includes(id)) throw new Error(`command '${id}' is not declared in the manifest`);
193
- if (handlers.has(id)) throw new Error(`command '${id}' is already registered`);
194
- handlers.set(id, handler);
195
- return { dispose: () => {
196
- if (handlers.get(id) === handler) handlers.delete(id);
197
- } };
198
- },
199
- async run(id, args) {
200
- const handler = handlers.get(id);
201
- if (handler === void 0) throw new Error(`command '${id}' was not registered`);
202
- if ((JSON.stringify(args)?.length ?? 0) > EXTENSION_COMMAND_LIMITS.argsChars) throw new Error(`args are longer than ${EXTENSION_COMMAND_LIMITS.argsChars} characters`);
203
- const result = await handler(args);
204
- try {
205
- return normalizeCommandResult(result, options.declaredPanels);
206
- } catch (error) {
207
- if (error instanceof InvalidCommandResultError) throw new Error(`invalid command result: ${error.message}`);
208
- throw error;
209
- }
210
- },
211
- ids: () => [...handlers.keys()]
212
- };
213
- };
214
- /** In-memory schedule subscriptions: one handler per schedule and no overlapping firings, as in the host. */
215
- const createMemorySchedule = (options = {}) => {
216
- const handlers = /* @__PURE__ */ new Map();
217
- const running = /* @__PURE__ */ new Set();
218
- return {
219
- on(id, handler) {
220
- if (options.declared !== void 0 && !options.declared.includes(id)) throw new Error(`schedule '${id}' is not declared in the manifest`);
221
- if (handlers.has(id)) throw new Error(`schedule '${id}' is already subscribed`);
222
- handlers.set(id, handler);
223
- return { dispose: () => {
224
- if (handlers.get(id) === handler) handlers.delete(id);
225
- } };
226
- },
227
- async fire(id) {
228
- const handler = handlers.get(id);
229
- if (handler === void 0 || running.has(id)) return false;
230
- running.add(id);
231
- try {
232
- await handler();
233
- return true;
234
- } finally {
235
- running.delete(id);
236
- }
237
- },
238
- ids: () => [...handlers.keys()]
239
- };
240
- };
241
- /** In-memory importers: the same registration rules and result checks as the host. */
242
- const createMemoryImporters = (options = {}) => {
243
- const handlers = /* @__PURE__ */ new Map();
244
- const declaredOf = (id) => options.declaredImporters?.find((entry) => entry.id === id);
245
- return {
246
- register(id, handler) {
247
- if (options.declaredImporters !== void 0 && declaredOf(id) === void 0) throw new Error(`importer '${id}' is not declared in the manifest`);
248
- if (handlers.has(id)) throw new Error(`importer '${id}' is already registered`);
249
- handlers.set(id, handler);
250
- return { dispose: () => {
251
- if (handlers.get(id) === handler) handlers.delete(id);
252
- } };
253
- },
254
- async run(id, input) {
255
- const handler = handlers.get(id);
256
- if (handler === void 0) throw new Error(`importer '${id}' was not registered`);
257
- const expected = declaredOf(id)?.input ?? "text";
258
- if (options.declaredImporters !== void 0 && ("text" in input ? "text" : "bytes") !== expected) throw new Error(`importer '${id}' takes ${expected} input`);
259
- if (("text" in input ? new TextEncoder().encode(input.text).length : input.bytes.byteLength) > EXTENSION_TRANSFER_LIMITS.inputBytes) throw new Error(`the file is longer than ${EXTENSION_TRANSFER_LIMITS.inputBytes} bytes`);
260
- const result = await handler(input);
261
- try {
262
- return normalizeImportResult(result);
263
- } catch (error) {
264
- if (error instanceof InvalidTransferResultError) throw new Error(`invalid import result: ${error.message}`);
265
- throw error;
266
- }
267
- },
268
- ids: () => [...handlers.keys()]
269
- };
270
- };
271
- /** In-memory exporters: the same registration rules and result checks as the host. */
272
- const createMemoryExporters = (options = {}) => {
273
- const handlers = /* @__PURE__ */ new Map();
274
- const declaredOf = (id) => options.declaredExporters?.find((entry) => entry.id === id);
275
- return {
276
- register(id, handler) {
277
- if (options.declaredExporters !== void 0 && declaredOf(id) === void 0) throw new Error(`exporter '${id}' is not declared in the manifest`);
278
- if (handlers.has(id)) throw new Error(`exporter '${id}' is already registered`);
279
- handlers.set(id, handler);
280
- return { dispose: () => {
281
- if (handlers.get(id) === handler) handlers.delete(id);
282
- } };
283
- },
284
- async run(id, input) {
285
- const handler = handlers.get(id);
286
- if (handler === void 0) throw new Error(`exporter '${id}' was not registered`);
287
- const declared = declaredOf(id);
288
- if (declared !== void 0 && declared.scope !== input.scope) throw new Error(`exporter '${id}' takes the ${declared.scope} scope`);
289
- if (input.scope === "course") {
290
- const encoder = new TextEncoder();
291
- if (Object.values(input.files).reduce((sum, text) => sum + encoder.encode(text).length, 0) > EXTENSION_TRANSFER_LIMITS.totalBytes) throw new Error(`the course files are longer than ${EXTENSION_TRANSFER_LIMITS.totalBytes} bytes`);
292
- }
293
- const result = await handler(input);
294
- try {
295
- return normalizeExportResult(result);
296
- } catch (error) {
297
- if (error instanceof InvalidTransferResultError) throw new Error(`invalid export result: ${error.message}`);
298
- throw error;
299
- }
300
- },
301
- ids: () => [...handlers.keys()]
302
- };
303
- };
304
280
  const DAY_MS = 864e5;
305
281
  const STATS_CORRECT_GRADE_MIN = 3;
306
282
  const dayNumberOf = (year, month, day) => {
@@ -345,9 +321,6 @@ const createMemoryStats = (options = {}) => {
345
321
  });
346
322
  };
347
323
  for (const attempt of options.attempts ?? []) record(attempt);
348
- const guard = () => {
349
- if (options.permitted === false) throw new PermissionError("learning.stats");
350
- };
351
324
  const countsOf = (courseId) => {
352
325
  const days = /* @__PURE__ */ new Map();
353
326
  for (const item of attempts) {
@@ -365,7 +338,6 @@ const createMemoryStats = (options = {}) => {
365
338
  return {
366
339
  record,
367
340
  async streak(streakOptions) {
368
- guard();
369
341
  const days = countsOf(streakOptions?.courseId);
370
342
  const today = localDay((options.now ?? Date.now)());
371
343
  let longest = 0;
@@ -388,7 +360,6 @@ const createMemoryStats = (options = {}) => {
388
360
  };
389
361
  },
390
362
  async daily({ from, to, courseId }) {
391
- guard();
392
363
  const first = parseDay("from", from);
393
364
  const last = parseDay("to", to);
394
365
  if (last < first || last - first + 1 > EXTENSION_STATS_LIMITS.dailyDays) throw new Error(`the range must be ascending and cover at most ${EXTENSION_STATS_LIMITS.dailyDays} dates`);
@@ -442,7 +413,6 @@ const createMemoryNotifications = (options = {}) => {
442
413
  supported = value;
443
414
  },
444
415
  async show(notification) {
445
- if (options.permitted === false) throw new PermissionError("notifications");
446
416
  const title = notificationText("title", notification?.title, limits.titleLength);
447
417
  const body = notificationText("body", notification?.body, limits.bodyLength);
448
418
  if (!enabled) return false;
@@ -462,22 +432,6 @@ const createMemoryNotifications = (options = {}) => {
462
432
  }
463
433
  };
464
434
  };
465
- const contextOf = (options, registrars) => ({
466
- extensionId: "test",
467
- logger: options.logger ?? silentLogger,
468
- library: options.library ?? createMemoryLibrary({}),
469
- storage: options.storage ?? createMemoryStorage(),
470
- secrets: options.secrets ?? createMemorySecrets(),
471
- settings: options.settings ?? createMemorySettings([]),
472
- events: options.events ?? createMemoryEvents(),
473
- stats: options.stats ?? createMemoryStats(),
474
- notifications: options.notifications ?? createMemoryNotifications(),
475
- schedule: options.schedule ?? createMemorySchedule(),
476
- commands: options.commands ?? createMemoryCommands(),
477
- importers: options.importers ?? createMemoryImporters(),
478
- exporters: options.exporters ?? createMemoryExporters(),
479
- ...registrars
480
- });
481
435
  const createSchemaValidator = (schema) => {
482
436
  const validate = new Ajv2020({
483
437
  allErrors: true,
@@ -531,344 +485,500 @@ const findGradeResultProblem = (result) => {
531
485
  if (extra !== void 0) return `unexpected key '${extra}'`;
532
486
  return (outcome === "passed" ? null : reasonProblem(result)) ?? textProblem(result, "feedback") ?? textProblem(result, "detail");
533
487
  };
534
- const loadExerciseType = async (module, type, options = {}) => {
535
- const handlers = /* @__PURE__ */ new Map();
536
- const context = contextOf(options, {
537
- registerExerciseType: (registeredType, handler) => {
538
- handlers.set(registeredType, handler);
539
- return { dispose: () => void handlers.delete(registeredType) };
540
- },
541
- registerGradePolicy: () => ({ dispose: () => void 0 })
542
- });
543
- await module.activate(context);
544
- const handler = handlers.get(type);
545
- if (handler === void 0) throw new Error(`exercise type '${type}' was not registered`);
546
- return {
547
- project: async (spec, { exerciseId = DEFAULT_EXERCISE_ID } = {}) => handler.project({
548
- exerciseId,
549
- spec
550
- }),
551
- grade: async ({ spec, answer, exerciseId = DEFAULT_EXERCISE_ID, timeoutMs = DEFAULT_TIMEOUT_MS, authorMode = false }) => {
552
- const result = await handler.grade({
553
- exerciseId,
554
- spec,
555
- answer,
556
- timeoutMs,
557
- authorMode
558
- });
559
- const problem = findGradeResultProblem(result);
560
- if (problem !== null) throw new Error(`invalid grade result: ${problem}`);
561
- return result;
562
- },
563
- referenceAnswer: async (spec, { exerciseId = DEFAULT_EXERCISE_ID } = {}) => {
564
- const answer = await handler.referenceAnswer?.({
565
- exerciseId,
566
- spec
567
- });
568
- return answer === void 0 ? { found: false } : {
569
- found: true,
570
- answer
571
- };
572
- },
573
- dispose: async () => {
574
- await module.deactivate?.();
575
- }
488
+ const isGradeValue = (value) => typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 5;
489
+ const registeredSettingOf = (definition) => {
490
+ const base = {
491
+ id: definition.id,
492
+ label: definition.label,
493
+ description: definition.description ?? null,
494
+ group: definition.group ?? null,
495
+ order: definition.order ?? 0,
496
+ visibleWhen: definition.visibleWhen ?? null
576
497
  };
498
+ switch (definition.type) {
499
+ case "boolean":
500
+ case "color": return {
501
+ ...base,
502
+ type: definition.type,
503
+ default: definition.default
504
+ };
505
+ case "string":
506
+ case "text": return {
507
+ ...base,
508
+ type: definition.type,
509
+ default: definition.default,
510
+ maxLength: definition.maxLength ?? null
511
+ };
512
+ case "list": return {
513
+ ...base,
514
+ type: "list",
515
+ default: [...definition.default],
516
+ maxItems: definition.maxItems ?? SETTING_LIMITS.listItems,
517
+ itemMaxLength: definition.itemMaxLength ?? SETTING_LIMITS.listItemLength
518
+ };
519
+ case "number": return {
520
+ ...base,
521
+ type: "number",
522
+ default: definition.default,
523
+ min: definition.min ?? null,
524
+ max: definition.max ?? null,
525
+ integer: definition.integer ?? false
526
+ };
527
+ default: return {
528
+ ...base,
529
+ type: "enum",
530
+ default: definition.default,
531
+ options: definition.options.map((option) => ({ ...option }))
532
+ };
533
+ }
577
534
  };
578
- const isGradeValue = (value) => Number.isInteger(value) && value >= 1 && value <= 5;
579
- const loadGradePolicy = async (module, id, options = {}) => {
580
- const handlers = /* @__PURE__ */ new Map();
581
- const context = contextOf(options, {
582
- registerExerciseType: () => ({ dispose: () => void 0 }),
583
- registerGradePolicy: (registeredId, handler) => {
584
- handlers.set(registeredId, handler);
585
- return { dispose: () => void handlers.delete(registeredId) };
586
- }
587
- });
588
- await module.activate(context);
589
- const handler = handlers.get(id);
590
- if (handler === void 0) throw new Error(`grade policy '${id}' was not registered`);
591
- return {
592
- evaluate: async (input) => {
593
- const result = await handler(input);
594
- if (result !== null && !isGradeValue(result)) throw new Error(`invalid grade policy result: ${JSON.stringify(result)} is not an integer 1..5 or null`);
595
- return result;
596
- },
597
- dispose: async () => {
598
- await module.deactivate?.();
599
- }
600
- };
535
+ const registeredKeybindingOf = (binding) => ({
536
+ key: binding.key,
537
+ mac: binding.mac ?? null,
538
+ windows: binding.windows ?? null,
539
+ linux: binding.linux ?? null,
540
+ when: binding.when ?? null
541
+ });
542
+ const registered = (map, kind, id) => {
543
+ const entry = map.get(id);
544
+ if (entry === void 0) throw new Error(`${kind} '${id}' was not registered`);
545
+ return entry;
601
546
  };
602
547
  /**
603
- * Activates the module with in-memory storage, settings, and events, and lets the test
604
- * send events and change settings.
548
+ * Starts `entry` (the `server` export of an extension) with in-memory
549
+ * storage, secrets, settings, statistics, notifications and library, and
550
+ * returns a harness to call what it registered. Registration is all or
551
+ * nothing, as in the host: when `entry` throws, so does `createTestServer`.
605
552
  */
606
- const loadEvents = async (module, options = {}) => {
607
- const events = createMemoryEvents(options);
553
+ const createTestServer = async (entry, options = {}) => {
554
+ const extensionId = options.extensionId ?? "test";
555
+ const library = options.library ?? createMemoryLibrary({});
608
556
  const storage = options.storage ?? createMemoryStorage();
609
557
  const secrets = options.secrets ?? createMemorySecrets();
610
- const settings = createMemorySettings(options.settings ?? [], options.settingValues);
611
- const context = contextOf({
612
- ...options.library !== void 0 && { library: options.library },
613
- ...options.logger !== void 0 && { logger: options.logger },
614
- ...options.stats !== void 0 && { stats: options.stats },
615
- ...options.notifications !== void 0 && { notifications: options.notifications },
616
- ...options.schedule !== void 0 && { schedule: options.schedule },
558
+ const stats = options.stats ?? createMemoryStats();
559
+ const notifications = options.notifications ?? createMemoryNotifications();
560
+ const settings = createMemorySettings([], options.settingValues);
561
+ const exerciseTypes = /* @__PURE__ */ new Map();
562
+ const gradePolicies = /* @__PURE__ */ new Map();
563
+ const commands = /* @__PURE__ */ new Map();
564
+ const schedules = /* @__PURE__ */ new Map();
565
+ const importers = /* @__PURE__ */ new Map();
566
+ const exporters = /* @__PURE__ */ new Map();
567
+ const rpcs = /* @__PURE__ */ new Map();
568
+ const engine = options.engine ?? unprovided("engine", "engine");
569
+ const events = /* @__PURE__ */ new Map();
570
+ const hooks = /* @__PURE__ */ new Map();
571
+ const definitions = [];
572
+ const running = /* @__PURE__ */ new Set();
573
+ const add = (map, kind, id, value, max) => {
574
+ if (typeof id !== "string" || id === "") throw new Error(`${kind} id must be a non-empty string`);
575
+ if (options.extensionId !== void 0 && id !== extensionId && !id.startsWith(`${extensionId}.`)) throw new Error(`${kind} id '${id}' must be '${extensionId}' or start with '${extensionId}.'`);
576
+ if (map.has(id)) throw new Error(`${kind} '${id}' is already registered`);
577
+ if (max !== void 0 && map.size >= max) throw new Error(`more than ${max} ${kind}s`);
578
+ map.set(id, value);
579
+ return { dispose: () => {
580
+ if (map.get(id) === value) map.delete(id);
581
+ } };
582
+ };
583
+ const cleanup = await entry({
584
+ extensionId,
585
+ logger: options.logger ?? silentLogger,
586
+ library,
617
587
  storage,
618
588
  secrets,
619
589
  settings,
620
- events
621
- }, {
622
- registerExerciseType: () => ({ dispose: () => void 0 }),
623
- registerGradePolicy: () => ({ dispose: () => void 0 })
590
+ stats,
591
+ notifications,
592
+ engine,
593
+ registerExerciseType: (reg) => add(exerciseTypes, "exercise type", reg.id, reg),
594
+ registerGradePolicy: (reg) => add(gradePolicies, "grade policy", reg.id, reg),
595
+ registerSettings: (added) => {
596
+ const registeredSettings = settings.register(added);
597
+ definitions.push(...added);
598
+ return { dispose: () => {
599
+ registeredSettings.dispose();
600
+ for (const definition of added) definitions.splice(definitions.indexOf(definition), 1);
601
+ } };
602
+ },
603
+ on: (name, handler) => {
604
+ if (events.has(name)) throw new Error(`event '${name}' is already subscribed`);
605
+ events.set(name, handler);
606
+ return { dispose: () => {
607
+ if (events.get(name) === handler) events.delete(name);
608
+ } };
609
+ },
610
+ before: (name, handler) => {
611
+ if (!EXTENSION_HOOK_NAMES.includes(name)) throw new Error(`hook '${String(name)}' is not a known hook`);
612
+ if (hooks.has(name)) throw new Error(`hook '${name}' is already registered`);
613
+ if (hooks.size >= EXTENSION_HOOK_LIMITS.hooks) throw new Error(`more than ${EXTENSION_HOOK_LIMITS.hooks} hooks`);
614
+ hooks.set(name, handler);
615
+ return { dispose: () => {
616
+ if (hooks.get(name) === handler) hooks.delete(name);
617
+ } };
618
+ },
619
+ registerCommand: (reg) => add(commands, "command", reg.id, reg, EXTENSION_COMMAND_LIMITS.commands),
620
+ schedule: (reg, handler) => add(schedules, "schedule", reg.id, {
621
+ reg,
622
+ handler
623
+ }, EXTENSION_SCHEDULE_LIMITS.schedules),
624
+ registerImporter: (reg) => add(importers, "importer", reg.id, reg, EXTENSION_TRANSFER_LIMITS.importers),
625
+ registerExporter: (reg) => add(exporters, "exporter", reg.id, reg, EXTENSION_TRANSFER_LIMITS.exporters),
626
+ handle: (contract, handler) => {
627
+ const { name } = contract;
628
+ if (typeof name !== "string" || name.length > EXTENSION_RPC_LIMITS.nameLength || !RPC_NAME_PATTERN.test(name)) throw new Error(`rpc name '${String(name)}' is not valid`);
629
+ if (rpcs.has(name)) throw new Error(`rpc '${name}' is already registered`);
630
+ if (rpcs.size >= EXTENSION_RPC_LIMITS.rpcs) throw new Error(`more than ${EXTENSION_RPC_LIMITS.rpcs} rpcs`);
631
+ const entry = {
632
+ contract,
633
+ handler
634
+ };
635
+ rpcs.set(name, entry);
636
+ return { dispose: () => {
637
+ if (rpcs.get(name) === entry) rpcs.delete(name);
638
+ } };
639
+ }
624
640
  });
625
- await module.activate(context);
641
+ for (const id of Object.keys(options.settingValues ?? {})) if (!definitions.some((definition) => definition.id === id)) throw new Error(`setting '${id}' of settingValues is not registered`);
626
642
  return {
627
- emit: events.emit,
643
+ extensionId,
644
+ registration: {
645
+ exerciseTypes: [...exerciseTypes.values()].map((reg) => ({
646
+ id: reg.id,
647
+ title: reg.title ?? null,
648
+ specSchema: reg.specSchema,
649
+ answerSchema: reg.answerSchema
650
+ })),
651
+ gradePolicies: [...gradePolicies.values()].map((reg) => ({
652
+ id: reg.id,
653
+ label: reg.label
654
+ })),
655
+ settings: definitions.map(registeredSettingOf),
656
+ events: [...events.keys()],
657
+ commands: [...commands.values()].map((reg) => ({
658
+ id: reg.id,
659
+ title: reg.title,
660
+ description: reg.description ?? null,
661
+ category: reg.category ?? null,
662
+ palette: reg.palette ?? true,
663
+ icon: reg.icon ?? DEFAULT_EXTENSION_ICON,
664
+ keybindings: (reg.keybindings ?? []).map(registeredKeybindingOf),
665
+ when: reg.when ?? null
666
+ })),
667
+ schedules: [...schedules.values()].map(({ reg }) => ({
668
+ id: reg.id,
669
+ every: reg.every,
670
+ at: reg.every === "daily" ? reg.at : null
671
+ })),
672
+ importers: [...importers.values()].map((reg) => ({
673
+ id: reg.id,
674
+ title: reg.title,
675
+ accept: [...reg.accept],
676
+ input: reg.input
677
+ })),
678
+ exporters: [...exporters.values()].map((reg) => ({
679
+ id: reg.id,
680
+ title: reg.title,
681
+ scope: reg.scope
682
+ })),
683
+ rpcs: [...rpcs.keys()],
684
+ hooks: [...hooks.keys()]
685
+ },
686
+ library,
628
687
  storage,
629
688
  secrets,
630
689
  settings,
631
- dispose: async () => {
632
- await module.deactivate?.();
690
+ stats,
691
+ notifications,
692
+ engine,
693
+ commands: { async run(id, args) {
694
+ const { run } = registered(commands, "command", id);
695
+ if ((JSON.stringify(args)?.length ?? 0) > EXTENSION_COMMAND_LIMITS.argsChars) throw new Error(`args are longer than ${EXTENSION_COMMAND_LIMITS.argsChars} characters`);
696
+ const result = await run(args);
697
+ try {
698
+ return normalizeCommandResult(result, void 0);
699
+ } catch (error) {
700
+ if (error instanceof InvalidCommandResultError) throw new Error(`invalid command result: ${error.message}`);
701
+ throw error;
702
+ }
703
+ } },
704
+ events: { async emit(name, payload) {
705
+ await events.get(name)?.(payload);
706
+ } },
707
+ schedule: { async fire(id) {
708
+ const { handler } = registered(schedules, "schedule", id);
709
+ if (running.has(id)) return false;
710
+ running.add(id);
711
+ try {
712
+ await handler();
713
+ return true;
714
+ } finally {
715
+ running.delete(id);
716
+ }
717
+ } },
718
+ async rpc(contract, input) {
719
+ const { contract: registeredContract, handler } = registered(rpcs, "rpc", contract.name);
720
+ if ((JSON.stringify(input)?.length ?? 0) > EXTENSION_RPC_LIMITS.inputChars) throw new Error(`input is longer than ${EXTENSION_RPC_LIMITS.inputChars} characters`);
721
+ const result = await handler(registeredContract.input.parse(input));
722
+ return registeredContract.output.parse(result);
723
+ },
724
+ async hook(name, request) {
725
+ const handler = registered(hooks, "hook", name);
726
+ const schemas = EXTENSION_HOOKS[name];
727
+ const result = await handler(schemas.request.parse(request));
728
+ return schemas.response.parse(result);
729
+ },
730
+ exerciseType(id) {
731
+ const reg = registered(exerciseTypes, "exercise type", id);
732
+ return {
733
+ project: async (spec, { exerciseId = DEFAULT_EXERCISE_ID } = {}) => reg.project({
734
+ exerciseId,
735
+ spec
736
+ }),
737
+ grade: async ({ spec, answer, exerciseId = DEFAULT_EXERCISE_ID, timeoutMs = DEFAULT_TIMEOUT_MS, authorMode = false }) => {
738
+ const result = await reg.grade({
739
+ exerciseId,
740
+ spec,
741
+ answer,
742
+ timeoutMs,
743
+ authorMode
744
+ });
745
+ const problem = findGradeResultProblem(result);
746
+ if (problem !== null) throw new Error(`invalid grade result: ${problem}`);
747
+ return result;
748
+ },
749
+ referenceAnswer: async (spec, { exerciseId = DEFAULT_EXERCISE_ID } = {}) => {
750
+ const answer = await reg.referenceAnswer?.({
751
+ exerciseId,
752
+ spec
753
+ });
754
+ return answer === void 0 ? { found: false } : {
755
+ found: true,
756
+ answer
757
+ };
758
+ }
759
+ };
760
+ },
761
+ gradePolicy(id) {
762
+ const reg = registered(gradePolicies, "grade policy", id);
763
+ return { evaluate: async (input) => {
764
+ const result = await reg.evaluate(input);
765
+ if (result !== null && !isGradeValue(result)) throw new Error(`invalid grade policy result: ${JSON.stringify(result)} is not an integer 1..5 or null`);
766
+ return result;
767
+ } };
768
+ },
769
+ importer(id) {
770
+ const reg = registered(importers, "importer", id);
771
+ return { async run(input) {
772
+ if (("text" in input ? "text" : "bytes") !== reg.input) throw new Error(`importer '${id}' takes ${reg.input} input`);
773
+ if (("text" in input ? new TextEncoder().encode(input.text).length : input.bytes.byteLength) > EXTENSION_TRANSFER_LIMITS.inputBytes) throw new Error(`the file is longer than ${EXTENSION_TRANSFER_LIMITS.inputBytes} bytes`);
774
+ const result = await reg.run(input);
775
+ try {
776
+ return normalizeImportResult(result);
777
+ } catch (error) {
778
+ if (error instanceof InvalidTransferResultError) throw new Error(`invalid import result: ${error.message}`);
779
+ throw error;
780
+ }
781
+ } };
782
+ },
783
+ exporter(id) {
784
+ const reg = registered(exporters, "exporter", id);
785
+ return { async run(input) {
786
+ if (reg.scope !== input.scope) throw new Error(`exporter '${id}' takes the ${reg.scope} scope`);
787
+ if (input.scope === "course") {
788
+ const encoder = new TextEncoder();
789
+ if (Object.values(input.files).reduce((sum, text) => sum + encoder.encode(text).length, 0) > EXTENSION_TRANSFER_LIMITS.totalBytes) throw new Error(`the course files are longer than ${EXTENSION_TRANSFER_LIMITS.totalBytes} bytes`);
790
+ }
791
+ const result = await reg.run(input);
792
+ try {
793
+ return normalizeExportResult(result);
794
+ } catch (error) {
795
+ if (error instanceof InvalidTransferResultError) throw new Error(`invalid export result: ${error.message}`);
796
+ throw error;
797
+ }
798
+ } };
799
+ },
800
+ async dispose() {
801
+ exerciseTypes.clear();
802
+ gradePolicies.clear();
803
+ commands.clear();
804
+ schedules.clear();
805
+ importers.clear();
806
+ exporters.clear();
807
+ rpcs.clear();
808
+ events.clear();
809
+ hooks.clear();
810
+ if (typeof cleanup === "function") await cleanup();
811
+ else if (cleanup !== void 0) await cleanup.dispose();
633
812
  }
634
813
  };
635
814
  };
636
- /** Activates the module with in-memory commands and lets the test invoke them like the host. */
637
- const loadCommands = async (module, options = {}) => {
638
- const commands = createMemoryCommands(options);
639
- const context = contextOf({
640
- ...options.library !== void 0 && { library: options.library },
641
- ...options.logger !== void 0 && { logger: options.logger },
642
- ...options.storage !== void 0 && { storage: options.storage },
643
- ...options.secrets !== void 0 && { secrets: options.secrets },
644
- ...options.settings !== void 0 && { settings: options.settings },
645
- ...options.events !== void 0 && { events: options.events },
646
- ...options.stats !== void 0 && { stats: options.stats },
647
- ...options.notifications !== void 0 && { notifications: options.notifications },
648
- ...options.schedule !== void 0 && { schedule: options.schedule },
649
- commands
650
- }, {
651
- registerExerciseType: () => ({ dispose: () => void 0 }),
652
- registerGradePolicy: () => ({ dispose: () => void 0 })
653
- });
654
- await module.activate(context);
655
- return {
656
- run: commands.run,
657
- ids: commands.ids,
658
- dispose: async () => {
659
- await module.deactivate?.();
660
- }
815
+ /**
816
+ * Starts `entry` (the `client` export of an extension) on a context that
817
+ * records what it adds, so a test can mount the components and read the
818
+ * themes and commands. An id added twice and an injection with a bad target
819
+ * or position fail like in the window.
820
+ */
821
+ const createTestClient = async (entry, options = {}) => {
822
+ const extensionId = options.extensionId ?? "test";
823
+ const panels = [];
824
+ const injections = [];
825
+ const answerViews = /* @__PURE__ */ new Map();
826
+ const markdownRenderers = /* @__PURE__ */ new Map();
827
+ const themes = [];
828
+ const commands = [];
829
+ const checkId = (kind, id) => {
830
+ if (options.extensionId !== void 0 && id !== extensionId && !id.startsWith(`${extensionId}.`)) throw new Error(`${kind} id '${id}' must be '${extensionId}' or start with '${extensionId}.'`);
661
831
  };
662
- };
663
- /** The context parts a test replaced, without the keys it left unset. */
664
- const loadOptionsOf = (options) => ({
665
- ...options.library !== void 0 && { library: options.library },
666
- ...options.logger !== void 0 && { logger: options.logger },
667
- ...options.storage !== void 0 && { storage: options.storage },
668
- ...options.secrets !== void 0 && { secrets: options.secrets },
669
- ...options.settings !== void 0 && { settings: options.settings },
670
- ...options.events !== void 0 && { events: options.events },
671
- ...options.commands !== void 0 && { commands: options.commands },
672
- ...options.stats !== void 0 && { stats: options.stats },
673
- ...options.notifications !== void 0 && { notifications: options.notifications },
674
- ...options.schedule !== void 0 && { schedule: options.schedule }
675
- });
676
- /** Activates the module with in-memory importers and lets the test run them like the host. */
677
- const loadImporters = async (module, options = {}) => {
678
- const importers = createMemoryImporters(options);
679
- const context = contextOf({
680
- ...loadOptionsOf(options),
681
- importers
682
- }, {
683
- registerExerciseType: () => ({ dispose: () => void 0 }),
684
- registerGradePolicy: () => ({ dispose: () => void 0 })
685
- });
686
- await module.activate(context);
687
- return {
688
- run: importers.run,
689
- ids: importers.ids,
690
- dispose: async () => {
691
- await module.deactivate?.();
692
- }
832
+ const addTo = (list, kind, value) => {
833
+ checkId(kind, value.id);
834
+ if (list.some((item) => item.id === value.id)) throw new Error(`${kind} '${value.id}' is already added`);
835
+ list.push(value);
836
+ return { dispose: () => void list.splice(list.indexOf(value), 1) };
693
837
  };
694
- };
695
- /** Activates the module with in-memory exporters and lets the test run them like the host; `stats` feeds `ctx.stats` of a progress exporter. */
696
- const loadExporters = async (module, options = {}) => {
697
- const exporters = createMemoryExporters(options);
698
- const context = contextOf({
699
- ...loadOptionsOf(options),
700
- exporters
701
- }, {
702
- registerExerciseType: () => ({ dispose: () => void 0 }),
703
- registerGradePolicy: () => ({ dispose: () => void 0 })
838
+ const addToMap = (map, kind, key, component) => {
839
+ if (map.has(key)) throw new Error(`${kind} '${key}' is already added`);
840
+ map.set(key, component);
841
+ return { dispose: () => {
842
+ if (map.get(key) === component) map.delete(key);
843
+ } };
844
+ };
845
+ const cleanup = await entry({
846
+ extensionId,
847
+ app: options.app ?? unprovided("app", "app"),
848
+ engine: options.engine ?? unprovided("engine", "engine"),
849
+ addPanel: (reg) => addTo(panels, "panel", reg),
850
+ addInjection: (reg) => {
851
+ const { target } = reg;
852
+ if (target.length === 0 || target.length > INJECTION_LIMITS.selectorLength) throw new Error(`injection target must be 1–${INJECTION_LIMITS.selectorLength} characters`);
853
+ const position = reg.position ?? "append";
854
+ if (!INJECTION_POSITIONS.includes(position)) throw new Error(`injection position '${position}' is not valid`);
855
+ if (injections.some(({ id }) => id === reg.id)) throw new Error(`injection '${reg.id}' is already added`);
856
+ const added = {
857
+ ...reg,
858
+ position
859
+ };
860
+ injections.push(added);
861
+ return { dispose: () => void injections.splice(injections.indexOf(added), 1) };
862
+ },
863
+ addAnswerView: (id, component) => addToMap(answerViews, "answer view", id, component),
864
+ addMarkdownRenderer: (language, component) => {
865
+ if (!MARKDOWN_LANGUAGE_PATTERN.test(language)) throw new Error(`markdown language '${language}' is not valid`);
866
+ return addToMap(markdownRenderers, "markdown renderer", language, component);
867
+ },
868
+ addTheme: (reg) => addTo(themes, "theme", reg),
869
+ addCommand: (reg) => addTo(commands, "command", reg)
704
870
  });
705
- await module.activate(context);
706
871
  return {
707
- run: exporters.run,
708
- ids: exporters.ids,
709
- dispose: async () => {
710
- await module.deactivate?.();
872
+ extensionId,
873
+ panels,
874
+ injections,
875
+ answerViews,
876
+ markdownRenderers,
877
+ themes,
878
+ commands,
879
+ async dispose() {
880
+ panels.length = 0;
881
+ injections.length = 0;
882
+ answerViews.clear();
883
+ markdownRenderers.clear();
884
+ themes.length = 0;
885
+ commands.length = 0;
886
+ if (typeof cleanup === "function") await cleanup();
887
+ else if (cleanup !== void 0) await cleanup.dispose();
711
888
  }
712
889
  };
713
890
  };
714
- /** Activates the module with in-memory schedules and lets the test fire them like the host. */
715
- const loadSchedules = async (module, options = {}) => {
716
- const schedule = createMemorySchedule(options);
717
- const context = contextOf({
718
- ...loadOptionsOf(options),
719
- schedule
720
- }, {
721
- registerExerciseType: () => ({ dispose: () => void 0 }),
722
- registerGradePolicy: () => ({ dispose: () => void 0 })
723
- });
724
- await module.activate(context);
891
+ const createNotifier = () => {
892
+ const listeners = /* @__PURE__ */ new Set();
725
893
  return {
726
- fire: schedule.fire,
727
- ids: schedule.ids,
728
- dispose: async () => {
729
- await module.deactivate?.();
894
+ subscribe: (listener) => {
895
+ listeners.add(listener);
896
+ return () => void listeners.delete(listener);
897
+ },
898
+ notify: (value) => {
899
+ for (const listener of [...listeners]) listener(value);
730
900
  }
731
901
  };
732
902
  };
733
- const requireDocument = (helper) => {
734
- if (typeof document === "undefined") throw new Error(`${helper} needs a DOM: run the test in a DOM environment (happy-dom or jsdom)`);
735
- return document;
736
- };
737
- const microtask = () => Promise.resolve();
738
- let viewCounter = 0;
739
903
  /**
740
- * Mounts a view from `views[id]` in the test DOM environment with the same element
741
- * the app creates (test tags are issued; the manifest `element` is not needed).
904
+ * Mounts a `Mountable` into an element on a recording context, so a test can
905
+ * change the props, the theme and the language, read what the component
906
+ * emitted and reported, and unmount it. Rejects with what `mount` throws. Needs
907
+ * a DOM (`document`) or `options.el`.
742
908
  */
743
- const loadView = async (views, id, options = {}) => {
744
- const doc = requireDocument("loadView");
745
- const view = views[id];
746
- if (view === void 0) throw new Error(`view '${id}' was not exported`);
747
- const tag = `dolphy-test-view-${++viewCounter}`;
748
- customElements.define(tag, createAnswerElementClass(tag, view));
749
- const element = doc.createElement(tag);
750
- if (options.label !== void 0) element.setAttribute("aria-label", options.label);
751
- for (const key of [
752
- "view",
753
- "value",
754
- "disabled",
755
- "verdict"
756
- ]) if (options[key] !== void 0) Object.assign(element, { [key]: options[key] });
757
- const changes = [];
758
- let submissions = 0;
759
- element.addEventListener(ANSWER_EVENT.change, (event) => {
760
- changes.push(event.detail);
761
- });
762
- element.addEventListener(ANSWER_EVENT.submit, () => void (submissions += 1));
763
- const container = options.container ?? doc.body.appendChild(doc.createElement("div"));
764
- container.append(element);
765
- await microtask();
766
- const root = element.shadowRoot;
767
- return {
768
- element,
769
- root,
770
- changes,
771
- get submissions() {
772
- return submissions;
909
+ const mountForTest = async (mountable, options) => {
910
+ let el = options.el;
911
+ if (el === void 0) {
912
+ if (typeof document === "undefined") throw new Error("mountForTest needs a DOM: run the test in happy-dom or jsdom, or pass options.el");
913
+ el = document.createElement("div");
914
+ }
915
+ const extensionId = options.extensionId ?? "test";
916
+ const engine = options.engine ?? unprovided("engine", "engine");
917
+ const abort = new AbortController();
918
+ const emitted = [];
919
+ const errors = [];
920
+ const propsListeners = createNotifier();
921
+ const themeListeners = createNotifier();
922
+ const localeListeners = createNotifier();
923
+ let props = options.props;
924
+ let theme = options.theme ?? {
925
+ id: "light",
926
+ dark: false
927
+ };
928
+ let locale = options.locale ?? "en";
929
+ const handle = options.handle;
930
+ const ctx = {
931
+ get props() {
932
+ return props;
773
933
  },
774
- update: async (props) => {
775
- Object.assign(element, props);
776
- await microtask();
934
+ onProps: propsListeners.subscribe,
935
+ emit: (event, payload) => void emitted.push([event, payload]),
936
+ app: options.app ?? unprovided("app", "app"),
937
+ engine,
938
+ callRpc: (contract, input) => callRpc({
939
+ engine,
940
+ extensionId
941
+ }, contract, input),
942
+ get theme() {
943
+ return theme;
777
944
  },
778
- query: (selector) => root.querySelector(selector),
779
- queryAll: (selector) => [...root.querySelectorAll(selector)],
780
- dispose: () => {
781
- element.remove();
782
- if (options.container === void 0) container.remove();
783
- }
945
+ onTheme: themeListeners.subscribe,
946
+ get locale() {
947
+ return locale;
948
+ },
949
+ onLocale: localeListeners.subscribe,
950
+ extensionId,
951
+ signal: abort.signal,
952
+ reportError: (error) => void errors.push(error),
953
+ handle
784
954
  };
785
- };
786
- /** The part of the context a panel and a widget share, with the controls a test needs. */
787
- const createFrameContext = (options, doc) => {
788
- const calls = [];
789
- const listeners = /* @__PURE__ */ new Set();
790
- const controller = new AbortController();
791
- const container = options.container ?? doc.body.appendChild(doc.createElement("div"));
792
- let current = { courseId: options.context?.courseId ?? null };
955
+ const unmountComponent = await mountable.mount(el, ctx);
956
+ let unmounted = false;
793
957
  return {
794
- context: {
795
- get context() {
796
- return current;
797
- },
798
- signal: controller.signal,
799
- call: async (commandId, args) => {
800
- calls.push({
801
- commandId,
802
- args
803
- });
804
- if (options.call === void 0) throw new Error(`command '${commandId}' is not available in this test`);
805
- return options.call(commandId, args);
806
- },
807
- onContextChange: (listener) => {
808
- listeners.add(listener);
809
- return () => void listeners.delete(listener);
810
- }
958
+ el,
959
+ ctx,
960
+ setProps(next) {
961
+ props = next;
962
+ propsListeners.notify(next);
811
963
  },
812
- frame: {
813
- container,
814
- calls,
815
- get aborted() {
816
- return controller.signal.aborted;
817
- },
818
- setContext: (next) => {
819
- current = { courseId: next.courseId };
820
- for (const listener of [...listeners]) listener(current);
821
- },
822
- dispose: () => {
823
- controller.abort();
824
- if (options.container === void 0) container.remove();
825
- }
964
+ setTheme(next) {
965
+ theme = next;
966
+ themeListeners.notify(next);
826
967
  },
827
- container
828
- };
829
- };
830
- /** Mounts a panel from `panels[id]` in the test DOM environment with the same context the frame provides. */
831
- const loadPanel = async (panels, id, options = {}) => {
832
- const doc = requireDocument("loadPanel");
833
- const panel = panels[id];
834
- if (panel === void 0) throw new Error(`panel '${id}' was not exported`);
835
- const { context, frame, container } = createFrameContext(options, doc);
836
- const listeners = /* @__PURE__ */ new Set();
837
- await panel.mount(container, {
838
- get context() {
839
- return context.context;
968
+ setLocale(next) {
969
+ locale = next;
970
+ localeListeners.notify(next);
840
971
  },
841
- signal: context.signal,
842
- call: context.call,
843
- onContextChange: context.onContextChange,
844
- panelId: id,
845
- props: options.props,
846
- onProps: (listener) => {
847
- listeners.add(listener);
848
- return () => void listeners.delete(listener);
972
+ emitted,
973
+ errors,
974
+ async unmount() {
975
+ if (unmounted) return;
976
+ unmounted = true;
977
+ abort.abort();
978
+ await unmountComponent();
849
979
  }
850
- });
851
- return Object.assign(frame, { setProps: (props) => {
852
- for (const listener of [...listeners]) listener(props);
853
- } });
854
- };
855
- /** Mounts a widget from `widgets[id]` in the test DOM environment with the same context the frame provides. */
856
- const loadWidget = async (widgets, id, options = {}) => {
857
- const doc = requireDocument("loadWidget");
858
- const widget = widgets[id];
859
- if (widget === void 0) throw new Error(`widget '${id}' was not exported`);
860
- const { context, frame, container } = createFrameContext(options, doc);
861
- await widget.mount(container, {
862
- get context() {
863
- return context.context;
864
- },
865
- signal: context.signal,
866
- call: context.call,
867
- onContextChange: context.onContextChange,
868
- widgetId: id
869
- });
870
- return frame;
980
+ };
871
981
  };
872
982
 
873
983
  //#endregion
874
- export { createMemoryCommands, createMemoryEvents, createMemoryExporters, createMemoryImporters, createMemoryLibrary, createMemoryNotifications, createMemorySchedule, createMemorySecrets, createMemorySettings, createMemoryStats, createMemoryStorage, createSchemaValidator, loadCommands, loadEvents, loadExerciseType, loadExporters, loadGradePolicy, loadImporters, loadPanel, loadSchedules, loadView, loadWidget };
984
+ export { createMemoryLibrary, createMemoryNotifications, createMemorySecrets, createMemorySettings, createMemoryStats, createMemoryStorage, createSchemaValidator, createTestClient, createTestServer, mountForTest };