@theaiplatform/miniapp-sdk 0.3.0 → 0.3.1

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,16 +1,16 @@
1
1
  # The AI Platform Miniapp SDK
2
2
 
3
- `@theaiplatform/miniapp-sdk` is the public package for building portable
4
- miniapps that run in The AI Platform.
3
+ `@theaiplatform/miniapp-sdk` is the TypeScript SDK for building, testing, and
4
+ shipping portable miniapps on The AI Platform.
5
5
 
6
6
  ```bash
7
7
  pnpm add @theaiplatform/miniapp-sdk
8
8
  ```
9
9
 
10
10
  The package provides the host-injected SDK API, isolated surface lifecycle
11
- types, browser helpers, portable React UI components, manifest schema, and the
12
- Rspack/Rslib integration used to produce descriptor-backed Module Federation
13
- targets.
11
+ types, browser helpers, portable React UI components, manifest schema,
12
+ Rstest/Playwright integration, and the Rspack/Rslib integration used to produce
13
+ descriptor-backed Module Federation targets.
14
14
 
15
15
  Start with the [Miniapp SDK documentation](https://docs.theaiplatform.app/miniapps/).
16
16
 
@@ -27,8 +27,28 @@ Start with the [Miniapp SDK documentation](https://docs.theaiplatform.app/miniap
27
27
  - `@theaiplatform/miniapp-sdk/surface`
28
28
  - `@theaiplatform/miniapp-sdk/config`
29
29
  - `@theaiplatform/miniapp-sdk/rspack`
30
+ - `@theaiplatform/miniapp-sdk/testing/rstest`
30
31
  - `@theaiplatform/miniapp-sdk/config-schema.json`
31
32
 
33
+ ## In-app E2E testing in 0.3.1
34
+
35
+ The `/testing/rstest` entry point extends Rstest's Playwright fixtures with a
36
+ host-provided miniapp surface and exact TAP session provenance. Tests can assert
37
+ both the rendered UI and the package/surface identity selected by the Test Lab:
38
+
39
+ ```ts
40
+ import { expect, test } from '@theaiplatform/miniapp-sdk/testing/rstest';
41
+
42
+ test('mounts the miniapp surface', async ({ surface, tap }) => {
43
+ await expect(surface.locator('body')).toBeVisible();
44
+ expect(tap.packageId).toBe('tap_pkg_example_0001');
45
+ expect(tap.surfaceId).toBe('example-surface');
46
+ });
47
+ ```
48
+
49
+ Install `@rstest/core@0.11.3`, `@rstest/playwright@0.11.3`, and Playwright as
50
+ development dependencies when using this optional entry point.
51
+
32
52
  The host API is injected at runtime. Importing an entry point is safe in build
33
53
  tools and tests; host-backed calls fail when used outside a supported runtime.
34
54
  `sdk.storage` provides revisioned non-secret JSON scoped by the host to the
@@ -0,0 +1,131 @@
1
+ import { afterAll } from '@rstest/playwright';
2
+ import { afterEach } from '@rstest/playwright';
3
+ import { APIRequestContext } from 'playwright';
4
+ import { beforeAll } from '@rstest/playwright';
5
+ import { beforeEach } from '@rstest/playwright';
6
+ import type { Browser } from 'playwright';
7
+ import type { BrowserContext } from 'playwright';
8
+ import { describe } from '@rstest/playwright';
9
+ import { expect } from '@rstest/playwright';
10
+ import type { FrameLocator } from 'playwright';
11
+ import type { Page } from 'playwright';
12
+ import { PlaywrightOptions } from '@rstest/playwright';
13
+ import { PlaywrightServe } from '@rstest/playwright';
14
+ import { PlaywrightTest } from '@rstest/playwright';
15
+ import { z } from 'zod';
16
+
17
+ export { afterAll }
18
+
19
+ export { afterEach }
20
+
21
+ export { beforeAll }
22
+
23
+ export { beforeEach }
24
+
25
+ export { describe }
26
+
27
+ export { expect }
28
+
29
+ /** Validate a driver session before any endpoint or provenance field is used. */
30
+ export declare function parseTapMiniappTestSession(value: unknown): TapMiniappTestSession;
31
+
32
+ declare const sessionSchema: z.ZodObject<{
33
+ schemaVersion: z.ZodLiteral<1>;
34
+ runId: z.ZodString;
35
+ workspaceId: z.ZodString;
36
+ channelId: z.ZodString;
37
+ packageId: z.ZodString;
38
+ installationId: z.ZodString;
39
+ mode: z.ZodEnum<{
40
+ surface: "surface";
41
+ "live-tap": "live-tap";
42
+ }>;
43
+ dataScope: z.ZodEnum<{
44
+ fixture: "fixture";
45
+ "selected-channel": "selected-channel";
46
+ }>;
47
+ permissionScenario: z.ZodString;
48
+ surfaceId: z.ZodString;
49
+ surfaceSelector: z.ZodString;
50
+ surfaceAssetOrigin: z.ZodString;
51
+ pageUrlPrefix: z.ZodString;
52
+ cdpEndpoint: z.ZodString;
53
+ cdpAuthorization: z.ZodString;
54
+ artifactDirectory: z.ZodString;
55
+ allowedNetworkOrigins: z.ZodArray<z.ZodString>;
56
+ credentialAliases: z.ZodArray<z.ZodString>;
57
+ releaseDigest: z.ZodOptional<z.ZodString>;
58
+ localGeneration: z.ZodOptional<z.ZodNumber>;
59
+ sourceDigest: z.ZodString;
60
+ testBundleDigest: z.ZodString;
61
+ }, z.core.$strip>;
62
+
63
+ export declare type TapMiniappTestControl = {
64
+ /** Restore the run's declared fixture and permission scenario. */
65
+ reset: () => Promise<void>;
66
+ /** Ask the TAP host to emit one versioned platform event through the normal frame bridge. */
67
+ emitHostEvent: (event: string, payload?: unknown) => Promise<void>;
68
+ };
69
+
70
+ export declare type TapMiniappTestFixture = Readonly<Pick<TapMiniappTestSession, 'channelId' | 'dataScope' | 'installationId' | 'localGeneration' | 'mode' | 'packageId' | 'permissionScenario' | 'releaseDigest' | 'runId' | 'sourceDigest' | 'surfaceId' | 'surfaceAssetOrigin' | 'testBundleDigest' | 'workspaceId'> & {
71
+ readonly allowedNetworkOrigins: readonly string[];
72
+ readonly credentialAliases: readonly string[];
73
+ control: TapMiniappTestControl;
74
+ }>;
75
+
76
+ export declare type TapMiniappTestSession = z.infer<typeof sessionSchema>;
77
+
78
+ export declare type TapRstestFixtures = {
79
+ browser: Browser;
80
+ context: BrowserContext;
81
+ page: Page;
82
+ /** Locator for the authorized miniapp iframe mounted by TAP's real frame host. */
83
+ surface: FrameLocator;
84
+ /** Provenance and scoped host controls for this exact test run. */
85
+ tap: TapMiniappTestFixture;
86
+ };
87
+
88
+ /**
89
+ * Rstest's Playwright test API with TAP-owned browser, page, iframe, and run
90
+ * fixtures. The upstream runner still owns scheduling, assertions, hooks, and
91
+ * reporting; TAP owns the privileged browser/session boundary.
92
+ */
93
+ export declare const test: PlaywrightTest< {
94
+ browser: Browser;
95
+ context: BrowserContext;
96
+ request: APIRequestContext;
97
+ surface: FrameLocator;
98
+ page: Page;
99
+ tap: Readonly<Pick<{
100
+ schemaVersion: 1;
101
+ runId: string;
102
+ workspaceId: string;
103
+ channelId: string;
104
+ packageId: string;
105
+ installationId: string;
106
+ mode: "surface" | "live-tap";
107
+ dataScope: "fixture" | "selected-channel";
108
+ permissionScenario: string;
109
+ surfaceId: string;
110
+ surfaceSelector: string;
111
+ surfaceAssetOrigin: string;
112
+ pageUrlPrefix: string;
113
+ cdpEndpoint: string;
114
+ cdpAuthorization: string;
115
+ artifactDirectory: string;
116
+ allowedNetworkOrigins: string[];
117
+ credentialAliases: string[];
118
+ sourceDigest: string;
119
+ testBundleDigest: string;
120
+ releaseDigest?: string | undefined;
121
+ localGeneration?: number | undefined;
122
+ }, "packageId" | "installationId" | "workspaceId" | "channelId" | "mode" | "runId" | "dataScope" | "permissionScenario" | "surfaceId" | "surfaceAssetOrigin" | "releaseDigest" | "localGeneration" | "sourceDigest" | "testBundleDigest"> & {
123
+ readonly allowedNetworkOrigins: readonly string[];
124
+ readonly credentialAliases: readonly string[];
125
+ control: TapMiniappTestControl;
126
+ }>;
127
+ playwright: PlaywrightOptions;
128
+ serve: PlaywrightServe;
129
+ }>;
130
+
131
+ export { }
@@ -0,0 +1,241 @@
1
+ import * as __rspack_external__rstest_playwright_5734ff29 from "@rstest/playwright";
2
+ import * as __rspack_external_node_fs_promises_3b710708 from "node:fs/promises";
3
+ import * as __rspack_external_node_path_806ed179 from "node:path";
4
+ import * as __rspack_external_playwright from "playwright";
5
+ import * as __rspack_external_zod from "zod";
6
+ const SESSION_ENV = 'TAP_MINIAPP_TEST_SESSION_FILE';
7
+ const sessionSchema = __rspack_external_zod.z.object({
8
+ schemaVersion: __rspack_external_zod.z.literal(1),
9
+ runId: __rspack_external_zod.z.string().min(1),
10
+ workspaceId: __rspack_external_zod.z.string().min(1),
11
+ channelId: __rspack_external_zod.z.string().min(1),
12
+ packageId: __rspack_external_zod.z.string().min(1),
13
+ installationId: __rspack_external_zod.z.string().min(1),
14
+ mode: __rspack_external_zod.z["enum"]([
15
+ 'surface',
16
+ 'live-tap'
17
+ ]),
18
+ dataScope: __rspack_external_zod.z["enum"]([
19
+ 'fixture',
20
+ 'selected-channel'
21
+ ]),
22
+ permissionScenario: __rspack_external_zod.z.string().min(1),
23
+ surfaceId: __rspack_external_zod.z.string().min(1),
24
+ surfaceSelector: __rspack_external_zod.z.string().min(1),
25
+ surfaceAssetOrigin: __rspack_external_zod.z.string().url(),
26
+ pageUrlPrefix: __rspack_external_zod.z.string().url(),
27
+ cdpEndpoint: __rspack_external_zod.z.string().url(),
28
+ cdpAuthorization: __rspack_external_zod.z.string().min(1),
29
+ artifactDirectory: __rspack_external_zod.z.string().min(1),
30
+ allowedNetworkOrigins: __rspack_external_zod.z.array(__rspack_external_zod.z.string().url()).max(128),
31
+ credentialAliases: __rspack_external_zod.z.array(__rspack_external_zod.z.string().min(1).max(256)).max(64),
32
+ releaseDigest: __rspack_external_zod.z.string().min(1).optional(),
33
+ localGeneration: __rspack_external_zod.z.number().int().nonnegative().optional(),
34
+ sourceDigest: __rspack_external_zod.z.string().min(1),
35
+ testBundleDigest: __rspack_external_zod.z.string().min(1)
36
+ });
37
+ let sessionPromise;
38
+ let browserPromise;
39
+ function parseTapMiniappTestSession(value) {
40
+ return sessionSchema.parse(value);
41
+ }
42
+ async function loadSession() {
43
+ sessionPromise ??= (async ()=>{
44
+ const sessionPath = process.env[SESSION_ENV]?.trim();
45
+ if (!sessionPath) throw new Error(`${SESSION_ENV} is missing. Start this suite from The AI Platform Miniapp Test Lab.`);
46
+ const raw = await (0, __rspack_external_node_fs_promises_3b710708.readFile)(sessionPath, 'utf8');
47
+ return parseTapMiniappTestSession(JSON.parse(raw));
48
+ })();
49
+ return sessionPromise;
50
+ }
51
+ function selectPage(context, session) {
52
+ const candidates = context.pages().filter((candidate)=>candidate.url().startsWith(session.pageUrlPrefix));
53
+ if (1 !== candidates.length) throw new Error(`TAP Browser Driver exposed ${candidates.length} matching pages for run ${session.runId}; expected exactly one.`);
54
+ const page = candidates[0];
55
+ if (!page) throw new Error('TAP Browser Driver page selection failed');
56
+ return page;
57
+ }
58
+ async function emitHostEvent(page, session, event, payload) {
59
+ if (!event.startsWith('tap.') || event.length > 160 || event.trim() !== event) throw new Error('TAP host events must be bounded, trimmed tap.* event names.');
60
+ await page.evaluate(({ controlEvent, event, payload, runId })=>{
61
+ const response = {};
62
+ globalThis.dispatchEvent(new CustomEvent(controlEvent, {
63
+ detail: {
64
+ event,
65
+ payload,
66
+ response,
67
+ runId
68
+ }
69
+ }));
70
+ if (response.error) throw new Error(response.error);
71
+ if (!response.delivered) throw new Error(`The TAP frame host has no active subscriber for ${event}. Declare the event on the miniapp surface before using it in a test.`);
72
+ }, {
73
+ controlEvent: 'tap:miniapp-test-control',
74
+ event,
75
+ payload,
76
+ runId: session.runId
77
+ });
78
+ }
79
+ function fixtureMetadata(session, page) {
80
+ return Object.freeze({
81
+ allowedNetworkOrigins: Object.freeze([
82
+ ...session.allowedNetworkOrigins
83
+ ]),
84
+ channelId: session.channelId,
85
+ credentialAliases: Object.freeze([
86
+ ...session.credentialAliases
87
+ ]),
88
+ control: Object.freeze({
89
+ emitHostEvent: (event, payload)=>emitHostEvent(page, session, event, payload),
90
+ reset: async ()=>{
91
+ await page.reload({
92
+ waitUntil: 'domcontentloaded'
93
+ });
94
+ await page.locator(session.surfaceSelector).waitFor({
95
+ state: 'attached'
96
+ });
97
+ }
98
+ }),
99
+ dataScope: session.dataScope,
100
+ installationId: session.installationId,
101
+ localGeneration: session.localGeneration,
102
+ mode: session.mode,
103
+ packageId: session.packageId,
104
+ permissionScenario: session.permissionScenario,
105
+ releaseDigest: session.releaseDigest,
106
+ runId: session.runId,
107
+ sourceDigest: session.sourceDigest,
108
+ surfaceAssetOrigin: session.surfaceAssetOrigin,
109
+ surfaceId: session.surfaceId,
110
+ testBundleDigest: session.testBundleDigest,
111
+ workspaceId: session.workspaceId
112
+ });
113
+ }
114
+ function safeArtifactSegment(value) {
115
+ return value.replaceAll(/[^a-zA-Z0-9._-]/g, '-').slice(0, 160);
116
+ }
117
+ const test = __rspack_external__rstest_playwright_5734ff29.test.extend({
118
+ browser: async (_context, use)=>{
119
+ const session = await loadSession();
120
+ browserPromise ??= __rspack_external_playwright.chromium.connectOverCDP(session.cdpEndpoint, {
121
+ headers: {
122
+ authorization: `Bearer ${session.cdpAuthorization}`,
123
+ 'x-tap-miniapp-test-run': session.runId
124
+ }
125
+ });
126
+ await use(await browserPromise);
127
+ },
128
+ context: async ({ browser, task }, use)=>{
129
+ const session = await loadSession();
130
+ const contexts = browser.contexts().filter((candidate)=>candidate.pages().some((page)=>page.url().startsWith(session.pageUrlPrefix)));
131
+ if (1 !== contexts.length) throw new Error(`TAP Browser Driver exposed ${contexts.length} matching contexts for run ${session.runId}; expected exactly one.`);
132
+ const context = contexts[0];
133
+ if (!context) throw new Error('TAP Browser Driver context selection failed');
134
+ const hostPage = selectPage(context, session);
135
+ const runtimeOrigins = new Set(hostPage.frames().map((frame)=>originOf(frame.url())).filter((origin)=>void 0 !== origin));
136
+ const allowedOrigins = new Set([
137
+ ...runtimeOrigins,
138
+ new URL(session.surfaceAssetOrigin).origin,
139
+ ...session.allowedNetworkOrigins.map((value)=>new URL(value).origin)
140
+ ]);
141
+ const enforceNetworkPolicy = async (route)=>{
142
+ const url = route.request().url();
143
+ const origin = originOf(url);
144
+ if (void 0 === origin || allowedOrigins.has(origin)) return void await route.continue();
145
+ await route.abort('blockedbyclient');
146
+ };
147
+ await context.route('**/*', enforceNetworkPolicy);
148
+ const traceDirectory = (0, __rspack_external_node_path_806ed179.join)(session.artifactDirectory, 'traces', safeArtifactSegment(task.id));
149
+ await (0, __rspack_external_node_fs_promises_3b710708.mkdir)(traceDirectory, {
150
+ recursive: true
151
+ });
152
+ await context.tracing.start({
153
+ screenshots: true,
154
+ snapshots: true,
155
+ sources: true
156
+ });
157
+ try {
158
+ await use(context);
159
+ } finally{
160
+ await context.tracing.stop({
161
+ path: (0, __rspack_external_node_path_806ed179.join)(traceDirectory, 'trace.zip')
162
+ });
163
+ const traceSummary = {
164
+ schemaVersion: 1,
165
+ runId: session.runId,
166
+ taskId: task.id,
167
+ mode: session.mode,
168
+ dataScope: session.dataScope,
169
+ packageId: session.packageId,
170
+ surfaceId: session.surfaceId,
171
+ sourceDigest: session.sourceDigest,
172
+ testBundleDigest: session.testBundleDigest,
173
+ tracePath: 'trace.zip',
174
+ redactions: [
175
+ 'authorization headers',
176
+ 'cookies',
177
+ 'credential values',
178
+ 'network bodies'
179
+ ]
180
+ };
181
+ await Promise.all([
182
+ (0, __rspack_external_node_fs_promises_3b710708.writeFile)((0, __rspack_external_node_path_806ed179.join)(traceDirectory, 'trace-summary.json'), `${JSON.stringify(traceSummary, void 0, 2)}\n`, 'utf8'),
183
+ (0, __rspack_external_node_fs_promises_3b710708.writeFile)((0, __rspack_external_node_path_806ed179.join)(traceDirectory, 'debug.md'), [
184
+ '# Miniapp test trace',
185
+ '',
186
+ `- Run: \`${session.runId}\``,
187
+ `- Rstest task: \`${task.id}\``,
188
+ `- Mode: \`${session.mode}\``,
189
+ `- Data scope: \`${session.dataScope}\``,
190
+ `- Surface: \`${session.surfaceId}\``,
191
+ `- Source digest: \`${session.sourceDigest}\``,
192
+ `- Test bundle digest: \`${session.testBundleDigest}\``,
193
+ '',
194
+ 'Open `trace.zip` from The AI Platform Test Lab for the DOM, screenshots, console, and network timeline. The final assertion outcome is authoritative in `report.json` and `report.md`.',
195
+ ''
196
+ ].join('\n'), 'utf8')
197
+ ]);
198
+ await context.unroute('**/*', enforceNetworkPolicy);
199
+ }
200
+ },
201
+ page: async ({ context }, use)=>{
202
+ const session = await loadSession();
203
+ const page = selectPage(context, session);
204
+ await page.locator(session.surfaceSelector).waitFor({
205
+ state: 'attached'
206
+ });
207
+ await use(page);
208
+ },
209
+ surface: async ({ page }, use)=>{
210
+ const session = await loadSession();
211
+ await use(page.frameLocator(session.surfaceSelector));
212
+ },
213
+ tap: async ({ page }, use)=>{
214
+ await use(fixtureMetadata(await loadSession(), page));
215
+ }
216
+ });
217
+ function originOf(value) {
218
+ try {
219
+ const url = new URL(value);
220
+ if (matchesNonNetworkScheme(url.protocol)) return;
221
+ return url.origin;
222
+ } catch {
223
+ return;
224
+ }
225
+ }
226
+ function matchesNonNetworkScheme(protocol) {
227
+ return NON_NETWORK_PROTOCOLS.has(protocol);
228
+ }
229
+ const NON_NETWORK_PROTOCOLS = new Set([
230
+ 'about:',
231
+ 'blob:',
232
+ 'data:',
233
+ 'file:'
234
+ ]);
235
+ var afterAll = __rspack_external__rstest_playwright_5734ff29.afterAll;
236
+ var afterEach = __rspack_external__rstest_playwright_5734ff29.afterEach;
237
+ var beforeAll = __rspack_external__rstest_playwright_5734ff29.beforeAll;
238
+ var beforeEach = __rspack_external__rstest_playwright_5734ff29.beforeEach;
239
+ var describe = __rspack_external__rstest_playwright_5734ff29.describe;
240
+ var expect = __rspack_external__rstest_playwright_5734ff29.expect;
241
+ export { afterAll, afterEach, beforeAll, beforeEach, describe, expect, parseTapMiniappTestSession, test };
package/package.json CHANGED
@@ -1,13 +1,18 @@
1
1
  {
2
2
  "name": "@theaiplatform/miniapp-sdk",
3
- "version": "0.3.0",
4
- "description": "Public SDK for building portable miniapps that run in The AI Platform.",
3
+ "version": "0.3.1",
4
+ "description": "TypeScript SDK for building, testing, and shipping portable miniapps on The AI Platform.",
5
5
  "type": "module",
6
6
  "engines": {
7
7
  "node": ">=20"
8
8
  },
9
9
  "keywords": [
10
10
  "miniapp",
11
+ "playwright",
12
+ "react",
13
+ "rstest",
14
+ "tap",
15
+ "typescript",
11
16
  "module-federation",
12
17
  "rspack",
13
18
  "the-ai-platform"
@@ -54,7 +59,11 @@
54
59
  "types": "./dist/rspack/index.d.ts",
55
60
  "import": "./dist/rspack/index.js"
56
61
  },
57
- "./config-schema.json": "./config-schema.json"
62
+ "./config-schema.json": "./config-schema.json",
63
+ "./testing/rstest": {
64
+ "types": "./dist/testing/rstest.d.ts",
65
+ "import": "./dist/testing/rstest.js"
66
+ }
58
67
  },
59
68
  "main": "./dist/index.js",
60
69
  "types": "./dist/index.d.ts",
@@ -119,7 +128,11 @@
119
128
  "types": "./dist/rspack/index.d.ts",
120
129
  "import": "./dist/rspack/index.js"
121
130
  },
122
- "./config-schema.json": "./config-schema.json"
131
+ "./config-schema.json": "./config-schema.json",
132
+ "./testing/rstest": {
133
+ "types": "./dist/testing/rstest.d.ts",
134
+ "import": "./dist/testing/rstest.js"
135
+ }
123
136
  }
