@dolphy-app/extension-sdk 0.3.0 → 0.4.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,5 +1,5 @@
1
1
  import { t as createAnswerElementClass } from "./answer-element-BOQcuxYh.js";
2
- import { ANSWER_EVENT, EXTENSION_COMMAND_LIMITS, EXTENSION_STORAGE_LIMITS, InvalidCommandResultError, PermissionError, StorageQuotaError, normalizeCommandResult } from "@dolphy-app/extension-api";
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";
3
3
  import { Ajv2020 } from "ajv/dist/2020.js";
4
4
 
5
5
  //#region packages/extension-sdk/src/testing.ts
@@ -69,10 +69,59 @@ const createMemoryStorage = () => {
69
69
  keys: async () => [...entries.keys()].sort(compareKeys)
70
70
  };
71
71
  };
72
+ /**
73
+ * In-memory secrets with the engine's limits and errors
74
+ * (`EXTENSION_SECRET_LIMITS`, `StorageQuotaError`, `SecretsUnavailableError`):
75
+ * without a key store `set` and `get` of an existing key throw, `get` of a
76
+ * missing key gives `undefined` and `delete` works.
77
+ */
78
+ const createMemorySecrets = (options = {}) => {
79
+ const limits = EXTENSION_SECRET_LIMITS;
80
+ const entries = /* @__PURE__ */ new Map();
81
+ let available = options.available ?? true;
82
+ const requireStore = () => {
83
+ if (!available) throw new SecretsUnavailableError();
84
+ };
85
+ return {
86
+ setAvailable: (value) => {
87
+ available = value;
88
+ },
89
+ get: async (key) => {
90
+ const value = entries.get(key);
91
+ if (value !== void 0) requireStore();
92
+ return value;
93
+ },
94
+ set: async (key, value) => {
95
+ if (typeof key !== "string" || key.length === 0) throw new Error("secret key must be a non-empty string");
96
+ if (typeof value !== "string") throw new Error("secret value must be a string");
97
+ if (key.length > limits.keyLength) throw new StorageQuotaError("key-length", limits.keyLength);
98
+ if (Buffer.byteLength(value) > limits.valueBytes) throw new StorageQuotaError("value-size", limits.valueBytes);
99
+ requireStore();
100
+ if (!entries.has(key) && entries.size >= limits.keys) throw new StorageQuotaError("key-count", limits.keys);
101
+ entries.set(key, value);
102
+ },
103
+ delete: async (key) => entries.delete(key)
104
+ };
105
+ };
106
+ /** The value as the host stores it: a color in lower case, a list copied. */
107
+ const storedForm = (definition, value) => {
108
+ if (definition.type === "color" && typeof value === "string") return value.toLowerCase();
109
+ return Array.isArray(value) ? [...value] : value;
110
+ };
111
+ const sameSettingValue = (a, b) => Array.isArray(a) && Array.isArray(b) ? a.length === b.length && a.every((item, index) => item === b[index]) : Object.is(a, b);
72
112
  /** Why a value does not fit the setting definition; `null` if it fits. */
