@iota-uz/sdk 0.13.0 → 0.15.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
@@ -11,6 +11,7 @@ subpath APIs are built from separate internal workspace modules:
11
11
  - `@iota-uz/sdk/lens`
12
12
  - `@iota-uz/sdk/lens/styles.css`
13
13
  - `@iota-uz/sdk/identity`
14
+ - `@iota-uz/sdk/testing` (Playwright Node process only; optional `@playwright/test` peer)
14
15
 
15
16
  Version 0.5 is a clean break: legacy root, BiChat, applet, Tailwind, and asset
16
17
  exports are intentionally absent. Solid is the standard client-route renderer;
@@ -26,3 +27,5 @@ compiled stylesheet once at their root:
26
27
  import { Button, Input } from '@iota-uz/sdk/solid-ui'
27
28
  import '@iota-uz/sdk/solid-ui/standalone.css'
28
29
  ```
30
+
31
+ Browser test projects import synchronization helpers and lifecycle/scenario fixture adapters from `@iota-uz/sdk/testing`. Helpers attach observers before actions and reject missing requests, HTMX refusal and missing readiness. Component hosts reuse explicit font/chart readiness while preserving existing approved screenshot baselines. The source workflow and adapter contracts are documented in `web/testing/README.md`.
@@ -1,9 +1,9 @@
1
- export declare const SDK_RELEASE_VERSION = "0.13.0";
2
- export declare const SDK_SOURCE_COMMIT = "469a4ea27a96fa0b7a3a6dc83cd10738157d6e1e";
1
+ export declare const SDK_RELEASE_VERSION = "0.15.0";
2
+ export declare const SDK_SOURCE_COMMIT = "9ceb321bfda838bf3c3333f6f465db1b9c81cf72";
3
3
  export declare const SDK_PROTOCOL_VERSION = "1.0.0";
4
4
  export declare const SDK_IDENTITY: Readonly<{
5
- releaseVersion: "0.13.0";
6
- sourceCommit: "469a4ea27a96fa0b7a3a6dc83cd10738157d6e1e";
5
+ releaseVersion: "0.15.0";
6
+ sourceCommit: "9ceb321bfda838bf3c3333f6f465db1b9c81cf72";
7
7
  protocolVersion: "1.0.0";
8
8
  }>;
9
9
  export interface SDKRuntimeIdentity {
@@ -1,6 +1,6 @@
1
1
  import { CLIENT_HOST_PROTOCOL_VERSION } from './protocol';
2
- export const SDK_RELEASE_VERSION = '0.13.0';
3
- export const SDK_SOURCE_COMMIT = '469a4ea27a96fa0b7a3a6dc83cd10738157d6e1e';
2
+ export const SDK_RELEASE_VERSION = '0.15.0';
3
+ export const SDK_SOURCE_COMMIT = '9ceb321bfda838bf3c3333f6f465db1b9c81cf72';
4
4
  export const SDK_PROTOCOL_VERSION = CLIENT_HOST_PROTOCOL_VERSION;
5
5
  export const SDK_IDENTITY = Object.freeze({
6
6
  releaseVersion: SDK_RELEASE_VERSION,
@@ -1,6 +1,6 @@
1
1
  'use strict'
2
- const SDK_RELEASE_VERSION = "0.13.0"
3
- const SDK_SOURCE_COMMIT = "469a4ea27a96fa0b7a3a6dc83cd10738157d6e1e"
2
+ const SDK_RELEASE_VERSION = "0.15.0"
3
+ const SDK_SOURCE_COMMIT = "9ceb321bfda838bf3c3333f6f465db1b9c81cf72"
4
4
  const SDK_PROTOCOL_VERSION = "1.0.0"
5
- const SDK_IDENTITY = Object.freeze({"releaseVersion":"0.13.0","sourceCommit":"469a4ea27a96fa0b7a3a6dc83cd10738157d6e1e","protocolVersion":"1.0.0"})
5
+ const SDK_IDENTITY = Object.freeze({"releaseVersion":"0.15.0","sourceCommit":"9ceb321bfda838bf3c3333f6f465db1b9c81cf72","protocolVersion":"1.0.0"})
6
6
  module.exports = { SDK_RELEASE_VERSION, SDK_SOURCE_COMMIT, SDK_PROTOCOL_VERSION, SDK_IDENTITY }
@@ -1,4 +1,4 @@
1
- export const SDK_RELEASE_VERSION = "0.13.0"
2
- export const SDK_SOURCE_COMMIT = "469a4ea27a96fa0b7a3a6dc83cd10738157d6e1e"
1
+ export const SDK_RELEASE_VERSION = "0.15.0"
2
+ export const SDK_SOURCE_COMMIT = "9ceb321bfda838bf3c3333f6f465db1b9c81cf72"
3
3
  export const SDK_PROTOCOL_VERSION = "1.0.0"
4
- export const SDK_IDENTITY = Object.freeze({"releaseVersion":"0.13.0","sourceCommit":"469a4ea27a96fa0b7a3a6dc83cd10738157d6e1e","protocolVersion":"1.0.0"})
4
+ export const SDK_IDENTITY = Object.freeze({"releaseVersion":"0.15.0","sourceCommit":"9ceb321bfda838bf3c3333f6f465db1b9c81cf72","protocolVersion":"1.0.0"})
@@ -1,5 +1,5 @@
1
1
  {
2
- "releaseVersion": "0.13.0",
3
- "sourceCommit": "469a4ea27a96fa0b7a3a6dc83cd10738157d6e1e",
2
+ "releaseVersion": "0.15.0",
3
+ "sourceCommit": "9ceb321bfda838bf3c3333f6f465db1b9c81cf72",
4
4
  "protocolVersion": "1.0.0"
5
5
  }
@@ -0,0 +1,21 @@
1
+ import type { EnvironmentDescriptor, EnvironmentLifecycle } from './index.js';
2
+ export type DriverDescriptor = EnvironmentDescriptor & {
3
+ environmentId: string;
4
+ buildRevision: string;
5
+ schemaFingerprint: string;
6
+ baselineFingerprint: string;
7
+ capabilities: readonly string[];
8
+ artifactDirectory: string;
9
+ };
10
+ /** One persistent, shell-free driver owns every environment it creates. */
11
+ export declare function createProcessEnvironmentDriver<Spec>(options: {
12
+ command: string;
13
+ args?: readonly string[];
14
+ cwd?: string;
15
+ env?: Record<string, string | undefined>;
16
+ spec(workerIndex: number): Spec;
17
+ timeoutMs?: number;
18
+ }): {
19
+ lifecycle: EnvironmentLifecycle<DriverDescriptor>;
20
+ close: () => Promise<void>;
21
+ };
@@ -0,0 +1,90 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { createInterface } from 'node:readline';
3
+ import { randomUUID } from 'node:crypto';
4
+ /** One persistent, shell-free driver owns every environment it creates. */
5
+ export function createProcessEnvironmentDriver(options) {
6
+ const timeoutMs = options.timeoutMs ?? 120_000;
7
+ if (!Number.isInteger(timeoutMs) || timeoutMs <= 0)
8
+ throw new Error('timeoutMs must be a positive integer');
9
+ const child = spawn(options.command, [...options.args ?? []], { cwd: options.cwd, env: options.env, stdio: ['pipe', 'pipe', 'pipe'] });
10
+ const pending = new Map();
11
+ let stderr = '';
12
+ let closed = false;
13
+ child.stderr.on('data', chunk => { stderr = (stderr + chunk).slice(-16_384); });
14
+ const rejectAll = (error) => {
15
+ closed = true;
16
+ for (const request of pending.values()) {
17
+ clearTimeout(request.timer);
18
+ request.reject(error);
19
+ }
20
+ pending.clear();
21
+ };
22
+ const exit = new Promise((resolve, reject) => {
23
+ child.once('error', error => { rejectAll(error); reject(error); });
24
+ child.once('exit', (code, signal) => {
25
+ const error = new Error(`Test environment driver exited (${code ?? signal}): ${stderr}`);
26
+ rejectAll(error);
27
+ if (code === 0)
28
+ resolve();
29
+ else
30
+ reject(error);
31
+ });
32
+ });
33
+ // Keep unexpected process exits observed even before the caller closes the driver.
34
+ void exit.catch(() => { });
35
+ const lines = createInterface({ input: child.stdout });
36
+ lines.on('line', line => {
37
+ let response;
38
+ try {
39
+ response = JSON.parse(line);
40
+ }
41
+ catch {
42
+ rejectAll(new Error('Test environment driver emitted invalid JSON'));
43
+ child.kill('SIGINT');
44
+ return;
45
+ }
46
+ const request = pending.get(response.id);
47
+ if (!request)
48
+ return;
49
+ pending.delete(response.id);
50
+ clearTimeout(request.timer);
51
+ if (response.error)
52
+ request.reject(new Error(`${response.error.code}: ${response.error.message}`));
53
+ else
54
+ request.resolve(response.descriptor);
55
+ });
56
+ const invoke = (request) => {
57
+ if (closed)
58
+ return Promise.reject(new Error('Test environment driver is closed'));
59
+ const id = randomUUID();
60
+ return new Promise((resolve, reject) => {
61
+ const timer = setTimeout(() => {
62
+ pending.delete(id);
63
+ reject(new Error('Test environment driver request timed out'));
64
+ child.kill('SIGINT');
65
+ }, timeoutMs);
66
+ pending.set(id, { resolve, reject, timer });
67
+ child.stdin.write(`${JSON.stringify({ id, ...request })}\n`, error => {
68
+ if (error) {
69
+ clearTimeout(timer);
70
+ pending.delete(id);
71
+ reject(error);
72
+ }
73
+ });
74
+ });
75
+ };
76
+ const lifecycle = {
77
+ start: async (workerIndex) => {
78
+ const descriptor = await invoke({ operation: 'start', spec: options.spec(workerIndex) });
79
+ if (!descriptor?.environmentId || !descriptor?.baseURL || !descriptor?.artifactDirectory) {
80
+ child.kill('SIGINT');
81
+ throw new Error('Incomplete test environment descriptor');
82
+ }
83
+ return { ...descriptor, runId: descriptor.environmentId, artifactDir: descriptor.artifactDirectory };
84
+ },
85
+ // Driver start returns only after the configured real readiness probe succeeds.
86
+ ready: async () => { },
87
+ stop: async (environment) => { await invoke({ operation: 'stop', environmentId: environment.environmentId }); },
88
+ };
89
+ return { lifecycle, close: async () => { child.stdin.end(); await exit; lines.close(); } };
90
+ }
@@ -0,0 +1,13 @@
1
+ import type { Browser, PlaywrightTestArgs, PlaywrightTestOptions, PlaywrightWorkerArgs, PlaywrightWorkerOptions, TestType } from '@playwright/test';
2
+ import { type EnvironmentDescriptor, type EnvironmentLifecycle, type ScenarioControl, type ScenarioLease, type IdentityLease } from './index.js';
3
+ export declare function createEnvironmentTest<E extends EnvironmentDescriptor, Input, State, Key, Identity>(base: TestType<PlaywrightTestArgs & PlaywrightTestOptions, PlaywrightWorkerArgs & PlaywrightWorkerOptions>, options: {
4
+ lifecycle: EnvironmentLifecycle<E>;
5
+ scenarios(environment: E): ScenarioControl<Input, State>;
6
+ identity(environment: E, browser: Browser, key: Key): Promise<Identity>;
7
+ }): TestType<PlaywrightTestArgs & PlaywrightTestOptions & {
8
+ scenario: ScenarioLease<Input, State>;
9
+ identity: IdentityLease<Key, Identity>;
10
+ }, PlaywrightWorkerArgs & PlaywrightWorkerOptions & {
11
+ environment: E;
12
+ workerIdentity: IdentityLease<Key, Identity>;
13
+ }>;
@@ -0,0 +1,30 @@
1
+ import { lazyIdentities, withEnvironment } from './index.js';
2
+ export function createEnvironmentTest(base, options) {
3
+ return base.extend({
4
+ environment: [async ({}, use, info) => {
5
+ await withEnvironment(options.lifecycle, info.workerIndex, use);
6
+ }, { scope: 'worker' }],
7
+ workerIdentity: [async ({ environment, browser }, use) => {
8
+ await use(lazyIdentities(key => options.identity(environment, browser, key)));
9
+ }, { scope: 'worker' }],
10
+ baseURL: async ({ environment }, use) => { await use(environment.baseURL); },
11
+ identity: async ({ workerIdentity }, use) => { await use(workerIdentity); },
12
+ scenario: async ({ environment }, use, info) => {
13
+ const control = options.scenarios(environment);
14
+ const scopes = [];
15
+ try {
16
+ await use(async (input) => {
17
+ const scope = `${environment.runId}-w${info.workerIndex}-r${info.retry}-${crypto.randomUUID()}`;
18
+ scopes.push(scope);
19
+ return control.prepare(input, scope);
20
+ });
21
+ }
22
+ finally {
23
+ const results = await Promise.allSettled(scopes.map(scope => control.dispose(scope)));
24
+ const errors = results.filter((result) => result.status === 'rejected').map(result => result.reason);
25
+ if (errors.length)
26
+ throw new AggregateError(errors, 'Scenario teardown failed');
27
+ }
28
+ },
29
+ });
30
+ }
@@ -0,0 +1,64 @@
1
+ import type { Locator, Page, Response } from '@playwright/test';
2
+ export type WaitOptions = {
3
+ timeoutMs?: number;
4
+ };
5
+ export type RequestMatch = {
6
+ method: string;
7
+ pathname: string;
8
+ };
9
+ export type ResponseMatch = RequestMatch | ((response: Response) => boolean);
10
+ export type ValidationResult = {
11
+ controlName: string;
12
+ message: string;
13
+ };
14
+ export type ComponentCase = {
15
+ id: string;
16
+ state: string;
17
+ locale: string;
18
+ theme: 'light' | 'dark';
19
+ viewport: {
20
+ width: number;
21
+ height: number;
22
+ };
23
+ };
24
+ export declare function actionAndResponse(page: Page, match: ResponseMatch, action: () => Promise<unknown>, options?: WaitOptions): Promise<Response>;
25
+ /** Installs the event observer before the action and follows replacement targets by selector. */
26
+ export declare function actionAndHtmxSettled(page: Page, target: string, action: () => Promise<unknown>, options?: WaitOptions): Promise<void>;
27
+ export declare function actionAndValidationRefusal(page: Page, form: Locator, action: () => Promise<unknown>): Promise<ValidationResult>;
28
+ export declare function expectComponentReady(page: Page, root: Locator, options?: WaitOptions & {
29
+ readinessAttribute?: string;
30
+ }): Promise<void>;
31
+ export interface ComponentHost {
32
+ cases(): Promise<readonly ComponentCase[]>;
33
+ navigate(page: Page, componentCase: ComponentCase): Promise<Locator>;
34
+ }
35
+ export declare function openComponentCase(page: Page, host: ComponentHost, componentCase: ComponentCase, options?: WaitOptions): Promise<Locator>;
36
+ export declare function actionAndHtmxResponse(page: Page, match: ResponseMatch, action: () => Promise<unknown>, options?: WaitOptions): Promise<Response>;
37
+ export interface EnvironmentDescriptor {
38
+ runId: string;
39
+ baseURL: string;
40
+ artifactDir: string;
41
+ }
42
+ export interface EnvironmentLifecycle<E extends EnvironmentDescriptor> {
43
+ start(workerIndex: number): Promise<E>;
44
+ ready(environment: E): Promise<void>;
45
+ stop(environment: E): Promise<void>;
46
+ }
47
+ export interface ScenarioControl<Input, Result> {
48
+ prepare(input: Input, scopeId: string): Promise<Result>;
49
+ dispose(scopeId: string): Promise<void>;
50
+ }
51
+ /** Teardown runs even when readiness, the scenario or the browser assertion fails. */
52
+ export declare function withEnvironment<E extends EnvironmentDescriptor, Result>(lifecycle: EnvironmentLifecycle<E>, workerIndex: number, use: (environment: E) => Promise<Result>): Promise<Result>;
53
+ export declare function withScenario<Input, State, Result>(control: ScenarioControl<Input, State>, input: Input, scopeId: string, use: (state: State) => Promise<Result>): Promise<Result>;
54
+ /** Instantiate per worker/environment; identities are materialised only when requested. */
55
+ export declare function lazyIdentities<Key, State>(create: (identity: Key) => Promise<State>): (identity: Key) => Promise<State>;
56
+ export type ScenarioLease<Input, State> = (input: Input) => Promise<State>;
57
+ export type IdentityLease<Key, State> = (identity: Key) => Promise<State>;
58
+ export { createEnvironmentTest } from './fixtures.js';
59
+ export { createProcessEnvironmentDriver, type DriverDescriptor } from './driver.js';
60
+ export declare function expectFontsReady(page: Page, options?: WaitOptions): Promise<void>;
61
+ export declare function expectChartsReady(charts: Locator, options?: WaitOptions & {
62
+ allowEmpty?: boolean;
63
+ }): Promise<void>;
64
+ export { createHttpScenarioControl, ScenarioControlError, type ScenarioInput, type ScenarioResult } from './scenarios.js';
@@ -0,0 +1,253 @@
1
+ function timeout(options = {}) {
2
+ const value = options.timeoutMs ?? 10_000;
3
+ if (!Number.isInteger(value) || value <= 0)
4
+ throw new Error('timeoutMs must be a positive integer');
5
+ return value;
6
+ }
7
+ export async function actionAndResponse(page, match, action, options = {}) {
8
+ const response = page.waitForResponse(r => typeof match === 'function' ? match(r) : r.request().method() === match.method.toUpperCase() && new URL(r.url()).pathname === match.pathname, { timeout: timeout(options) });
9
+ const [result] = await Promise.all([response, action()]);
10
+ return result;
11
+ }
12
+ /** Installs the event observer before the action and follows replacement targets by selector. */
13
+ export async function actionAndHtmxSettled(page, target, action, options = {}) {
14
+ const key = `sdk-htmx-${crypto.randomUUID()}`;
15
+ await page.evaluate(({ key, target }) => {
16
+ const state = { pending: 0, swapped: false, settled: false, failed: false };
17
+ const requests = new WeakSet();
18
+ const matches = (event) => {
19
+ const detail = event.detail;
20
+ const element = detail?.target ?? detail?.elt;
21
+ return element instanceof Element && (element.matches(target) || Boolean(element.closest(target)));
22
+ };
23
+ const before = (event) => {
24
+ const detail = event.detail;
25
+ if (matches(event) && detail?.xhr && !requests.has(detail.xhr)) {
26
+ requests.add(detail.xhr);
27
+ state.pending++;
28
+ state.settled = false;
29
+ }
30
+ };
31
+ const correlated = (event) => { const xhr = event.detail?.xhr; return xhr && requests.has(xhr); };
32
+ const swap = (event) => { if (correlated(event))
33
+ state.swapped = true; };
34
+ const settle = (event) => { if (correlated(event) && state.swapped) {
35
+ requests.delete(event.detail.xhr);
36
+ state.pending--;
37
+ state.settled = state.pending === 0;
38
+ } };
39
+ const error = (event) => { if (correlated(event))
40
+ state.failed = true; };
41
+ document.addEventListener('htmx:beforeRequest', before);
42
+ document.addEventListener('htmx:afterSwap', swap);
43
+ document.addEventListener('htmx:afterSettle', settle);
44
+ document.addEventListener('htmx:responseError', error);
45
+ window[key] = { state, dispose: () => {
46
+ document.removeEventListener('htmx:beforeRequest', before);
47
+ document.removeEventListener('htmx:afterSwap', swap);
48
+ document.removeEventListener('htmx:afterSettle', settle);
49
+ document.removeEventListener('htmx:responseError', error);
50
+ } };
51
+ }, { key, target });
52
+ try {
53
+ await action();
54
+ await page.waitForFunction(key => window[key].state.settled || window[key].state.failed, key, { timeout: timeout(options) });
55
+ const failed = await page.evaluate(key => window[key].state.failed, key);
56
+ if (failed)
57
+ throw new Error(`HTMX response failed for ${target}`);
58
+ await page.locator(target).waitFor({ state: 'visible', timeout: timeout(options) });
59
+ }
60
+ finally {
61
+ await page.evaluate(key => { window[key]?.dispose(); delete window[key]; }, key);
62
+ }
63
+ }
64
+ export async function actionAndValidationRefusal(page, form, action) {
65
+ const key = `sdk-validation-${crypto.randomUUID()}`;
66
+ await form.evaluate((element, key) => {
67
+ const state = { invalid: null, submitted: false };
68
+ const invalid = (event) => {
69
+ const control = event.target;
70
+ state.invalid ??= { controlName: control.name, message: control.validationMessage };
71
+ };
72
+ const submit = () => { state.submitted = true; };
73
+ element.addEventListener('invalid', invalid, true);
74
+ element.addEventListener('submit', submit);
75
+ window[key] = { state, dispose: () => { element.removeEventListener('invalid', invalid, true); element.removeEventListener('submit', submit); } };
76
+ }, key);
77
+ const submission = await form.evaluate(element => {
78
+ const f = element;
79
+ return { url: new URL(f.getAttribute('hx-post') ?? f.action, location.href).href, method: f.hasAttribute('hx-post') ? 'POST' : f.method.toUpperCase() };
80
+ });
81
+ let submittedRequest = false;
82
+ const observe = (request) => {
83
+ if (request.url().split('?')[0] === submission.url.split('?')[0] && request.method() === submission.method)
84
+ submittedRequest = true;
85
+ };
86
+ page.on('request', observe);
87
+ try {
88
+ await action();
89
+ if (submittedRequest)
90
+ throw new Error('Native validation unexpectedly issued a submission request');
91
+ return await page.evaluate(key => {
92
+ const state = window[key].state;
93
+ if (!state.invalid || state.submitted)
94
+ throw new Error('Expected native validation refusal without submission');
95
+ return state.invalid;
96
+ }, key);
97
+ }
98
+ finally {
99
+ page.off('request', observe);
100
+ await page.evaluate(key => { window[key]?.dispose(); delete window[key]; }, key);
101
+ }
102
+ }
103
+ export async function expectComponentReady(page, root, options = {}) {
104
+ await waitForCondition(async () => await root.getAttribute(options.readinessAttribute ?? 'data-component-ready') === 'true', timeout(options), 'Component readiness marker missing');
105
+ await page.waitForFunction(() => document.fonts.status === 'loaded', undefined, { timeout: timeout(options) });
106
+ await root.waitFor({ state: 'visible', timeout: timeout(options) });
107
+ }
108
+ export async function openComponentCase(page, host, componentCase, options = {}) {
109
+ await page.setViewportSize(componentCase.viewport);
110
+ const root = await host.navigate(page, componentCase);
111
+ await expectComponentReady(page, root, options);
112
+ return root;
113
+ }
114
+ export async function actionAndHtmxResponse(page, match, action, options = {}) {
115
+ const key = `sdk-response-${crypto.randomUUID()}`;
116
+ await page.evaluate(key => {
117
+ const records = [];
118
+ const requests = new WeakMap();
119
+ const observed = new Set();
120
+ const before = (event) => {
121
+ const d = event.detail;
122
+ if (!d?.xhr)
123
+ return;
124
+ const record = { url: new URL(d.requestConfig.path, location.href).href, method: String(d.requestConfig.verb).toUpperCase(), status: 0, settled: false, failed: false, aborted: false };
125
+ records.push(record);
126
+ requests.set(d.xhr, record);
127
+ // A removed swap target no longer bubbles its lifecycle events to document.
128
+ for (const element of [d.elt, d.target]) {
129
+ if (!(element instanceof Element) || observed.has(element))
130
+ continue;
131
+ observed.add(element);
132
+ element.addEventListener('htmx:afterSettle', update);
133
+ element.addEventListener('htmx:responseError', update);
134
+ element.addEventListener('htmx:sendAbort', update);
135
+ element.addEventListener('htmx:sendError', update);
136
+ }
137
+ };
138
+ const update = (event) => {
139
+ const d = event.detail;
140
+ const record = d?.xhr && requests.get(d.xhr);
141
+ if (!record)
142
+ return;
143
+ record.url = d.xhr.responseURL || record.url;
144
+ record.status = d.xhr.status;
145
+ record.failed = event.type === 'htmx:responseError' || event.type === 'htmx:sendError';
146
+ record.aborted = event.type === 'htmx:sendAbort';
147
+ record.settled = event.type === 'htmx:afterSettle';
148
+ };
149
+ document.addEventListener('htmx:beforeRequest', before);
150
+ document.addEventListener('htmx:afterSettle', update);
151
+ document.addEventListener('htmx:responseError', update);
152
+ document.addEventListener('htmx:sendAbort', update);
153
+ document.addEventListener('htmx:sendError', update);
154
+ window[key] = { records, dispose: () => {
155
+ document.removeEventListener('htmx:beforeRequest', before);
156
+ document.removeEventListener('htmx:afterSettle', update);
157
+ document.removeEventListener('htmx:responseError', update);
158
+ document.removeEventListener('htmx:sendAbort', update);
159
+ document.removeEventListener('htmx:sendError', update);
160
+ for (const element of observed) {
161
+ element.removeEventListener('htmx:afterSettle', update);
162
+ element.removeEventListener('htmx:responseError', update);
163
+ element.removeEventListener('htmx:sendAbort', update);
164
+ element.removeEventListener('htmx:sendError', update);
165
+ }
166
+ } };
167
+ }, key);
168
+ try {
169
+ const response = await actionAndResponse(page, match, action, options);
170
+ const identity = { url: response.url(), method: response.request().method(), status: response.status() };
171
+ await page.waitForFunction(({ key, identity }) => {
172
+ const records = window[key].records.filter((r) => r.url === identity.url && r.method === identity.method);
173
+ return records.some((r) => !r.aborted && (r.settled || r.failed)) && records.every((r) => r.settled || r.failed || r.aborted);
174
+ }, { key, identity }, { timeout: timeout(options) });
175
+ const failure = await page.evaluate(({ key, identity }) => {
176
+ const records = window[key].records.filter((r) => r.url === identity.url && r.method === identity.method && !r.aborted);
177
+ const latest = records.at(-1);
178
+ return latest?.failed ? latest.status : null;
179
+ }, { key, identity });
180
+ if (failure !== null)
181
+ throw new Error(`HTMX request failed: ${failure} ${response.url()}`);
182
+ if (!response.ok())
183
+ throw new Error(`HTMX request failed: ${response.status()} ${response.url()}`);
184
+ return response;
185
+ }
186
+ finally {
187
+ await page.evaluate(key => { window[key]?.dispose(); delete window[key]; }, key);
188
+ }
189
+ }
190
+ /** Teardown runs even when readiness, the scenario or the browser assertion fails. */
191
+ export async function withEnvironment(lifecycle, workerIndex, use) {
192
+ const environment = await lifecycle.start(workerIndex);
193
+ let failed = false;
194
+ let failure;
195
+ try {
196
+ await lifecycle.ready(environment);
197
+ return await use(environment);
198
+ }
199
+ catch (error) {
200
+ failed = true;
201
+ failure = error;
202
+ throw error;
203
+ }
204
+ finally {
205
+ try {
206
+ await lifecycle.stop(environment);
207
+ }
208
+ catch (error) {
209
+ if (failed)
210
+ throw new AggregateError([failure, error], 'Environment failed and teardown failed');
211
+ throw error;
212
+ }
213
+ }
214
+ }
215
+ export async function withScenario(control, input, scopeId, use) {
216
+ try {
217
+ return await use(await control.prepare(input, scopeId));
218
+ }
219
+ finally {
220
+ await control.dispose(scopeId);
221
+ }
222
+ }
223
+ /** Instantiate per worker/environment; identities are materialised only when requested. */
224
+ export function lazyIdentities(create) {
225
+ const identities = new Map();
226
+ return identity => {
227
+ let state = identities.get(identity);
228
+ if (!state) {
229
+ state = create(identity).catch(error => { identities.delete(identity); throw error; });
230
+ identities.set(identity, state);
231
+ }
232
+ return state;
233
+ };
234
+ }
235
+ export { createEnvironmentTest } from './fixtures.js';
236
+ export { createProcessEnvironmentDriver } from './driver.js';
237
+ export async function expectFontsReady(page, options = {}) {
238
+ await page.waitForFunction(() => document.fonts.status === 'loaded', undefined, { timeout: timeout(options) });
239
+ }
240
+ export async function expectChartsReady(charts, options = {}) {
241
+ await waitForCondition(async () => charts.evaluateAll((elements, allowEmpty) => (allowEmpty || elements.length > 0) && elements.every(element => element.getAttribute('data-chart-ready') === 'true'), options.allowEmpty ?? false), timeout(options), 'Chart readiness missing');
242
+ if (!options.allowEmpty)
243
+ await charts.first().waitFor({ state: 'visible', timeout: timeout(options) });
244
+ }
245
+ async function waitForCondition(condition, timeoutMs, diagnostic) {
246
+ const deadline = Date.now() + timeoutMs;
247
+ while (!(await condition())) {
248
+ if (Date.now() >= deadline)
249
+ throw new Error(diagnostic);
250
+ await new Promise(resolve => setTimeout(resolve, Math.min(16, Math.max(1, deadline - Date.now()))));
251
+ }
252
+ }
253
+ export { createHttpScenarioControl, ScenarioControlError } from './scenarios.js';
@@ -0,0 +1,29 @@
1
+ import type { ScenarioControl, WaitOptions } from './index.js';
2
+ export type ScenarioInput = {
3
+ name: string;
4
+ version: string;
5
+ seed: string;
6
+ now: string;
7
+ params: Record<string, unknown>;
8
+ };
9
+ export type ScenarioResult<Data = Record<string, unknown>> = {
10
+ scopeId: string;
11
+ name: string;
12
+ version: string;
13
+ seed: string;
14
+ now: string;
15
+ refs: Record<string, {
16
+ kind: string;
17
+ id: string;
18
+ }>;
19
+ data: Data;
20
+ };
21
+ export declare class ScenarioControlError extends Error {
22
+ readonly code: string;
23
+ readonly status: number;
24
+ constructor(code: string, status: number, message: string);
25
+ }
26
+ export declare function createHttpScenarioControl<Data = Record<string, unknown>>(environment: {
27
+ environmentId: string;
28
+ baseURL: string;
29
+ }, token: string, options?: WaitOptions): ScenarioControl<ScenarioInput, ScenarioResult<Data>>;
@@ -0,0 +1,38 @@
1
+ export class ScenarioControlError extends Error {
2
+ code;
3
+ status;
4
+ constructor(code, status, message) {
5
+ super(message);
6
+ this.code = code;
7
+ this.status = status;
8
+ this.name = 'ScenarioControlError';
9
+ }
10
+ }
11
+ export function createHttpScenarioControl(environment, token, options = {}) {
12
+ const timeoutMs = options.timeoutMs ?? 10_000;
13
+ if (!Number.isInteger(timeoutMs) || timeoutMs <= 0 || token.length < 32)
14
+ throw new Error('A positive timeout and a control credential of at least 32 characters are required');
15
+ const invoke = async (pathname, method, body) => {
16
+ const response = await fetch(new URL(pathname, environment.baseURL), { method, redirect: 'manual', signal: AbortSignal.timeout(timeoutMs),
17
+ headers: { Authorization: `Bearer ${token}`, ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) }, body: body === undefined ? undefined : JSON.stringify(body) });
18
+ if (!response.ok) {
19
+ const text = await response.text();
20
+ let error = {};
21
+ try {
22
+ error = JSON.parse(text);
23
+ }
24
+ catch { }
25
+ throw new ScenarioControlError(error.code ?? `http_${response.status}`, response.status, error.message ?? `Scenario control returned HTTP ${response.status}`);
26
+ }
27
+ if (response.headers.get('X-Test-Environment-ID') !== environment.environmentId)
28
+ throw new ScenarioControlError('environment_mismatch', response.status, 'Scenario endpoint belongs to another environment');
29
+ return response.json();
30
+ };
31
+ return {
32
+ prepare: async (input, scopeId) => {
33
+ await invoke('/__test__/scopes', 'POST', { scopeId });
34
+ return await invoke('/__test__/scenarios/prepare', 'POST', { ...input, scopeId });
35
+ },
36
+ dispose: async (scopeId) => { await invoke(`/__test__/scopes/${encodeURIComponent(scopeId)}`, 'DELETE'); },
37
+ };
38
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iota-uz/sdk",
3
- "version": "0.13.0",
3
+ "version": "0.15.0",
4
4
  "description": "Canonical JavaScript distribution for iota-sdk.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -49,12 +49,17 @@
49
49
  "types": "./dist/lens/index.d.ts",
50
50
  "import": "./dist/lens/index.js"
51
51
  },
52
- "./lens/styles.css": "./dist/lens/style.css"
52
+ "./lens/styles.css": "./dist/lens/style.css",
53
+ "./testing": {
54
+ "types": "./dist/testing/index.d.ts",
55
+ "import": "./dist/testing/index.js"
56
+ }
53
57
  },
54
58
  "peerDependencies": {
55
59
  "react": "^18.3.0 || ^19.0.0",
56
60
  "react-dom": "^18.3.0 || ^19.0.0",
57
- "solid-js": "^1.9.0"
61
+ "solid-js": "^1.9.0",
62
+ "@playwright/test": "^1.55.0"
58
63
  },
59
64
  "peerDependenciesMeta": {
60
65
  "react": {
@@ -65,6 +70,9 @@
65
70
  },
66
71
  "solid-js": {
67
72
  "optional": true
73
+ },
74
+ "@playwright/test": {
75
+ "optional": true
68
76
  }
69
77
  }
70
78
  }