@dolphy-app/extension-sdk 0.3.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, EXTENSION_COMMAND_LIMITS, EXTENSION_STORAGE_LIMITS, InvalidCommandResultError, PermissionError, StorageQuotaError, normalizeCommandResult } 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 {
@@ -69,10 +160,59 @@ const createMemoryStorage = () => {
69
160
  keys: async () => [...entries.keys()].sort(compareKeys)
70
161
  };
71
162
  };
163
+ /**
164
+ * In-memory secrets with the engine's limits and errors
165
+ * (`EXTENSION_SECRET_LIMITS`, `StorageQuotaError`, `SecretsUnavailableError`):
166
+ * without a key store `set` and `get` of an existing key throw, `get` of a
167
+ * missing key gives `undefined` and `delete` works.
168
+ */
169
+ const createMemorySecrets = (options = {}) => {
170
+ const limits = EXTENSION_SECRET_LIMITS;
171
+ const entries = /* @__PURE__ */ new Map();
172
+ let available = options.available ?? true;
173
+ const requireStore = () => {
174
+ if (!available) throw new SecretsUnavailableError();
175
+ };
176
+ return {
177
+ setAvailable: (value) => {
178
+ available = value;
179
+ },
180
+ get: async (key) => {
181
+ const value = entries.get(key);
182
+ if (value !== void 0) requireStore();
183
+ return value;
184
+ },
185
+ set: async (key, value) => {
186
+ if (typeof key !== "string" || key.length === 0) throw new Error("secret key must be a non-empty string");
187
+ if (typeof value !== "string") throw new Error("secret value must be a string");
188
+ if (key.length > limits.keyLength) throw new StorageQuotaError("key-length", limits.keyLength);
189
+ if (Buffer.byteLength(value) > limits.valueBytes) throw new StorageQuotaError("value-size", limits.valueBytes);
190
+ requireStore();
191
+ if (!entries.has(key) && entries.size >= limits.keys) throw new StorageQuotaError("key-count", limits.keys);
192
+ entries.set(key, value);
193
+ },
194
+ delete: async (key) => entries.delete(key)
195
+ };
196
+ };
197
+ /** The value as the host stores it: a color in lower case, a list copied. */
198
+ const storedForm = (definition, value) => {
199
+ if (definition.type === "color" && typeof value === "string") return value.toLowerCase();
200
+ return Array.isArray(value) ? [...value] : value;
201
+ };
202
+ 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
203
  /** Why a value does not fit the setting definition; `null` if it fits. */
73
204
  const findSettingProblem = (definition, value) => {
74
205
  switch (definition.type) {
75
206
  case "boolean": return typeof value === "boolean" ? null : "must be a boolean";
207
+ case "color": return typeof value === "string" && COLOR_SETTING_PATTERN.test(value) ? null : "must be a color #rrggbb";
208
+ case "list": {
209
+ if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) return "must be an array of strings";
210
+ const maxItems = definition.maxItems ?? SETTING_LIMITS.listItems;
211
+ if (value.length > maxItems) return `has more than ${maxItems} items`;
212
+ const itemMax = definition.itemMaxLength ?? SETTING_LIMITS.listItemLength;
213
+ return value.some((item) => item.length > itemMax) ? `has an item longer than ${itemMax} characters` : null;
214
+ }
215
+ case "text":
76
216
  case "string":
77
217
  if (typeof value !== "string") return "must be a string";
78
218
  return definition.maxLength !== void 0 && value.length > definition.maxLength ? `is longer than ${definition.maxLength} characters` : null;
@@ -84,34 +224,51 @@ const findSettingProblem = (definition, value) => {
84
224
  default: return typeof value === "string" && definition.options.some((option) => option.value === value) ? null : "must be one of the options";
85
225
  }
86
226
  };