73
113
  const findSettingProblem = (definition, value) => {
74
114
  switch (definition.type) {
75
115
  case "boolean": return typeof value === "boolean" ? null : "must be a boolean";
116
+ case "color": return typeof value === "string" && COLOR_SETTING_PATTERN.test(value) ? null : "must be a color #rrggbb";
117
+ case "list": {
118
+ if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) return "must be an array of strings";
119
+ const maxItems = definition.maxItems ?? SETTING_LIMITS.listItems;
120
+ if (value.length > maxItems) return `has more than ${maxItems} items`;
121
+ const itemMax = definition.itemMaxLength ?? SETTING_LIMITS.listItemLength;
122
+ return value.some((item) => item.length > itemMax) ? `has an item longer than ${itemMax} characters` : null;
123
+ }
124
+ case "text":
76
125
  case "string":
77
126
  if (typeof value !== "string") return "must be a string";
78
127
  return definition.maxLength !== void 0 && value.length > definition.maxLength ? `is longer than ${definition.maxLength} characters` : null;
@@ -87,7 +136,7 @@ const findSettingProblem = (definition, value) => {
87
136
  /** In-memory settings from manifest definitions; `initial` provides user values in place of `default`. */
88
137
  const createMemorySettings = (definitions, initial = {}) => {
89
138
  const byId = new Map(definitions.map((definition) => [definition.id, definition]));
90
- const values = new Map(definitions.map((definition) => [definition.id, definition.default]));
139
+ const values = new Map(definitions.map((definition) => [definition.id, storedForm(definition, definition.default)]));
91
140
  const handlers = /* @__PURE__ */ new Set();
92
141
  const known = (id) => {
93
142
  const definition = byId.get(id);
@@ -97,21 +146,18 @@ const createMemorySettings = (definitions, initial = {}) => {
97
146
  const checked = (id, value) => {
98
147
  const problem = findSettingProblem(known(id), value);
99
148
  if (problem !== null) throw new Error(`setting '${id}' ${problem}`);
100
- return value;
149
+ return storedForm(known(id), value);
101
150
  };
102
151
  for (const [id, value] of Object.entries(initial)) values.set(id, checked(id, value));
103
152
  return {
104
- get: (id) => {
105
- known(id);
106
- return values.get(id);
107
- },
153
+ get: (id) => storedForm(known(id), values.get(id)),
108
154
  onDidChange(handler) {
109
155
  handlers.add(handler);
110
156
  return { dispose: () => void handlers.delete(handler) };
111
157
  },
112
158
  async set(id, value) {
113
159
  const next = checked(id, value);
114
- if (Object.is(values.get(id), next)) return;
160
+ if (sameSettingValue(values.get(id), next)) return;
115
161
  values.set(id, next);
116
162
  for (const handler of [...handlers]) await handler({
117
163
  id,
@@ -165,14 +211,271 @@ const createMemoryCommands = (options = {}) => {
165
211
  ids: () => [...handlers.keys()]
166
212
  };
167
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
+ const DAY_MS = 864e5;
305
+ const STATS_CORRECT_GRADE_MIN = 3;
306
+ const dayNumberOf = (year, month, day) => {
307
+ const date = /* @__PURE__ */ new Date(0);
308
+ date.setUTCFullYear(year, month - 1, day);
309
+ return Math.floor(date.getTime() / DAY_MS);
310
+ };
311
+ const dayLabelOf = (dayNumber) => (/* @__PURE__ */ new Date(dayNumber * DAY_MS)).toISOString().slice(0, 10);
312
+ const parseDay = (field, value) => {
313
+ const match = typeof value === "string" ? /^(\d{4})-(\d{2})-(\d{2})$/.exec(value) : null;
314
+ const day = match === null ? null : dayNumberOf(Number(match[1]), Number(match[2]), Number(match[3]));
315
+ if (day === null || dayLabelOf(day) !== value) throw new Error(`'${field}' must be a date as YYYY-MM-DD`);
316
+ return day;
317
+ };
318
+ /**
319
+ * In-memory statistics with the semantics of the app: local days in a time zone,
320
+ * correct at grade 3 or higher, `current` streak not broken while today has no
321
+ * attempts yet, one `daily` entry per date (at most `EXTENSION_STATS_LIMITS.dailyDays`).
322
+ */
323
+ const createMemoryStats = (options = {}) => {
324
+ const timeZone = options.timeZone ?? new Intl.DateTimeFormat().resolvedOptions().timeZone;
325
+ const formatter = new Intl.DateTimeFormat("en-US", {
326
+ timeZone,
327
+ calendar: "gregory",
328
+ numberingSystem: "latn",
329
+ year: "numeric",
330
+ month: "2-digit",
331
+ day: "2-digit"
332
+ });
333
+ const localDay = (at) => {
334
+ const parts = Object.fromEntries(formatter.formatToParts(at).map(({ type, value }) => [type, value]));
335
+ return dayNumberOf(Number(parts.year), Number(parts.month), Number(parts.day));
336
+ };
337
+ const attempts = [];
338
+ const record = (attempt) => {
339
+ const at = new Date(attempt.at).getTime();
340
+ if (Number.isNaN(at)) throw new Error("attempt.at is not a valid time");
341
+ attempts.push({
342
+ day: localDay(at),
343
+ correct: attempt.grade >= STATS_CORRECT_GRADE_MIN,
344
+ ...attempt.courseId !== void 0 && { courseId: attempt.courseId }
345
+ });
346
+ };
347
+ for (const attempt of options.attempts ?? []) record(attempt);
348
+ const guard = () => {
349
+ if (options.permitted === false) throw new PermissionError("learning.stats");
350
+ };
351
+ const countsOf = (courseId) => {
352
+ const days = /* @__PURE__ */ new Map();
353
+ for (const item of attempts) {
354
+ if (courseId !== void 0 && item.courseId !== courseId) continue;
355
+ const counts = days.get(item.day) ?? {
356
+ attempts: 0,
357
+ correct: 0
358
+ };
359
+ counts.attempts += 1;
360
+ if (item.correct) counts.correct += 1;
361
+ days.set(item.day, counts);
362
+ }
363
+ return days;
364
+ };
365
+ return {
366
+ record,
367
+ async streak(streakOptions) {
368
+ guard();
369
+ const days = countsOf(streakOptions?.courseId);
370
+ const today = localDay((options.now ?? Date.now)());
371
+ let longest = 0;
372
+ let run = 0;
373
+ let previous = NaN;
374
+ for (const day of [...days.keys()].sort((a, b) => a - b)) {
375
+ run = day === previous + 1 ? run + 1 : 1;
376
+ longest = Math.max(longest, run);
377
+ previous = day;
378
+ }
379
+ let cursor = days.has(today) ? today : today - 1;
380
+ let current = 0;
381
+ while (days.has(cursor)) {
382
+ current += 1;
383
+ cursor -= 1;
384
+ }
385
+ return {
386
+ current,
387
+ longest
388
+ };
389
+ },
390
+ async daily({ from, to, courseId }) {
391
+ guard();
392
+ const first = parseDay("from", from);
393
+ const last = parseDay("to", to);
394
+ 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`);
395
+ const days = countsOf(courseId);
396
+ const result = [];
397
+ for (let day = first; day <= last; day += 1) {
398
+ const { attempts: made, correct } = days.get(day) ?? {
399
+ attempts: 0,
400
+ correct: 0
401
+ };
402
+ result.push({
403
+ date: dayLabelOf(day),
404
+ attempts: made,
405
+ correct,
406
+ accuracy: made === 0 ? null : correct / made
407
+ });
408
+ }
409
+ return result;
410
+ }
411
+ };
412
+ };
413
+ /** Control characters (except the line feed), line and paragraph separators and text direction marks. */
414
+ const NOTIFICATION_CONTROL = /[\u0000-\u0009\u000B-\u001F\u007F-\u009F\u2028\u2029\u200E\u200F\u202A-\u202E\u2066-\u2069]/gu;
415
+ const notificationText = (field, value, max) => {
416
+ if (typeof value !== "string") throw new Error(`notification ${field} must be a string`);
417
+ const unified = value.replace(/\r\n?/gu, "\n").replace(/\t/gu, " ");
418
+ const text = (field === "body" ? unified : unified.replace(/\n/gu, " ")).replace(NOTIFICATION_CONTROL, "").trim();
419
+ if (field === "title" && text === "" || [...text].length > max) throw new Error(`notification ${field} must be ${field === "title" ? "1 to " : "up to "}${max} characters`);
420
+ return text;
421
+ };
422
+ /**
423
+ * In-memory notifications with the engine's rules: the text is sanitized and
424
+ * limited, `perMinute`/`perHour` windows throw `NotificationRateLimitError`,
425
+ * a switched-off extension or an unsupporting system resolves `false` (and
426
+ * does not use the rate limit). `shown` is the log the app's fake notifier
427
+ * would keep.
428
+ */
429
+ const createMemoryNotifications = (options = {}) => {
430
+ const now = options.now ?? Date.now;
431
+ let enabled = options.enabled !== false;
432
+ let supported = options.supported !== false;
433
+ const shown = [];
434
+ const times = [];
435
+ const limits = EXTENSION_NOTIFICATION_LIMITS;
436
+ return {
437
+ shown,
438
+ setEnabled: (value) => {
439
+ enabled = value;
440
+ },
441
+ setSupported: (value) => {
442
+ supported = value;
443
+ },
444
+ async show(notification) {
445
+ if (options.permitted === false) throw new PermissionError("notifications");
446
+ const title = notificationText("title", notification?.title, limits.titleLength);
447
+ const body = notificationText("body", notification?.body, limits.bodyLength);
448
+ if (!enabled) return false;
449
+ const at = now();
450
+ const recent = times.filter((time) => at - time < 36e5);
451
+ times.length = 0;
452
+ times.push(...recent);
453
+ if (recent.filter((time) => at - time < 6e4).length >= limits.perMinute) throw new NotificationRateLimitError("minute", limits.perMinute);
454
+ if (recent.length >= limits.perHour) throw new NotificationRateLimitError("hour", limits.perHour);
455
+ times.push(at);
456
+ if (!supported) return false;
457
+ shown.push({
458
+ title,
459
+ body
460
+ });
461
+ return true;
462
+ }
463
+ };
464
+ };
168
465
  const contextOf = (options, registrars) => ({
169
466
  extensionId: "test",
170
467
  logger: options.logger ?? silentLogger,
171
468
  library: options.library ?? createMemoryLibrary({}),
172
469
  storage: options.storage ?? createMemoryStorage(),
470
+ secrets: options.secrets ?? createMemorySecrets(),
173
471
  settings: options.settings ?? createMemorySettings([]),
174
472
  events: options.events ?? createMemoryEvents(),
473
+ stats: options.stats ?? createMemoryStats(),
474
+ notifications: options.notifications ?? createMemoryNotifications(),
475
+ schedule: options.schedule ?? createMemorySchedule(),
175
476
  commands: options.commands ?? createMemoryCommands(),
477
+ importers: options.importers ?? createMemoryImporters(),
478
+ exporters: options.exporters ?? createMemoryExporters(),
176
479
  ...registrars
177
480
  });
178
481
  const createSchemaValidator = (schema) => {
@@ -303,11 +606,16 @@ const loadGradePolicy = async (module, id, options = {}) => {
303
606
  const loadEvents = async (module, options = {}) => {
304
607
  const events = createMemoryEvents(options);
305
608
  const storage = options.storage ?? createMemoryStorage();
609
+ const secrets = options.secrets ?? createMemorySecrets();
306
610
  const settings = createMemorySettings(options.settings ?? [], options.settingValues);
307
611
  const context = contextOf({
308
612
  ...options.library !== void 0 && { library: options.library },
309
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 },
310
617
  storage,
618
+ secrets,
311
619
  settings,
312
620
  events
313
621
  }, {
@@ -318,6 +626,7 @@ const loadEvents = async (module, options = {}) => {
318
626
  return {
319
627
  emit: events.emit,
320
628
  storage,
629
+ secrets,
321
630
  settings,
322
631
  dispose: async () => {
323
632
  await module.deactivate?.();
@@ -331,8 +640,12 @@ const loadCommands = async (module, options = {}) => {
331
640
  ...options.library !== void 0 && { library: options.library },
332
641
  ...options.logger !== void 0 && { logger: options.logger },
333
642
  ...options.storage !== void 0 && { storage: options.storage },
643
+ ...options.secrets !== void 0 && { secrets: options.secrets },
334
644
  ...options.settings !== void 0 && { settings: options.settings },
335
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 },
336
649
  commands
337
650
  }, {
338
651
  registerExerciseType: () => ({ dispose: () => void 0 }),
@@ -347,6 +660,76 @@ const loadCommands = async (module, options = {}) => {
347
660
  }
348
661
  };
349
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
+ }
693
+ };
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 })
704
+ });
705
+ await module.activate(context);
706
+ return {
707
+ run: exporters.run,
708
+ ids: exporters.ids,
709
+ dispose: async () => {
710
+ await module.deactivate?.();
711
+ }
712
+ };
713
+ };
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);
725
+ return {
726
+ fire: schedule.fire,
727
+ ids: schedule.ids,
728
+ dispose: async () => {
729
+ await module.deactivate?.();
730
+ }
731
+ };
732
+ };
350
733
  const requireDocument = (helper) => {
351
734
  if (typeof document === "undefined") throw new Error(`${helper} needs a DOM: run the test in a DOM environment (happy-dom or jsdom)`);
352
735
  return document;
@@ -400,47 +783,92 @@ const loadView = async (views, id, options = {}) => {
400
783
  }
401
784
  };
402
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 };
793
+ 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
+ }
811
+ },
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
+ }
826
+ },
827
+ container
828
+ };
829
+ };
403
830
  /** Mounts a panel from `panels[id]` in the test DOM environment with the same context the frame provides. */
404
831
  const loadPanel = async (panels, id, options = {}) => {
405
832
  const doc = requireDocument("loadPanel");
406
833
  const panel = panels[id];
407
834
  if (panel === void 0) throw new Error(`panel '${id}' was not exported`);
408
- const calls = [];
835
+ const { context, frame, container } = createFrameContext(options, doc);
409
836
  const listeners = /* @__PURE__ */ new Set();
410
- const controller = new AbortController();
411
- const container = options.container ?? doc.body.appendChild(doc.createElement("div"));
412
837
  await panel.mount(container, {
838
+ get context() {
839
+ return context.context;
840
+ },
841
+ signal: context.signal,
842
+ call: context.call,
843
+ onContextChange: context.onContextChange,
413
844
  panelId: id,
414
845
  props: options.props,
415
- signal: controller.signal,
416
- call: async (commandId, args) => {
417
- calls.push({
418
- commandId,
419
- args
420
- });
421
- if (options.call === void 0) throw new Error(`command '${commandId}' is not available in this test`);
422
- return options.call(commandId, args);
423
- },
424
846
  onProps: (listener) => {
425
847
  listeners.add(listener);
426
848
  return () => void listeners.delete(listener);
427
849
  }
428
850
  });
429
- return {
430
- container,
431
- calls,
432
- get aborted() {
433
- return controller.signal.aborted;
434
- },
435
- setProps: (props) => {
436
- for (const listener of [...listeners]) listener(props);
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;
437
864
  },
438
- dispose: () => {
439
- controller.abort();
440
- if (options.container === void 0) container.remove();
441
- }
442
- };
865
+ signal: context.signal,
866
+ call: context.call,
867
+ onContextChange: context.onContextChange,
868
+ widgetId: id
869
+ });
870
+ return frame;
443
871
  };
444
872
 
445
873
  //#endregion
446
- export { createMemoryCommands, createMemoryEvents, createMemoryLibrary, createMemorySettings, createMemoryStorage, createSchemaValidator, loadCommands, loadEvents, loadExerciseType, loadGradePolicy, loadPanel, loadView };
874
+ export { createMemoryCommands, createMemoryEvents, createMemoryExporters, createMemoryImporters, createMemoryLibrary, createMemoryNotifications, createMemorySchedule, createMemorySecrets, createMemorySettings, createMemoryStats, createMemoryStorage, createSchemaValidator, loadCommands, loadEvents, loadExerciseType, loadExporters, loadGradePolicy, loadImporters, loadPanel, loadSchedules, loadView, loadWidget };