124
137
  },
125
138
  "peerDependencies": {
@@ -127,10 +140,13 @@
127
140
  "@module-federation/runtime-tools": "2.8.0",
128
141
  "@rsbuild/core": ">=2 <3",
129
142
  "@rslib/core": ">=0.21 <1",
143
+ "@rstest/core": "0.11.3",
144
+ "@rstest/playwright": "0.11.3",
130
145
  "@tanstack/react-query": ">=5 <6",
131
146
  "@tanstack/react-router": ">=1 <2",
132
147
  "react": ">=19 <20",
133
- "react-dom": ">=19 <20"
148
+ "react-dom": ">=19 <20",
149
+ "playwright": ">=1.61 <2"
134
150
  },
135
151
  "peerDependenciesMeta": {
136
152
  "@module-federation/rsbuild-plugin": {
@@ -145,6 +161,12 @@
145
161
  "@rslib/core": {
146
162
  "optional": true
147
163
  },
164
+ "@rstest/core": {
165
+ "optional": true
166
+ },
167
+ "@rstest/playwright": {
168
+ "optional": true
169
+ },
148
170
  "@tanstack/react-query": {
149
171
  "optional": true
150
172
  },
@@ -156,6 +178,9 @@
156
178
  },
157
179
  "react-dom": {
158
180
  "optional": true
181
+ },
182
+ "playwright": {
183
+ "optional": true
159
184
  }
160
185
  },
161
186
  "devDependencies": {
@@ -165,6 +190,7 @@
165
190
  "@rsbuild/core": "^2.1.4",
166
191
  "@rslib/core": "^0.23.2",
167
192
  "@rstest/core": "catalog:rstest",
193
+ "@rstest/playwright": "catalog:rstest",
168
194
  "@tailwindcss/postcss": "^4.3.0",
169
195
  "@types/jsdom": "^28.0.3",
170
196
  "@types/node": "catalog:node",
@@ -172,6 +198,7 @@
172
198
  "@types/react-dom": "catalog:react",
173
199
  "jsdom": "^29.1.1",
174
200
  "postcss": "^8.5.6",
201
+ "playwright": "^1.61.0",
175
202
  "tailwindcss": "^4.3.0",
176
203
  "tw-animate-css": "^1.4.0",
177
204
  "typescript": "catalog:typescript"