87
- /** In-memory settings from manifest definitions; `initial` provides user values in place of `default`. */
88
- const createMemorySettings = (definitions, initial = {}) => {
89
- const byId = new Map(definitions.map((definition) => [definition.id, definition]));
90
- const values = new Map(definitions.map((definition) => [definition.id, 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();
91
235
  const handlers = /* @__PURE__ */ new Set();
92
236
  const known = (id) => {
93
237
  const definition = byId.get(id);
94
- 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`);
95
239
  return definition;
96
240
  };
97
241
  const checked = (id, value) => {
98
242
  const problem = findSettingProblem(known(id), value);
99
243
  if (problem !== null) throw new Error(`setting '${id}' ${problem}`);
100
- return value;
244
+ return storedForm(known(id), value);
101
245
  };
102
- 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);
103
262
  return {
104
- get: (id) => {
105
- known(id);
106
- return values.get(id);
107
- },
263
+ register,
264
+ get: (id) => storedForm(known(id), values.get(id)),
108
265
  onDidChange(handler) {
109
266
  handlers.add(handler);
110
267
  return { dispose: () => void handlers.delete(handler) };
111
268
  },
112
269
  async set(id, value) {
113
270
  const next = checked(id, value);
114
- if (Object.is(values.get(id), next)) return;
271
+ if (sameSettingValue(values.get(id), next)) return;
115
272
  values.set(id, next);
116
273
  for (const handler of [...handlers]) await handler({
117
274
  id,
@@ -120,61 +277,161 @@ const createMemorySettings = (definitions, initial = {}) => {
120
277
  }
121
278
  };
122
279
  };
123
- /** In-memory learning event subscriptions: one handler per event, as in the host. */
124
- const createMemoryEvents = (options = {}) => {
125
- const handlers = /* @__PURE__ */ new Map();
280
+ const DAY_MS = 864e5;
281
+ const STATS_CORRECT_GRADE_MIN = 3;
282
+ const dayNumberOf = (year, month, day) => {
283
+ const date = /* @__PURE__ */ new Date(0);
284
+ date.setUTCFullYear(year, month - 1, day);
285
+ return Math.floor(date.getTime() / DAY_MS);
286
+ };
287
+ const dayLabelOf = (dayNumber) => (/* @__PURE__ */ new Date(dayNumber * DAY_MS)).toISOString().slice(0, 10);
288
+ const parseDay = (field, value) => {
289
+ const match = typeof value === "string" ? /^(\d{4})-(\d{2})-(\d{2})$/.exec(value) : null;
290
+ const day = match === null ? null : dayNumberOf(Number(match[1]), Number(match[2]), Number(match[3]));
291
+ if (day === null || dayLabelOf(day) !== value) throw new Error(`'${field}' must be a date as YYYY-MM-DD`);
292
+ return day;
293
+ };
294
+ /**
295
+ * In-memory statistics with the semantics of the app: local days in a time zone,
296
+ * correct at grade 3 or higher, `current` streak not broken while today has no
297
+ * attempts yet, one `daily` entry per date (at most `EXTENSION_STATS_LIMITS.dailyDays`).
298
+ */
299
+ const createMemoryStats = (options = {}) => {
300
+ const timeZone = options.timeZone ?? new Intl.DateTimeFormat().resolvedOptions().timeZone;
301
+ const formatter = new Intl.DateTimeFormat("en-US", {
302
+ timeZone,
303
+ calendar: "gregory",
304
+ numberingSystem: "latn",
305
+ year: "numeric",
306
+ month: "2-digit",
307
+ day: "2-digit"
308
+ });
309
+ const localDay = (at) => {
310
+ const parts = Object.fromEntries(formatter.formatToParts(at).map(({ type, value }) => [type, value]));
311
+ return dayNumberOf(Number(parts.year), Number(parts.month), Number(parts.day));
312
+ };
313
+ const attempts = [];
314
+ const record = (attempt) => {
315
+ const at = new Date(attempt.at).getTime();
316
+ if (Number.isNaN(at)) throw new Error("attempt.at is not a valid time");
317
+ attempts.push({
318
+ day: localDay(at),
319
+ correct: attempt.grade >= STATS_CORRECT_GRADE_MIN,
320
+ ...attempt.courseId !== void 0 && { courseId: attempt.courseId }
321
+ });
322
+ };
323
+ for (const attempt of options.attempts ?? []) record(attempt);
324
+ const countsOf = (courseId) => {
325
+ const days = /* @__PURE__ */ new Map();
326
+ for (const item of attempts) {
327
+ if (courseId !== void 0 && item.courseId !== courseId) continue;
328
+ const counts = days.get(item.day) ?? {
329
+ attempts: 0,
330
+ correct: 0
331
+ };
332
+ counts.attempts += 1;
333
+ if (item.correct) counts.correct += 1;
334
+ days.set(item.day, counts);
335
+ }
336
+ return days;
337
+ };
126
338
  return {
127
- on(name, handler) {
128
- if (options.permitted === false) throw new PermissionError("learning.events");
129
- if (options.declared !== void 0 && !options.declared.includes(name)) throw new Error(`event '${name}' is not declared in the manifest`);
130
- if (handlers.has(name)) throw new Error(`event '${name}' is already subscribed`);
131
- handlers.set(name, handler);
132
- return { dispose: () => {
133
- if (handlers.get(name) === handler) handlers.delete(name);
134
- } };
339
+ record,
340
+ async streak(streakOptions) {
341
+ const days = countsOf(streakOptions?.courseId);
342
+ const today = localDay((options.now ?? Date.now)());
343
+ let longest = 0;
344
+ let run = 0;
345
+ let previous = NaN;
346
+ for (const day of [...days.keys()].sort((a, b) => a - b)) {
347
+ run = day === previous + 1 ? run + 1 : 1;
348
+ longest = Math.max(longest, run);
349
+ previous = day;
350
+ }
351
+ let cursor = days.has(today) ? today : today - 1;
352
+ let current = 0;
353
+ while (days.has(cursor)) {
354
+ current += 1;
355
+ cursor -= 1;
356
+ }
357
+ return {
358
+ current,
359
+ longest
360
+ };
135
361
  },
136
- async emit(name, payload) {
137
- await handlers.get(name)?.(payload);
362
+ async daily({ from, to, courseId }) {
363
+ const first = parseDay("from", from);
364
+ const last = parseDay("to", to);
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`);
366
+ const days = countsOf(courseId);
367
+ const result = [];
368
+ for (let day = first; day <= last; day += 1) {
369
+ const { attempts: made, correct } = days.get(day) ?? {
370
+ attempts: 0,
371
+ correct: 0
372
+ };
373
+ result.push({
374
+ date: dayLabelOf(day),
375
+ attempts: made,
376
+ correct,
377
+ accuracy: made === 0 ? null : correct / made
378
+ });
379
+ }
380
+ return result;
138
381
  }
139
382
  };
140
383
  };
141
- /** In-memory commands: the same registration rules and result parsing as the host. */
142
- const createMemoryCommands = (options = {}) => {
143
- const handlers = /* @__PURE__ */ new Map();
384
+ /** Control characters (except the line feed), line and paragraph separators and text direction marks. */
385
+ const NOTIFICATION_CONTROL = /[\u0000-\u0009\u000B-\u001F\u007F-\u009F\u2028\u2029\u200E\u200F\u202A-\u202E\u2066-\u2069]/gu;
386
+ const notificationText = (field, value, max) => {
387
+ if (typeof value !== "string") throw new Error(`notification ${field} must be a string`);
388
+ const unified = value.replace(/\r\n?/gu, "\n").replace(/\t/gu, " ");
389
+ const text = (field === "body" ? unified : unified.replace(/\n/gu, " ")).replace(NOTIFICATION_CONTROL, "").trim();
390
+ if (field === "title" && text === "" || [...text].length > max) throw new Error(`notification ${field} must be ${field === "title" ? "1 to " : "up to "}${max} characters`);
391
+ return text;
392
+ };
393
+ /**
394
+ * In-memory notifications with the engine's rules: the text is sanitized and
395
+ * limited, `perMinute`/`perHour` windows throw `NotificationRateLimitError`,
396
+ * a switched-off extension or an unsupporting system resolves `false` (and
397
+ * does not use the rate limit). `shown` is the log the app's fake notifier
398
+ * would keep.
399
+ */
400
+ const createMemoryNotifications = (options = {}) => {
401
+ const now = options.now ?? Date.now;
402
+ let enabled = options.enabled !== false;
403
+ let supported = options.supported !== false;
404
+ const shown = [];
405
+ const times = [];
406
+ const limits = EXTENSION_NOTIFICATION_LIMITS;
144
407
  return {
145
- register(id, handler) {
146
- if (options.declaredCommands !== void 0 && !options.declaredCommands.includes(id)) throw new Error(`command '${id}' is not declared in the manifest`);
147
- if (handlers.has(id)) throw new Error(`command '${id}' is already registered`);
148
- handlers.set(id, handler);
149
- return { dispose: () => {
150
- if (handlers.get(id) === handler) handlers.delete(id);
151
- } };
408
+ shown,
409
+ setEnabled: (value) => {
410
+ enabled = value;
152
411
  },
153
- async run(id, args) {
154
- const handler = handlers.get(id);
155
- if (handler === void 0) throw new Error(`command '${id}' was not registered`);
156
- if ((JSON.stringify(args)?.length ?? 0) > EXTENSION_COMMAND_LIMITS.argsChars) throw new Error(`args are longer than ${EXTENSION_COMMAND_LIMITS.argsChars} characters`);
157
- const result = await handler(args);
158
- try {
159
- return normalizeCommandResult(result, options.declaredPanels);
160
- } catch (error) {
161
- if (error instanceof InvalidCommandResultError) throw new Error(`invalid command result: ${error.message}`);
162
- throw error;
163
- }
412
+ setSupported: (value) => {
413
+ supported = value;
164
414
  },
165
- ids: () => [...handlers.keys()]
415
+ async show(notification) {
416
+ const title = notificationText("title", notification?.title, limits.titleLength);
417
+ const body = notificationText("body", notification?.body, limits.bodyLength);
418
+ if (!enabled) return false;
419
+ const at = now();
420
+ const recent = times.filter((time) => at - time < 36e5);
421
+ times.length = 0;
422
+ times.push(...recent);
423
+ if (recent.filter((time) => at - time < 6e4).length >= limits.perMinute) throw new NotificationRateLimitError("minute", limits.perMinute);
424
+ if (recent.length >= limits.perHour) throw new NotificationRateLimitError("hour", limits.perHour);
425
+ times.push(at);
426
+ if (!supported) return false;
427
+ shown.push({
428
+ title,
429
+ body
430
+ });
431
+ return true;
432
+ }
166
433
  };
167
434
  };
168
- const contextOf = (options, registrars) => ({
169
- extensionId: "test",
170
- logger: options.logger ?? silentLogger,
171
- library: options.library ?? createMemoryLibrary({}),
172
- storage: options.storage ?? createMemoryStorage(),
173
- settings: options.settings ?? createMemorySettings([]),
174
- events: options.events ?? createMemoryEvents(),
175
- commands: options.commands ?? createMemoryCommands(),
176
- ...registrars
177
- });
178
435
  const createSchemaValidator = (schema) => {
179
436
  const validate = new Ajv2020({
180
437
  allErrors: true,
@@ -228,219 +485,500 @@ const findGradeResultProblem = (result) => {
228
485
  if (extra !== void 0) return `unexpected key '${extra}'`;
229
486
  return (outcome === "passed" ? null : reasonProblem(result)) ?? textProblem(result, "feedback") ?? textProblem(result, "detail");
230
487
  };
231
- const loadExerciseType = async (module, type, options = {}) => {
232
- const handlers = /* @__PURE__ */ new Map();
233
- const context = contextOf(options, {
234
- registerExerciseType: (registeredType, handler) => {
235
- handlers.set(registeredType, handler);
236
- return { dispose: () => void handlers.delete(registeredType) };
237
- },
238
- registerGradePolicy: () => ({ dispose: () => void 0 })
239
- });
240
- await module.activate(context);
241
- const handler = handlers.get(type);
242
- if (handler === void 0) throw new Error(`exercise type '${type}' was not registered`);
243
- return {
244
- project: async (spec, { exerciseId = DEFAULT_EXERCISE_ID } = {}) => handler.project({
245
- exerciseId,
246
- spec
247
- }),
248
- grade: async ({ spec, answer, exerciseId = DEFAULT_EXERCISE_ID, timeoutMs = DEFAULT_TIMEOUT_MS, authorMode = false }) => {
249
- const result = await handler.grade({
250
- exerciseId,
251
- spec,
252
- answer,
253
- timeoutMs,
254
- authorMode
255
- });
256
- const problem = findGradeResultProblem(result);
257
- if (problem !== null) throw new Error(`invalid grade result: ${problem}`);
258
- return result;
259
- },
260
- referenceAnswer: async (spec, { exerciseId = DEFAULT_EXERCISE_ID } = {}) => {
261
- const answer = await handler.referenceAnswer?.({
262
- exerciseId,
263
- spec
264
- });
265
- return answer === void 0 ? { found: false } : {
266
- found: true,
267
- answer
268
- };
269
- },
270
- dispose: async () => {
271
- await module.deactivate?.();
272
- }
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
273
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
+ }
274
534
  };
275
- const isGradeValue = (value) => Number.isInteger(value) && value >= 1 && value <= 5;
276
- const loadGradePolicy = async (module, id, options = {}) => {
277
- const handlers = /* @__PURE__ */ new Map();
278
- const context = contextOf(options, {
279
- registerExerciseType: () => ({ dispose: () => void 0 }),
280
- registerGradePolicy: (registeredId, handler) => {
281
- handlers.set(registeredId, handler);
282
- return { dispose: () => void handlers.delete(registeredId) };
283
- }
284
- });
285
- await module.activate(context);
286
- const handler = handlers.get(id);
287
- if (handler === void 0) throw new Error(`grade policy '${id}' was not registered`);
288
- return {
289
- evaluate: async (input) => {
290
- const result = await handler(input);
291
- if (result !== null && !isGradeValue(result)) throw new Error(`invalid grade policy result: ${JSON.stringify(result)} is not an integer 1..5 or null`);
292
- return result;
293
- },
294
- dispose: async () => {
295
- await module.deactivate?.();
296
- }
297
- };
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;
298
546
  };
299
547
  /**
300
- * Activates the module with in-memory storage, settings, and events, and lets the test
301
- * 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`.
302
552
  */
303
- const loadEvents = async (module, options = {}) => {
304
- const events = createMemoryEvents(options);
553
+ const createTestServer = async (entry, options = {}) => {
554
+ const extensionId = options.extensionId ?? "test";
555
+ const library = options.library ?? createMemoryLibrary({});
305
556
  const storage = options.storage ?? createMemoryStorage();
306
- const settings = createMemorySettings(options.settings ?? [], options.settingValues);
307
- const context = contextOf({
308
- ...options.library !== void 0 && { library: options.library },
309
- ...options.logger !== void 0 && { logger: options.logger },
557
+ const secrets = options.secrets ?? createMemorySecrets();
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,
310
587
  storage,
588
+ secrets,
311
589
  settings,
312
- events
313
- }, {
314
- registerExerciseType: () => ({ dispose: () => void 0 }),
315
- 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
+ }
316
640
  });
317
- 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`);
318
642
  return {
319
- 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,
320
687
  storage,
688
+ secrets,
321
689
  settings,
322
- dispose: async () => {
323
- await module.deactivate?.();
324
- }
325
- };
326
- };
327
- /** Activates the module with in-memory commands and lets the test invoke them like the host. */
328
- const loadCommands = async (module, options = {}) => {
329
- const commands = createMemoryCommands(options);
330
- const context = contextOf({
331
- ...options.library !== void 0 && { library: options.library },
332
- ...options.logger !== void 0 && { logger: options.logger },
333
- ...options.storage !== void 0 && { storage: options.storage },
334
- ...options.settings !== void 0 && { settings: options.settings },
335
- ...options.events !== void 0 && { events: options.events },
336
- commands
337
- }, {
338
- registerExerciseType: () => ({ dispose: () => void 0 }),
339
- registerGradePolicy: () => ({ dispose: () => void 0 })
340
- });
341
- await module.activate(context);
342
- return {
343
- run: commands.run,
344
- ids: commands.ids,
345
- dispose: async () => {
346
- 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();
347
812
  }
348
813
  };
349
814
  };
350
- const requireDocument = (helper) => {
351
- if (typeof document === "undefined") throw new Error(`${helper} needs a DOM: run the test in a DOM environment (happy-dom or jsdom)`);
352
- return document;
353
- };
354
- const microtask = () => Promise.resolve();
355
- let viewCounter = 0;
356
815
  /**
357
- * Mounts a view from `views[id]` in the test DOM environment with the same element
358
- * the app creates (test tags are issued; the manifest `element` is not needed).
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.
359
820
  */
360
- const loadView = async (views, id, options = {}) => {
361
- const doc = requireDocument("loadView");
362
- const view = views[id];
363
- if (view === void 0) throw new Error(`view '${id}' was not exported`);
364
- const tag = `dolphy-test-view-${++viewCounter}`;
365
- customElements.define(tag, createAnswerElementClass(tag, view));
366
- const element = doc.createElement(tag);
367
- if (options.label !== void 0) element.setAttribute("aria-label", options.label);
368
- for (const key of [
369
- "view",
370
- "value",
371
- "disabled",
372
- "verdict"
373
- ]) if (options[key] !== void 0) Object.assign(element, { [key]: options[key] });
374
- const changes = [];
375
- let submissions = 0;
376
- element.addEventListener(ANSWER_EVENT.change, (event) => {
377
- changes.push(event.detail);
378
- });
379
- element.addEventListener(ANSWER_EVENT.submit, () => void (submissions += 1));
380
- const container = options.container ?? doc.body.appendChild(doc.createElement("div"));
381
- container.append(element);
382
- await microtask();
383
- const root = element.shadowRoot;
384
- return {
385
- element,
386
- root,
387
- changes,
388
- get submissions() {
389
- return submissions;
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}.'`);
831
+ };
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) };
837
+ };
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) };
390
862
  },
