@dolphy-app/extension-sdk 0.0.0-stage → 0.3.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/README.md CHANGED
@@ -1,3 +1,27 @@
1
- # Temporary Holding Version
1
+ # @dolphy-app/extension-sdk
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ SDK for extension authors: defineExtension, defineAnswerView, test helpers
4
+
5
+ The package version equals the version of the Dolphy app release it was published from (0.3.0).
6
+
7
+ `@dolphy-app/extension-sdk` — extension code (`defineExtension`,
8
+ `defineExerciseType`), answer views (`defineAnswerView`), panels,
9
+ markdown renderers and test helpers (`@dolphy-app/extension-sdk/testing`).
10
+ The package has no side effects: an extension `src/index.ts` can be
11
+ imported in plain Node. Ids declared in `extension.json` become types
12
+ through `.dolphy/ids.d.ts`, which `dolphy-ext types` generates.
13
+
14
+ ```ts
15
+ import { defineExtension } from '@dolphy-app/extension-sdk';
16
+ import { loadExerciseType } from '@dolphy-app/extension-sdk/testing';
17
+ ```
18
+
19
+ ## Installation
20
+
21
+ ```sh
22
+ npm install @dolphy-app/extension-sdk
23
+ ```
24
+
25
+ ## Documentation
26
+
27
+ [Dolphy extensions](https://github.com/dolphy-app/dolphy/blob/main/docs/design/extensions.md).
@@ -0,0 +1,114 @@
1
+ import { ANSWER_EVENT, ELEMENT_NAME_PATTERN } from "@dolphy-app/extension-api";
2
+
3
+ //#region packages/extension-sdk/src/answer-element.ts
4
+ const logFailure = (tag, message, error) => {
5
+ console.error({
6
+ error,
7
+ tag
8
+ }, message);
9
+ };
10
+ const createAnswerElementClass = (tag, answerView) => class AnswerElement extends HTMLElement {
11
+ #root = this.attachShadow({ mode: "open" });
12
+ #props = {
13
+ view: void 0,
14
+ value: void 0,
15
+ disabled: false,
16
+ verdict: null
17
+ };
18
+ #instance = null;
19
+ #isFlushScheduled = false;
20
+ get view() {
21
+ return this.#props.view;
22
+ }
23
+ set view(view) {
24
+ this.#change({ view });
25
+ }
26
+ get value() {
27
+ return this.#props.value;
28
+ }
29
+ set value(value) {
30
+ this.#change({ value });
31
+ }
32
+ get disabled() {
33
+ return this.#props.disabled;
34
+ }
35
+ set disabled(disabled) {
36
+ this.#change({ disabled });
37
+ }
38
+ get verdict() {
39
+ return this.#props.verdict;
40
+ }
41
+ set verdict(verdict) {
42
+ this.#change({ verdict });
43
+ }
44
+ connectedCallback() {
45
+ if (this.#instance !== null) return;
46
+ const readLabel = () => this.getAttribute("aria-label");
47
+ const api = {
48
+ root: this.#root,
49
+ get label() {
50
+ return readLabel();
51
+ },
52
+ setAnswer: (value, complete) => {
53
+ const detail = {
54
+ value,
55
+ complete
56
+ };
57
+ this.#emit(ANSWER_EVENT.change, detail);
58
+ },
59
+ submit: () => this.#emit(ANSWER_EVENT.submit, void 0)
60
+ };
61
+ try {
62
+ this.#instance = answerView.mount(api, Object.freeze({ ...this.#props }));
63
+ } catch (error) {
64
+ logFailure(tag, "answer element failed to mount", error);
65
+ }
66
+ }
67
+ disconnectedCallback() {
68
+ const instance = this.#instance;
69
+ this.#instance = null;
70
+ if (instance === null) return;
71
+ try {
72
+ instance.destroy?.();
73
+ } catch (error) {
74
+ logFailure(tag, "answer element failed to destroy", error);
75
+ }
76
+ this.#root.replaceChildren();
77
+ }
78
+ #change(patch) {
79
+ this.#props = {
80
+ ...this.#props,
81
+ ...patch
82
+ };
83
+ if (this.#instance === null || this.#isFlushScheduled) return;
84
+ this.#isFlushScheduled = true;
85
+ queueMicrotask(() => this.#flush());
86
+ }
87
+ #flush() {
88
+ this.#isFlushScheduled = false;
89
+ const instance = this.#instance;
90
+ if (instance === null) return;
91
+ try {
92
+ instance.update(Object.freeze({ ...this.#props }));
93
+ } catch (error) {
94
+ logFailure(tag, "answer element failed to update", error);
95
+ }
96
+ }
97
+ #emit(name, detail) {
98
+ this.dispatchEvent(new CustomEvent(name, {
99
+ detail,
100
+ bubbles: true,
101
+ composed: true
102
+ }));
103
+ }
104
+ };
105
+ /** Defines the kind's custom element; calling again with the same tag changes nothing. */
106
+ const registerAnswerView = (tag, view) => {
107
+ if (!ELEMENT_NAME_PATTERN.test(tag)) throw new TypeError(`invalid custom element name '${tag}'`);
108
+ if (typeof view?.mount !== "function") throw new TypeError(`answer view for '${tag}' has no mount()`);
109
+ if (customElements.get(tag) !== void 0) return;
110
+ customElements.define(tag, createAnswerElementClass(tag, view));
111
+ };
112
+
113
+ //#endregion
114
+ export { registerAnswerView as n, createAnswerElementClass as t };
@@ -0,0 +1,28 @@
1
+ import { AnswerElementProps } from "@dolphy-app/extension-api";
2
+ //#region packages/extension-sdk/src/answer-view.d.ts
3
+ interface AnswerViewApi {
4
+ readonly root: ShadowRoot;
5
+ /** `aria-label` of the host element set by the app; `null` if none. */
6
+ readonly label: string | null;
7
+ /** Reports the current answer to the app: the `dolphy-answer-change` event. */
8
+ setAnswer(value: unknown, complete: boolean): void;
9
+ /** Asks the app to submit the answer: the `dolphy-answer-submit` event. */
10
+ submit(): void;
11
+ }
12
+ interface AnswerViewInstance {
13
+ /** Called when `view`/`value`/`disabled`/`verdict` change. */
14
+ update(props: AnswerElementProps): void;
15
+ destroy?(): void;
16
+ }
17
+ type MountAnswerView = (api: AnswerViewApi, props: AnswerElementProps) => AnswerViewInstance;
18
+ /** Description of an answer input view: side-effect-free data; the build registers the element. */
19
+ interface AnswerView {
20
+ readonly mount: MountAnswerView;
21
+ }
22
+ /**
23
+ * Entry `views[<exercise kind id>]` in `src/index.ts`. Registers nothing:
24
+ * the custom element with the manifest tag is defined by the build's browser file.
25
+ */
26
+ declare const defineAnswerView: (mount: MountAnswerView) => AnswerView;
27
+ //#endregion
28
+ export { defineAnswerView as a, MountAnswerView as i, AnswerViewApi as n, AnswerViewInstance as r, AnswerView as t };
@@ -0,0 +1,108 @@
1
+ import { a as defineAnswerView, i as MountAnswerView, n as AnswerViewApi, r as AnswerViewInstance, t as AnswerView } from "./answer-view-D6wnyThb.js";
2
+ import { CommandHandler, ExerciseTypeHandler, ExtensionContext as ExtensionContext$1, ExtensionIdSet, ExtensionModule, GradePolicyHandler, JsonValue, LearningEventHandler, MarkdownRenderContext, MarkdownRendererModule, NotifyEffect, OpenPanelEffect, PanelContext as PanelContext$1, PanelModule } from "@dolphy-app/extension-api";
3
+ export * from "@dolphy-app/extension-api";
4
+ //#region packages/extension-sdk/src/ids.d.ts
5
+ /**
6
+ * The ids declared in `extension.json`. Empty here: `dolphy-ext types` (and
7
+ * every `dolphy-ext build`) writes `.dolphy/ids.d.ts`, which augments this
8
+ * interface with one key per kind of id:
9
+ *
10
+ * ```ts
11
+ * declare module '@dolphy-app/extension-sdk' {
12
+ * interface ExtensionIds {
13
+ * exerciseTypes: 'acme.echo';
14
+ * commands: 'acme.a' | 'acme.b';
15
+ * settings: { 'acme.goal': number };
16
+ * // …
17
+ * }
18
+ * }
19
+ * ```
20
+ *
21
+ * While the interface is empty (no generated file) every id is a plain
22
+ * `string` and `defineExtension` does not require any record.
23
+ */
24
+ interface ExtensionIds {}
25
+ type Declared<K extends keyof ExtensionIdSet> = K extends keyof ExtensionIds ? ExtensionIds[K] extends ExtensionIdSet[K] ? ExtensionIds[K] : ExtensionIdSet[K] : ExtensionIdSet[K];
26
+ /** `ExtensionIds` with the plain-string fallback for the kinds it does not declare. */
27
+ type ResolvedIds = { [K in keyof ExtensionIdSet]: Declared<K>; };
28
+ /** Whether the generated declarations are part of the program. */
29
+ type HasGeneratedIds = [keyof ExtensionIds] extends [never] ? false : true;
30
+ /**
31
+ * `ExtensionContext` of this extension: `settings`, `events`, `commands` and
32
+ * the `register…` methods accept the declared ids only.
33
+ */
34
+ type ExtensionContext = ExtensionContext$1<ResolvedIds>;
35
+ /** Context of a panel module; `call` accepts the declared command ids only. */
36
+ type PanelContext = PanelContext$1<ResolvedIds['commands']>;
37
+ /**
38
+ * A record that holds exactly the declared ids: a missing and an extra key are
39
+ * both compile errors. With no generated declarations any keys are accepted;
40
+ * with none declared of this kind no key is accepted.
41
+ */
42
+ type Exact<Id extends string, Value> = [HasGeneratedIds] extends [false] ? {
43
+ readonly [key: string]: Value;
44
+ } : [Id] extends [never] ? {
45
+ readonly [key: string]: never;
46
+ } : { readonly [K in Id]: Value; };
47
+ /** `export const views = { … } satisfies ExtensionViews`: one `defineAnswerView` per declared exercise type. */
48
+ type ExtensionViews = Exact<ResolvedIds['exerciseTypes'], AnswerView>;
49
+ /** `export const panels = { … } satisfies ExtensionPanels`: one `defineExtensionPanel` per declared panel. */
50
+ type ExtensionPanels = Exact<ResolvedIds['panels'], PanelModule<HTMLElement, ResolvedIds['commands']>>;
51
+ /** `export const markdown = { … } satisfies ExtensionMarkdown`: one `defineMarkdownRenderer` per declared language. */
52
+ type ExtensionMarkdown = Exact<ResolvedIds['markdownLanguages'], MarkdownRendererModule<HTMLElement>>;
53
+ //#endregion
54
+ //#region packages/extension-sdk/src/commands.d.ts
55
+ /** A command result: the app shows a notification (1–500 characters, as is, no markup). */
56
+ export declare const notify: (text: string) => NotifyEffect;
57
+ /** A command result: the app opens a panel of this extension (a declared panel id); `props` reach the panel as `ctx.props`. */
58
+ export declare const openPanel: (panelId: ResolvedIds["panels"], props?: JsonValue) => OpenPanelEffect;
59
+ //#endregion
60
+ //#region packages/extension-sdk/src/define-extension.d.ts
61
+ declare const inActivateBrand: unique symbol;
62
+ /** Type of `inActivate`. */
63
+ interface InActivate {
64
+ readonly [inActivateBrand]: true;
65
+ }
66
+ /**
67
+ * A record value in `defineExtension` that says "this id is registered in
68
+ * `activate`" (`ctx.commands.register`, `ctx.events.on`,
69
+ * `ctx.registerExerciseType`, `ctx.registerGradePolicy`) rather than by a
70
+ * handler in the record. Needed because the records must name every declared
71
+ * id: a handler that needs `ctx` is written in `activate`, and its id gets this
72
+ * marker in the record.
73
+ */
74
+ export declare const inActivate: InActivate;
75
+ /** Without generated declarations a record may name any subset: an index signature already does, a record keyed by the event names needs `Partial`. */
76
+ type Lenient<Entries> = string extends keyof Entries ? Entries : Partial<Entries>;
77
+ /**
78
+ * The definition record of one kind of id. With generated declarations the
79
+ * record is required (when the manifest declares any id of the kind) and holds
80
+ * exactly the declared ids: a missing and an extra key are compile errors.
81
+ * Without them every key is accepted and the record is optional.
82
+ */
83
+ type Section<Name extends string, Id extends string, Entries> = [HasGeneratedIds] extends [false] ? { readonly [N in Name]?: Lenient<Entries>; } : [Id] extends [never] ? { readonly [N in Name]?: never; } : { readonly [N in Name]: Entries; };
84
+ type Ids = ResolvedIds;
85
+ /** Learning-event handlers by event name; the events must be declared in `contributes.events`, the `learning.events` permission is needed. */
86
+ type EventHandlers = { readonly [N in Ids['events']]: LearningEventHandler<N> | InActivate; };
87
+ /**
88
+ * What `defineExtension` takes. `exerciseTypes`, `gradePolicies`, `events` and
89
+ * `commands` name every id `extension.json` declares for them, exactly: a
90
+ * handler, or `inActivate` for an id that `activate` registers.
91
+ */
92
+ type ExtensionDefinition = Section<'exerciseTypes', Ids['exerciseTypes'], { readonly [K in Ids['exerciseTypes']]: ExerciseTypeHandler | InActivate; }> & Section<'gradePolicies', Ids['gradePolicies'], { readonly [K in Ids['gradePolicies']]: GradePolicyHandler | InActivate; }> & Section<'events', Ids['events'], EventHandlers> & Section<'commands', Ids['commands'], { readonly [K in Ids['commands']]: CommandHandler | InActivate; }> & {
93
+ /** Runs after the records are registered. */
94
+ activate?(context: ExtensionContext): void | Promise<void>;
95
+ deactivate?(): void | Promise<void>;
96
+ };
97
+ export declare const defineExerciseType: <Spec, Answer, View>(handler: ExerciseTypeHandler<Spec, Answer, View>) => ExerciseTypeHandler<Spec, Answer, View>;
98
+ export declare const defineExtension: (declared: ExtensionDefinition) => ExtensionModule;
99
+ //#endregion
100
+ //#region packages/extension-sdk/src/markdown-renderer.d.ts
101
+ /** Entry `markdown[<language>]` in `src/index.ts` (`contributes.markdownRenderers`). */
102
+ export declare const defineMarkdownRenderer: (render: (source: string, container: HTMLElement, context: MarkdownRenderContext) => void | Promise<void>) => MarkdownRendererModule<HTMLElement>;
103
+ //#endregion
104
+ //#region packages/extension-sdk/src/panel.d.ts
105
+ /** An entry of `panels[<panel id>]` in `src/index.ts` (`contributes.panels`); `ctx.call` accepts the declared command ids. */
106
+ export declare const defineExtensionPanel: (module: PanelModule<HTMLElement, ResolvedIds["commands"]>) => PanelModule<HTMLElement, ResolvedIds["commands"]>;
107
+ //#endregion
108
+ export { type AnswerView, type AnswerViewApi, type AnswerViewInstance, type EventHandlers, type ExtensionContext, type ExtensionDefinition, type ExtensionIds, type ExtensionMarkdown, type ExtensionPanels, type ExtensionViews, type InActivate, type MountAnswerView, type PanelContext, defineAnswerView };
package/dist/index.js ADDED
@@ -0,0 +1,91 @@
1
+ export * from "@dolphy-app/extension-api"
2
+
3
+ //#region packages/extension-sdk/src/commands.ts
4
+ /** A command result: the app shows a notification (1–500 characters, as is, no markup). */
5
+ const notify = (text) => ({ notify: text });
6
+ /** A command result: the app opens a panel of this extension (a declared panel id); `props` reach the panel as `ctx.props`. */
7
+ const openPanel = (panelId, props) => props === void 0 ? { openPanel: panelId } : {
8
+ openPanel: panelId,
9
+ props
10
+ };
11
+
12
+ //#endregion
13
+ //#region packages/extension-sdk/src/define-extension.ts
14
+ /**
15
+ * A record value in `defineExtension` that says "this id is registered in
16
+ * `activate`" (`ctx.commands.register`, `ctx.events.on`,
17
+ * `ctx.registerExerciseType`, `ctx.registerGradePolicy`) rather than by a
18
+ * handler in the record. Needed because the records must name every declared
19
+ * id: a handler that needs `ctx` is written in `activate`, and its id gets this
20
+ * marker in the record.
21
+ */
22
+ const inActivate = /*#__PURE__*/ Object.freeze({});
23
+ const defineExerciseType = /* @__NO_SIDE_EFFECTS__ */ (handler) => handler;
24
+ const disposeInReverse = async (registrations) => {
25
+ const errors = [];
26
+ for (const registration of registrations.splice(0).reverse()) try {
27
+ await registration.dispose();
28
+ } catch (error) {
29
+ errors.push(error);
30
+ }
31
+ return errors;
32
+ };
33
+ /** The entries of a record that carry a handler (not `inActivate`). */
34
+ const handlersOf = (record) => Object.entries(record ?? {}).filter((entry) => entry[1] !== inActivate);
35
+ const defineExtension = /* @__NO_SIDE_EFFECTS__ */ (declared) => {
36
+ const definition = declared;
37
+ const registrations = [];
38
+ const register = (context) => {
39
+ for (const [type, handler] of handlersOf(definition.exerciseTypes)) registrations.push(context.registerExerciseType(type, handler));
40
+ for (const [id, handler] of handlersOf(definition.gradePolicies)) registrations.push(context.registerGradePolicy(id, handler));
41
+ for (const [name, handler] of handlersOf(definition.events)) registrations.push(context.events.on(name, handler));
42
+ for (const [id, handler] of handlersOf(definition.commands)) registrations.push(context.commands.register(id, handler));
43
+ };
44
+ const activate = async (context) => {
45
+ try {
46
+ register(context);
47
+ await definition.activate?.(context);
48
+ } catch (error) {
49
+ const disposalErrors = await disposeInReverse(registrations);
50
+ if (disposalErrors.length === 0) throw error;
51
+ throw new AggregateError([error, ...disposalErrors], "activation failed and rollback was incomplete");
52
+ }
53
+ };
54
+ const deactivate = async () => {
55
+ const failures = [];
56
+ try {
57
+ await definition.deactivate?.();
58
+ } catch (error) {
59
+ failures.push(error);
60
+ }
61
+ const disposalErrors = await disposeInReverse(registrations);
62
+ if (disposalErrors.length > 0) failures.push(new AggregateError(disposalErrors, "failed to dispose contributions"));
63
+ if (failures.length === 1) throw failures[0];
64
+ if (failures.length > 1) throw new AggregateError(failures, "failed to deactivate extension");
65
+ };
66
+ return {
67
+ activate,
68
+ deactivate
69
+ };
70
+ };
71
+
72
+ //#endregion
73
+ //#region packages/extension-sdk/src/answer-view.ts
74
+ /**
75
+ * Entry `views[<exercise kind id>]` in `src/index.ts`. Registers nothing:
76
+ * the custom element with the manifest tag is defined by the build's browser file.
77
+ */
78
+ const defineAnswerView = /* @__NO_SIDE_EFFECTS__ */ (mount) => ({ mount });
79
+
80
+ //#endregion
81
+ //#region packages/extension-sdk/src/markdown-renderer.ts
82
+ /** Entry `markdown[<language>]` in `src/index.ts` (`contributes.markdownRenderers`). */
83
+ const defineMarkdownRenderer = /* @__NO_SIDE_EFFECTS__ */ (render) => ({ render });
84
+
85
+ //#endregion
86
+ //#region packages/extension-sdk/src/panel.ts
87
+ /** An entry of `panels[<panel id>]` in `src/index.ts` (`contributes.panels`); `ctx.call` accepts the declared command ids. */
88
+ const defineExtensionPanel = /* @__NO_SIDE_EFFECTS__ */ (module) => module;
89
+
90
+ //#endregion
91
+ export { defineAnswerView, defineExerciseType, defineExtension, defineExtensionPanel, defineMarkdownRenderer, inActivate, notify, openPanel };
@@ -0,0 +1,14 @@
1
+ import { t as AnswerView } from "./answer-view-D6wnyThb.js";
2
+ import { MarkdownRendererModule, PanelModule } from "@dolphy-app/extension-api";
3
+ //#region packages/extension-sdk/src/answer-element.d.ts
4
+ /** Defines the kind's custom element; calling again with the same tag changes nothing. */
5
+ export declare const registerAnswerView: (tag: string, view: AnswerView) => void;
6
+ //#endregion
7
+ //#region packages/extension-sdk/src/runtime.d.ts
8
+ type PanelEntry = PanelModule<HTMLElement>;
9
+ type MarkdownEntry = MarkdownRendererModule<HTMLElement>;
10
+ /** Panel module that selects the `panels` entry by `ctx.panelId` (for a file shared by several panels). */
11
+ export declare const dispatchPanels: (panels: Readonly<Record<string, PanelEntry>>) => PanelEntry;
12
+ /** Renderer module that selects the `markdown` entry by block language. */
13
+ export declare const dispatchMarkdown: (renderers: Readonly<Record<string, MarkdownEntry>>) => MarkdownEntry;
14
+ //#endregion
@@ -0,0 +1,18 @@
1
+ import { n as registerAnswerView } from "./answer-element-BOQcuxYh.js";
2
+
3
+ //#region packages/extension-sdk/src/runtime.ts
4
+ /** Panel module that selects the `panels` entry by `ctx.panelId` (for a file shared by several panels). */
5
+ const dispatchPanels = (panels) => ({ mount(container, context) {
6
+ const panel = panels[context.panelId];
7
+ if (panel === void 0) throw new Error(`panel '${context.panelId}' is not exported`);
8
+ return panel.mount(container, context);
9
+ } });
10
+ /** Renderer module that selects the `markdown` entry by block language. */
11
+ const dispatchMarkdown = (renderers) => ({ render(source, container, context) {
12
+ const renderer = renderers[context.language];
13
+ if (renderer === void 0) throw new Error(`markdown renderer '${context.language}' is not exported`);
14
+ return renderer.render(source, container, context);
15
+ } });
16
+
17
+ //#endregion
18
+ export { dispatchMarkdown, dispatchPanels, registerAnswerView };
@@ -0,0 +1,175 @@
1
+ import { t as AnswerView } from "./answer-view-D6wnyThb.js";
2
+ import { AnswerChangeDetail, AnswerElementProps, CommandOutcome, ExtensionCommands, ExtensionEvents, ExtensionLogger, ExtensionModule, ExtensionSettings, ExtensionStorage, GradePolicyInput, GradeResult, GradeValue, JsonSchema, JsonValue, LearningEventName, LearningEventPayloads, LibraryReader, PanelModule, SettingContribution, SettingValue } from "@dolphy-app/extension-api";
3
+ //#region packages/extension-sdk/src/testing.d.ts
4
+ export declare const createMemoryLibrary: (files: Readonly<Record<string, string>>) => LibraryReader;
5
+ /**
6
+ * In-memory storage with the same limits and errors as the engine
7
+ * (`EXTENSION_STORAGE_LIMITS`, `StorageQuotaError`): values are stored as
8
+ * JSON text and returned as copies; on rejection nothing changes.
9
+ */
10
+ export declare const createMemoryStorage: () => ExtensionStorage;
11
+ export interface MemorySettings extends ExtensionSettings {
12
+ /**
13
+ * Changes the value as the user does in the dialog: the value is validated
14
+ * against the definition, and `onDidChange` subscribers are called if it changed.
15
+ * Unlike the host, a handler failure is not swallowed but rejects the promise.
16
+ */
17
+ set(id: string, value: SettingValue): Promise<void>;
18
+ }
19
+ /** In-memory settings from manifest definitions; `initial` provides user values in place of `default`. */
20
+ export declare const createMemorySettings: (definitions: readonly SettingContribution[], initial?: Readonly<Record<string, SettingValue>>) => MemorySettings;
21
+ export interface MemoryEvents extends ExtensionEvents {
22
+ /**
23
+ * Sends the event to the subscribed handler and awaits it. With no subscription,
24
+ * the event is skipped, as in the host. Unlike the host, a handler failure
25
+ * is not swallowed but rejects the promise, and the 2 s handler timeout is not applied.
26
+ */
27
+ emit<N extends LearningEventName>(name: N, payload: LearningEventPayloads[N]): Promise<void>;
28
+ }
29
+ export interface MemoryEventsOptions {
30
+ /** Events from `contributes.events`: subscribing to another throws, as in the host. Unset — any are allowed. */
31
+ declared?: readonly LearningEventName[];
32
+ /** false — subscribing throws `PermissionError`, as for an extension without `learning.events`. Defaults to true. */
33
+ permitted?: boolean;
34
+ }
35
+ /** In-memory learning event subscriptions: one handler per event, as in the host. */
36
+ export declare const createMemoryEvents: (options?: MemoryEventsOptions) => MemoryEvents;
37
+ export interface MemoryCommands extends ExtensionCommands {
38
+ /**
39
+ * Runs a registered command the way the host does: the same
40
+ * argument and result bounds, the same result normalization. An unregistered
41
+ * command and an invalid result reject the promise. The 10 s handler timeout is not applied.
42
+ */
43
+ run(id: string, args?: JsonValue): Promise<CommandOutcome>;
44
+ /** Registered commands in registration order. */
45
+ ids(): string[];
46
+ }
47
+ export interface MemoryCommandsOptions {
48
+ /** Commands from `contributes.commands`: registering another throws, as in the host. Unset — any are allowed. */
49
+ declaredCommands?: readonly string[];
50
+ /** Panels from `contributes.panels`: `openPanel` on another is invalid, as in the host. Unset — any. */
51
+ declaredPanels?: readonly string[];
52
+ }
53
+ /** In-memory commands: the same registration rules and result parsing as the host. */
54
+ export declare const createMemoryCommands: (options?: MemoryCommandsOptions) => MemoryCommands;
55
+ /** What a test replaces in the extension context; by default everything is in memory and silent. */
56
+ export interface LoadOptions {
57
+ library?: LibraryReader;
58
+ logger?: ExtensionLogger;
59
+ storage?: ExtensionStorage;
60
+ settings?: ExtensionSettings;
61
+ events?: ExtensionEvents;
62
+ commands?: ExtensionCommands;
63
+ }
64
+ export declare const createSchemaValidator: (schema: JsonSchema) => (value: unknown) => string[];
65
+ export interface LoadedExerciseType {
66
+ project(spec: unknown, options?: {
67
+ exerciseId?: string;
68
+ }): Promise<unknown>;
69
+ grade(input: {
70
+ spec: unknown;
71
+ answer: unknown;
72
+ exerciseId?: string;
73
+ timeoutMs?: number;
74
+ authorMode?: boolean;
75
+ }): Promise<GradeResult>;
76
+ referenceAnswer(spec: unknown, options?: {
77
+ exerciseId?: string;
78
+ }): Promise<{
79
+ found: true;
80
+ answer: unknown;
81
+ } | {
82
+ found: false;
83
+ }>;
84
+ /** Deactivates the extension module. */
85
+ dispose(): Promise<void>;
86
+ }
87
+ export declare const loadExerciseType: (module: ExtensionModule, type: string, options?: LoadOptions) => Promise<LoadedExerciseType>;
88
+ export interface LoadedGradePolicy {
89
+ evaluate(input: GradePolicyInput): Promise<GradeValue | null>;
90
+ /** Deactivates the extension module. */
91
+ dispose(): Promise<void>;
92
+ }
93
+ export declare const loadGradePolicy: (module: ExtensionModule, id: string, options?: LoadOptions) => Promise<LoadedGradePolicy>;
94
+ export interface LoadedEvents {
95
+ /** Events are delivered as in the host: to the subscribed handler, one at a time. See `MemoryEvents.emit`. */
96
+ emit: MemoryEvents['emit'];
97
+ storage: ExtensionStorage;
98
+ settings: MemorySettings;
99
+ /** Deactivates the extension module. */
100
+ dispose(): Promise<void>;
101
+ }
102
+ export interface LoadEventsOptions extends Omit<LoadOptions, 'storage' | 'settings' | 'events'>, MemoryEventsOptions {
103
+ storage?: ExtensionStorage;
104
+ /** Definitions from the manifest's `contributes.settings`; values are read and changed through `settings`. */
105
+ settings?: readonly SettingContribution[];
106
+ /** User values in place of `default`. */
107
+ settingValues?: Readonly<Record<string, SettingValue>>;
108
+ }
109
+ /**
110
+ * Activates the module with in-memory storage, settings, and events, and lets the test
111
+ * send events and change settings.
112
+ */
113
+ export declare const loadEvents: (module: ExtensionModule, options?: LoadEventsOptions) => Promise<LoadedEvents>;
114
+ export interface LoadedCommands {
115
+ run: MemoryCommands['run'];
116
+ ids: MemoryCommands['ids'];
117
+ /** Deactivates the extension module. */
118
+ dispose(): Promise<void>;
119
+ }
120
+ export interface LoadCommandsOptions extends Omit<LoadOptions, 'commands'>, MemoryCommandsOptions {}
121
+ /** Activates the module with in-memory commands and lets the test invoke them like the host. */
122
+ export declare const loadCommands: (module: ExtensionModule, options?: LoadCommandsOptions) => Promise<LoadedCommands>;
123
+ export interface LoadViewOptions extends Partial<AnswerElementProps> {
124
+ /** `aria-label` of the host element, as the app sets it. */
125
+ label?: string;
126
+ /** Where to mount; defaults to a new `div` in `document.body`. */
127
+ container?: HTMLElement;
128
+ }
129
+ export interface LoadedView {
130
+ /** The kind's custom element, as the app creates it. */
131
+ readonly element: HTMLElement;
132
+ /** The element's shadow root: the view renders its UI here. */
133
+ readonly root: ShadowRoot;
134
+ /** `dolphy-answer-change` events in order. */
135
+ readonly changes: readonly AnswerChangeDetail[];
136
+ /** How many times the view asked to submit the answer (`dolphy-answer-submit`). */
137
+ readonly submissions: number;
138
+ /** Sets element properties and waits for the view to apply the update. */
139
+ update(props: Partial<AnswerElementProps>): Promise<void>;
140
+ query<E extends Element = Element>(selector: string): E | null;
141
+ queryAll<E extends Element = Element>(selector: string): E[];
142
+ /** Removes the element from the document; the view receives `destroy()`. */
143
+ dispose(): void;
144
+ }
145
+ /**
146
+ * Mounts a view from `views[id]` in the test DOM environment with the same element
147
+ * the app creates (test tags are issued; the manifest `element` is not needed).
148
+ */
149
+ export declare const loadView: (views: Readonly<Record<string, AnswerView>>, id: string, options?: LoadViewOptions) => Promise<LoadedView>;
150
+ export interface LoadPanelOptions {
151
+ /** Properties the panel was opened with (`openPanel(id, props)`). */
152
+ props?: JsonValue;
153
+ /** Reply to `ctx.call`; by default the call is rejected. */
154
+ call?: (commandId: string, args: JsonValue | undefined) => JsonValue | undefined | Promise<JsonValue | undefined>;
155
+ /** Where to mount; defaults to a new `div` in `document.body`. */
156
+ container?: HTMLElement;
157
+ }
158
+ export interface LoadedPanel {
159
+ /** Container the panel received in `mount`. */
160
+ readonly container: HTMLElement;
161
+ /** `ctx.call` invocations in order. */
162
+ readonly calls: readonly {
163
+ commandId: string;
164
+ args: JsonValue | undefined;
165
+ }[];
166
+ /** Whether `ctx.signal` was aborted (after `dispose()`). */
167
+ readonly aborted: boolean;
168
+ /** Sends new properties to the panel (`ctx.onProps`). */
169
+ setProps(props: JsonValue | undefined): void;
170
+ /** Closes the frame: aborts `ctx.signal` and removes the container. */
171
+ dispose(): void;
172
+ }
173
+ /** Mounts a panel from `panels[id]` in the test DOM environment with the same context the frame provides. */
174
+ export declare const loadPanel: (panels: Readonly<Record<string, PanelModule<HTMLElement>>>, id: string, options?: LoadPanelOptions) => Promise<LoadedPanel>;
175
+ //#endregion
@@ -0,0 +1,446 @@
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";
3
+ import { Ajv2020 } from "ajv/dist/2020.js";
4
+
5
+ //#region packages/extension-sdk/src/testing.ts
6
+ const MAX_MESSAGES = 6;
7
+ const MAX_REASON_CHARS = 100;
8
+ const MAX_TEXT_CHARS = 4e3;
9
+ const DEFAULT_EXERCISE_ID = "test::lesson::exercise";
10
+ const DEFAULT_TIMEOUT_MS = 2e3;
11
+ const silentLogger = {
12
+ debug: () => void 0,
13
+ info: () => void 0,
14
+ warn: () => void 0,
15
+ error: () => void 0
16
+ };
17
+ const createMemoryLibrary = (files) => {
18
+ const entries = new Map(Object.entries(files));
19
+ return {
20
+ readText: async (path) => {
21
+ const text = entries.get(path);
22
+ if (text === void 0) throw new Error(`ENOENT: ${path}`);
23
+ return text;
24
+ },
25
+ stat: async (path) => {
26
+ const text = entries.get(path);
27
+ if (text === void 0) return null;
28
+ return {
29
+ kind: "file",
30
+ bytes: Buffer.byteLength(text),
31
+ mtimeMs: 0
32
+ };
33
+ }
34
+ };
35
+ };
36
+ /** UTF-8 byte order matches code point order — this is how the engine sorts keys. */
37
+ const compareKeys = (a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b));
38
+ /**
39
+ * In-memory storage with the same limits and errors as the engine
40
+ * (`EXTENSION_STORAGE_LIMITS`, `StorageQuotaError`): values are stored as
41
+ * JSON text and returned as copies; on rejection nothing changes.
42
+ */
43
+ const createMemoryStorage = () => {
44
+ const limits = EXTENSION_STORAGE_LIMITS;
45
+ const entries = /* @__PURE__ */ new Map();
46
+ const totalBytes = () => {
47
+ let total = 0;
48
+ for (const text of entries.values()) total += Buffer.byteLength(text);
49
+ return total;
50
+ };
51
+ return {
52
+ get: async (key) => {
53
+ const text = entries.get(key);
54
+ return text === void 0 ? void 0 : JSON.parse(text);
55
+ },
56
+ set: async (key, value) => {
57
+ if (typeof key !== "string" || key.length === 0) throw new Error("storage key must be a non-empty string");
58
+ const text = JSON.stringify(value);
59
+ if (text === void 0) throw new Error("storage value must be JSON");
60
+ const bytes = Buffer.byteLength(text);
61
+ if (key.length > limits.keyLength) throw new StorageQuotaError("key-length", limits.keyLength);
62
+ if (bytes > limits.valueBytes) throw new StorageQuotaError("value-size", limits.valueBytes);
63
+ const existing = entries.get(key);
64
+ if (existing === void 0 && entries.size >= limits.keys) throw new StorageQuotaError("key-count", limits.keys);
65
+ if (totalBytes() - (existing === void 0 ? 0 : Buffer.byteLength(existing)) + bytes > limits.totalBytes) throw new StorageQuotaError("total-size", limits.totalBytes);
66
+ entries.set(key, text);
67
+ },
68
+ delete: async (key) => entries.delete(key),
69
+ keys: async () => [...entries.keys()].sort(compareKeys)
70
+ };
71
+ };
72
+ /** Why a value does not fit the setting definition; `null` if it fits. */
73
+ const findSettingProblem = (definition, value) => {
74
+ switch (definition.type) {
75
+ case "boolean": return typeof value === "boolean" ? null : "must be a boolean";
76
+ case "string":
77
+ if (typeof value !== "string") return "must be a string";
78
+ return definition.maxLength !== void 0 && value.length > definition.maxLength ? `is longer than ${definition.maxLength} characters` : null;
79
+ case "number":
80
+ if (typeof value !== "number" || !Number.isFinite(value)) return "must be a finite number";
81
+ if (definition.integer === true && !Number.isInteger(value)) return "must be an integer";
82
+ if (definition.min !== void 0 && value < definition.min) return `is less than ${definition.min}`;
83
+ return definition.max !== void 0 && value > definition.max ? `is greater than ${definition.max}` : null;
84
+ default: return typeof value === "string" && definition.options.some((option) => option.value === value) ? null : "must be one of the options";
85
+ }
86
+ };
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]));
91
+ const handlers = /* @__PURE__ */ new Set();
92
+ const known = (id) => {
93
+ const definition = byId.get(id);
94
+ if (definition === void 0) throw new Error(`setting '${id}' is not declared in the manifest`);
95
+ return definition;
96
+ };
97
+ const checked = (id, value) => {
98
+ const problem = findSettingProblem(known(id), value);
99
+ if (problem !== null) throw new Error(`setting '${id}' ${problem}`);
100
+ return value;
101
+ };
102
+ for (const [id, value] of Object.entries(initial)) values.set(id, checked(id, value));
103
+ return {
104
+ get: (id) => {
105
+ known(id);
106
+ return values.get(id);
107
+ },
108
+ onDidChange(handler) {
109
+ handlers.add(handler);
110
+ return { dispose: () => void handlers.delete(handler) };
111
+ },
112
+ async set(id, value) {
113
+ const next = checked(id, value);
114
+ if (Object.is(values.get(id), next)) return;
115
+ values.set(id, next);
116
+ for (const handler of [...handlers]) await handler({
117
+ id,
118
+ value: next
119
+ });
120
+ }
121
+ };
122
+ };
123
+ /** In-memory learning event subscriptions: one handler per event, as in the host. */
124
+ const createMemoryEvents = (options = {}) => {
125
+ const handlers = /* @__PURE__ */ new Map();
126
+ 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
+ } };
135
+ },
136
+ async emit(name, payload) {
137
+ await handlers.get(name)?.(payload);
138
+ }
139
+ };
140
+ };
141
+ /** In-memory commands: the same registration rules and result parsing as the host. */
142
+ const createMemoryCommands = (options = {}) => {
143
+ const handlers = /* @__PURE__ */ new Map();
144
+ 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
+ } };
152
+ },
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
+ }
164
+ },
165
+ ids: () => [...handlers.keys()]
166
+ };
167
+ };
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
+ const createSchemaValidator = (schema) => {
179
+ const validate = new Ajv2020({
180
+ allErrors: true,
181
+ strict: false
182
+ }).compile(schema);
183
+ return (value) => {
184
+ if (validate(value)) return [];
185
+ return (validate.errors ?? []).slice(0, MAX_MESSAGES).map((error) => `${error.instancePath || "/"} ${error.message}`);
186
+ };
187
+ };
188
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
189
+ const OUTCOME_KEYS = {
190
+ passed: [
191
+ "outcome",
192
+ "feedback",
193
+ "data"
194
+ ],
195
+ failed: [
196
+ "outcome",
197
+ "reason",
198
+ "feedback",
199
+ "detail",
200
+ "data"
201
+ ],
202
+ error: [
203
+ "outcome",
204
+ "reason",
205
+ "feedback",
206
+ "data"
207
+ ]
208
+ };
209
+ const textProblem = (result, key) => {
210
+ const value = result[key];
211
+ if (value === void 0) return null;
212
+ if (typeof value !== "string") return `'${key}' must be a string`;
213
+ if (value.length > MAX_TEXT_CHARS) return `'${key}' is longer than ${MAX_TEXT_CHARS} characters`;
214
+ return null;
215
+ };
216
+ const reasonProblem = (result) => {
217
+ const { reason } = result;
218
+ if (typeof reason !== "string" || reason.length === 0) return `'reason' must be a non-empty string`;
219
+ if (reason.length > MAX_REASON_CHARS) return `'reason' is longer than ${MAX_REASON_CHARS} characters`;
220
+ return null;
221
+ };
222
+ const findGradeResultProblem = (result) => {
223
+ if (!isRecord(result)) return "result must be an object";
224
+ const { outcome } = result;
225
+ if (outcome !== "passed" && outcome !== "failed" && outcome !== "error") return `unknown outcome ${JSON.stringify(outcome)}`;
226
+ const allowed = OUTCOME_KEYS[outcome];
227
+ const extra = Object.keys(result).find((key) => !allowed.includes(key));
228
+ if (extra !== void 0) return `unexpected key '${extra}'`;
229
+ return (outcome === "passed" ? null : reasonProblem(result)) ?? textProblem(result, "feedback") ?? textProblem(result, "detail");
230
+ };
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
+ }
273
+ };
274
+ };
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
+ };
298
+ };
299
+ /**
300
+ * Activates the module with in-memory storage, settings, and events, and lets the test
301
+ * send events and change settings.
302
+ */
303
+ const loadEvents = async (module, options = {}) => {
304
+ const events = createMemoryEvents(options);
305
+ 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 },
310
+ storage,
311
+ settings,
312
+ events
313
+ }, {
314
+ registerExerciseType: () => ({ dispose: () => void 0 }),
315
+ registerGradePolicy: () => ({ dispose: () => void 0 })
316
+ });
317
+ await module.activate(context);
318
+ return {
319
+ emit: events.emit,
320
+ storage,
321
+ 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?.();
347
+ }
348
+ };
349
+ };
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
+ /**
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).
359
+ */
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;
390
+ },
391
+ update: async (props) => {
392
+ Object.assign(element, props);
393
+ await microtask();
394
+ },
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();
400
+ }
401
+ };
402
+ };
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 = [];
409
+ 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) => {
425
+ listeners.add(listener);
426
+ return () => void listeners.delete(listener);
427
+ }
428
+ });
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);
437
+ },
438
+ dispose: () => {
439
+ controller.abort();
440
+ if (options.container === void 0) container.remove();
441
+ }
442
+ };
443
+ };
444
+
445
+ //#endregion
446
+ export { createMemoryCommands, createMemoryEvents, createMemoryLibrary, createMemorySettings, createMemoryStorage, createSchemaValidator, loadCommands, loadEvents, loadExerciseType, loadGradePolicy, loadPanel, loadView };
package/package.json CHANGED
@@ -1,6 +1,41 @@
1
1
  {
2
2
  "name": "@dolphy-app/extension-sdk",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.3.0",
4
+ "description": "SDK for extension authors: defineExtension, defineAnswerView, test helpers",
5
+ "type": "module",
6
+ "sideEffects": false,
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "default": "./dist/index.js"
11
+ },
12
+ "./runtime": {
13
+ "types": "./dist/runtime.d.ts",
14
+ "default": "./dist/runtime.js"
15
+ },
16
+ "./testing": {
17
+ "types": "./dist/testing.d.ts",
18
+ "default": "./dist/testing.js"
19
+ }
20
+ },
21
+ "types": "./dist/index.d.ts",
22
+ "files": [
23
+ "dist"
24
+ ],
25
+ "dependencies": {
26
+ "@dolphy-app/extension-api": "0.3.0",
27
+ "ajv": "^8"
28
+ },
29
+ "engines": {
30
+ "node": ">=22.12"
31
+ },
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/dolphy-app/dolphy.git",
35
+ "directory": "packages/extension-sdk"
36
+ },
37
+ "publishConfig": {
38
+ "access": "public",
39
+ "registry": "https://registry.npmjs.org"
40
+ }
41
+ }