391
- update: async (props) => {
392
- Object.assign(element, props);
393
- await microtask();
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);
394
867
  },
395
- query: (selector) => root.querySelector(selector),
396
- queryAll: (selector) => [...root.querySelectorAll(selector)],
397
- dispose: () => {
398
- element.remove();
399
- if (options.container === void 0) container.remove();
868
+ addTheme: (reg) => addTo(themes, "theme", reg),
869
+ addCommand: (reg) => addTo(commands, "command", reg)
870
+ });
871
+ return {
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();
400
888
  }
401
889
  };
402
890
  };
403
- /** Mounts a panel from `panels[id]` in the test DOM environment with the same context the frame provides. */
404
- const loadPanel = async (panels, id, options = {}) => {
405
- const doc = requireDocument("loadPanel");
406
- const panel = panels[id];
407
- if (panel === void 0) throw new Error(`panel '${id}' was not exported`);
408
- const calls = [];
891
+ const createNotifier = () => {
409
892
  const listeners = /* @__PURE__ */ new Set();
410
- const controller = new AbortController();
411
- const container = options.container ?? doc.body.appendChild(doc.createElement("div"));
412
- await panel.mount(container, {
413
- panelId: id,
414
- 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
- onProps: (listener) => {
893
+ return {
894
+ subscribe: (listener) => {
425
895
  listeners.add(listener);
426
896
  return () => void listeners.delete(listener);
897
+ },
898
+ notify: (value) => {
899
+ for (const listener of [...listeners]) listener(value);
427
900
  }
428
- });
901
+ };
902
+ };
903
+ /**
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`.
908
+ */
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;
933
+ },
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;
944
+ },
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
954
+ };
955
+ const unmountComponent = await mountable.mount(el, ctx);
956
+ let unmounted = false;
429
957
  return {
430
- container,
431
- calls,
432
- get aborted() {
433
- return controller.signal.aborted;
958
+ el,
959
+ ctx,
960
+ setProps(next) {
961
+ props = next;
962
+ propsListeners.notify(next);
963
+ },
964
+ setTheme(next) {
965
+ theme = next;
966
+ themeListeners.notify(next);
434
967
  },
435
- setProps: (props) => {
436
- for (const listener of [...listeners]) listener(props);
968
+ setLocale(next) {
969
+ locale = next;
970
+ localeListeners.notify(next);
437
971
  },
438
- dispose: () => {
439
- controller.abort();
440
- if (options.container === void 0) container.remove();
972
+ emitted,
973
+ errors,
974
+ async unmount() {
975
+ if (unmounted) return;
976
+ unmounted = true;
977
+ abort.abort();
978
+ await unmountComponent();
441
979
  }
442
980
  };
443
981
  };
444
982
 
445
983
  //#endregion
446
- export { createMemoryCommands, createMemoryEvents, createMemoryLibrary, createMemorySettings, createMemoryStorage, createSchemaValidator, loadCommands, loadEvents, loadExerciseType, loadGradePolicy, loadPanel, loadView };
984
+ export { createMemoryLibrary, createMemoryNotifications, createMemorySecrets, createMemorySettings, createMemoryStats, createMemoryStorage, createSchemaValidator, createTestClient, createTestServer, mountForTest };