@owlmeans/test-ui 0.1.3

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 ADDED
@@ -0,0 +1,51 @@
1
+ # @owlmeans/test-ui
2
+
3
+ Playwright-as-a-library helpers for **bun-test**-driven component acceptance tests of OwlMeans UI packages (`web-panel`, `web-client`, `client-panel`, `client-wl`, `web-flow`, `web-wl`, `client`, `client-i18n`).
4
+
5
+ These are **not** end-to-end UI tests — they exercise individual components in a real browser, the same way unit tests in other categories exercise individual services. The runner is `bun test` (not `bun x playwright test`), keeping the test toolchain consistent across all four categories.
6
+
7
+ ## Exports
8
+
9
+ - `launchBrowser(opts?)` — return the shared chromium instance for this `bun test` process. Idempotent.
10
+ - `closeBrowser()` — tear it down. Call from `afterAll`.
11
+ - `withPage(fn)` — convenience: lease a fresh context+page for the duration of `fn`, then dispose the context.
12
+ - `mountComponent({ url, component?, props? })` — opens a fresh context, navigates to a harness URL with the component / props encoded, returns `{ page, close }` so the spec can assert against `page.locator(...)` and dispose the context when done.
13
+ - `Browser`, `BrowserContext`, `Page`, `Locator` — re-exports of the Playwright types so consumers don't need a direct `playwright` import.
14
+
15
+ ## Spec shape
16
+
17
+ ```ts
18
+ import { afterAll, describe, expect, test } from 'bun:test'
19
+ import { closeBrowser, mountComponent } from '@owlmeans/test-ui'
20
+
21
+ afterAll(async () => { await closeBrowser() })
22
+
23
+ describe('LoginForm — acceptance', () => {
24
+ test('renders the heading from the SKILL example', async () => {
25
+ const { page, close } = await mountComponent({
26
+ url: 'http://localhost:5173/',
27
+ component: 'LoginForm',
28
+ props: { redirect: '/' },
29
+ })
30
+ try {
31
+ expect(await page.locator('h1').textContent()).toBe('Sign in')
32
+ } finally {
33
+ await close()
34
+ }
35
+ })
36
+ })
37
+ ```
38
+
39
+ ## Component harness
40
+
41
+ `harness/index.html` is a starter page (`<div id="root"></div>` + a `<script type="module" src="/mount.tsx"></script>`). Each consuming UI package serves it via Vite (or any bundler) inside a `beforeAll` hook, registers the components under test from a per-package `mount.tsx` that reads `?component=` and `?props=` from the URL, then mounts via `createRoot(...).render(...)`.
42
+
43
+ The wiring is per-package because the React/MUI/router providers each package needs differ; see the `testing-ui` skill for the recommended layout.
44
+
45
+ ## One-time setup
46
+
47
+ ```sh
48
+ bunx playwright install chromium
49
+ ```
50
+
51
+ This downloads the Chromium binary used by the `playwright` library. CI installs it as a workflow step.
@@ -0,0 +1,19 @@
1
+ import type { Browser, LaunchOptions } from 'playwright';
2
+ /**
3
+ * Launch (or return) a single shared chromium instance for the current
4
+ * `bun test` process. UI specs hold a fixture page off this browser; the
5
+ * suite calls `closeBrowser()` from `afterAll` to tear it down once.
6
+ */
7
+ export declare const launchBrowser: (opts?: LaunchOptions) => Promise<Browser>;
8
+ /**
9
+ * Tear down the shared browser. Idempotent — safe to call from multiple
10
+ * `afterAll` hooks.
11
+ */
12
+ export declare const closeBrowser: () => Promise<void>;
13
+ /**
14
+ * Convenience that launches the browser, runs a callback with a fresh
15
+ * context + page, and closes the context on completion. The browser
16
+ * itself stays alive across calls so multiple specs share it cheaply.
17
+ */
18
+ export declare const withPage: <T>(fn: (page: import("playwright").Page) => Promise<T>) => Promise<T>;
19
+ //# sourceMappingURL=browser.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"browser.d.ts","sourceRoot":"","sources":["../src/browser.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,OAAO,EAAE,aAAa,EAAE,MAAM,YAAY,CAAA;AAKxD;;;;GAIG;AACH,eAAO,MAAM,aAAa,GAAU,OAAM,aAAkB,KAAG,OAAO,CAAC,OAAO,CAO7E,CAAA;AAED;;;GAGG;AACH,eAAO,MAAM,YAAY,QAAa,OAAO,CAAC,IAAI,CAOjD,CAAA;AAED;;;;GAIG;AACH,eAAO,MAAM,QAAQ,GAAU,CAAC,EAC9B,IAAI,CAAC,IAAI,EAAE,OAAO,YAAY,EAAE,IAAI,KAAK,OAAO,CAAC,CAAC,CAAC,KAClD,OAAO,CAAC,CAAC,CASX,CAAA"}
@@ -0,0 +1,47 @@
1
+ import { chromium } from 'playwright';
2
+ let shared;
3
+ let inflight;
4
+ /**
5
+ * Launch (or return) a single shared chromium instance for the current
6
+ * `bun test` process. UI specs hold a fixture page off this browser; the
7
+ * suite calls `closeBrowser()` from `afterAll` to tear it down once.
8
+ */
9
+ export const launchBrowser = async (opts = {}) => {
10
+ if (shared != null)
11
+ return shared;
12
+ if (inflight != null)
13
+ return inflight;
14
+ inflight = chromium.launch({ headless: true, ...opts });
15
+ shared = await inflight;
16
+ inflight = undefined;
17
+ return shared;
18
+ };
19
+ /**
20
+ * Tear down the shared browser. Idempotent — safe to call from multiple
21
+ * `afterAll` hooks.
22
+ */
23
+ export const closeBrowser = async () => {
24
+ const b = shared;
25
+ shared = undefined;
26
+ inflight = undefined;
27
+ if (b != null) {
28
+ await b.close();
29
+ }
30
+ };
31
+ /**
32
+ * Convenience that launches the browser, runs a callback with a fresh
33
+ * context + page, and closes the context on completion. The browser
34
+ * itself stays alive across calls so multiple specs share it cheaply.
35
+ */
36
+ export const withPage = async (fn) => {
37
+ const browser = await launchBrowser();
38
+ const context = await browser.newContext();
39
+ const page = await context.newPage();
40
+ try {
41
+ return await fn(page);
42
+ }
43
+ finally {
44
+ await context.close();
45
+ }
46
+ };
47
+ //# sourceMappingURL=browser.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"browser.js","sourceRoot":"","sources":["../src/browser.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAA;AAGrC,IAAI,MAA2B,CAAA;AAC/B,IAAI,QAAsC,CAAA;AAE1C;;;;GAIG;AACH,MAAM,CAAC,MAAM,aAAa,GAAG,KAAK,EAAE,OAAsB,EAAE,EAAoB,EAAE;IAChF,IAAI,MAAM,IAAI,IAAI;QAAE,OAAO,MAAM,CAAA;IACjC,IAAI,QAAQ,IAAI,IAAI;QAAE,OAAO,QAAQ,CAAA;IACrC,QAAQ,GAAG,QAAQ,CAAC,MAAM,CAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,GAAG,IAAI,EAAE,CAAC,CAAA;IACvD,MAAM,GAAG,MAAM,QAAQ,CAAA;IACvB,QAAQ,GAAG,SAAS,CAAA;IACpB,OAAO,MAAM,CAAA;AACf,CAAC,CAAA;AAED;;;GAGG;AACH,MAAM,CAAC,MAAM,YAAY,GAAG,KAAK,IAAmB,EAAE;IACpD,MAAM,CAAC,GAAG,MAAM,CAAA;IAChB,MAAM,GAAG,SAAS,CAAA;IAClB,QAAQ,GAAG,SAAS,CAAA;IACpB,IAAI,CAAC,IAAI,IAAI,EAAE,CAAC;QACd,MAAM,CAAC,CAAC,KAAK,EAAE,CAAA;IACjB,CAAC;AACH,CAAC,CAAA;AAED;;;;GAIG;AACH,MAAM,CAAC,MAAM,QAAQ,GAAG,KAAK,EAC3B,EAAmD,EACvC,EAAE;IACd,MAAM,OAAO,GAAG,MAAM,aAAa,EAAE,CAAA;IACrC,MAAM,OAAO,GAAG,MAAM,OAAO,CAAC,UAAU,EAAE,CAAA;IAC1C,MAAM,IAAI,GAAG,MAAM,OAAO,CAAC,OAAO,EAAE,CAAA;IACpC,IAAI,CAAC;QACH,OAAO,MAAM,EAAE,CAAC,IAAI,CAAC,CAAA;IACvB,CAAC;YAAS,CAAC;QACT,MAAM,OAAO,CAAC,KAAK,EAAE,CAAA;IACvB,CAAC;AACH,CAAC,CAAA"}
@@ -0,0 +1,15 @@
1
+ import type { PlaywrightTestConfig } from '@playwright/test';
2
+ /**
3
+ * Wrap `defineConfig` from `@playwright/test` with OwlMeans-wide defaults
4
+ * for category-D acceptance tests:
5
+ *
6
+ * - `testDir: './tests'`
7
+ * - chromium-only (override `projects` to add WebKit/Firefox)
8
+ * - retries `0` locally, `1` in CI
9
+ * - 10s navigation timeout
10
+ *
11
+ * Pass overrides such as `webServer`, `baseURL`, or additional `projects`
12
+ * directly through. Anything you set wins over the defaults.
13
+ */
14
+ export declare const defineUiConfig: (overrides?: PlaywrightTestConfig) => PlaywrightTestConfig;
15
+ //# sourceMappingURL=config.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,kBAAkB,CAAA;AAI5D;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,cAAc,GAAI,YAAW,oBAAyB,KAAG,oBAelE,CAAA"}
@@ -0,0 +1,30 @@
1
+ import { defineConfig, devices } from '@playwright/test';
2
+ const isCi = process.env.CI === 'true' || process.env.CI === '1';
3
+ /**
4
+ * Wrap `defineConfig` from `@playwright/test` with OwlMeans-wide defaults
5
+ * for category-D acceptance tests:
6
+ *
7
+ * - `testDir: './tests'`
8
+ * - chromium-only (override `projects` to add WebKit/Firefox)
9
+ * - retries `0` locally, `1` in CI
10
+ * - 10s navigation timeout
11
+ *
12
+ * Pass overrides such as `webServer`, `baseURL`, or additional `projects`
13
+ * directly through. Anything you set wins over the defaults.
14
+ */
15
+ export const defineUiConfig = (overrides = {}) => defineConfig({
16
+ testDir: './tests',
17
+ fullyParallel: true,
18
+ forbidOnly: isCi,
19
+ retries: isCi ? 1 : 0,
20
+ reporter: isCi ? [['github'], ['list']] : 'list',
21
+ use: {
22
+ trace: 'on-first-retry',
23
+ ...overrides.use,
24
+ },
25
+ projects: overrides.projects ?? [
26
+ { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
27
+ ],
28
+ ...overrides,
29
+ });
30
+ //# sourceMappingURL=config.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.js","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,OAAO,EAAE,MAAM,kBAAkB,CAAA;AAGxD,MAAM,IAAI,GAAG,OAAO,CAAC,GAAG,CAAC,EAAE,KAAK,MAAM,IAAI,OAAO,CAAC,GAAG,CAAC,EAAE,KAAK,GAAG,CAAA;AAEhE;;;;;;;;;;;GAWG;AACH,MAAM,CAAC,MAAM,cAAc,GAAG,CAAC,YAAkC,EAAE,EAAwB,EAAE,CAC3F,YAAY,CAAC;IACX,OAAO,EAAE,SAAS;IAClB,aAAa,EAAE,IAAI;IACnB,UAAU,EAAE,IAAI;IAChB,OAAO,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;IACrB,QAAQ,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,EAAE,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,MAAM;IAChD,GAAG,EAAE;QACH,KAAK,EAAE,gBAAgB;QACvB,GAAG,SAAS,CAAC,GAAG;KACjB;IACD,QAAQ,EAAE,SAAS,CAAC,QAAQ,IAAI;QAC9B,EAAE,IAAI,EAAE,UAAU,EAAE,GAAG,EAAE,EAAE,GAAG,OAAO,CAAC,gBAAgB,CAAC,EAAE,EAAE;KAC5D;IACD,GAAG,SAAS;CACb,CAAC,CAAA"}
@@ -0,0 +1,4 @@
1
+ export * from './browser.js';
2
+ export * from './mount.js';
3
+ export type { Browser, BrowserContext, Locator, Page } from 'playwright';
4
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,cAAc,CAAA;AAC5B,cAAc,YAAY,CAAA;AAC1B,YAAY,EAAE,OAAO,EAAE,cAAc,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,YAAY,CAAA"}
package/build/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export * from './browser.js';
2
+ export * from './mount.js';
3
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,cAAc,CAAA;AAC5B,cAAc,YAAY,CAAA"}
@@ -0,0 +1,40 @@
1
+ import type { Page } from 'playwright';
2
+ export interface MountOptions {
3
+ /**
4
+ * URL the harness is reachable at. The consuming package is in charge
5
+ * of producing it — typically a Vite dev server pointed at
6
+ * `node_modules/@owlmeans/test-ui/harness/` plus a per-package
7
+ * `mount.tsx` that registers the components under test, OR an
8
+ * inlined `data:text/html,...` URL for the simplest smoke tests.
9
+ */
10
+ url: string;
11
+ /**
12
+ * Component name registered in the harness. Appended as `?component=`.
13
+ * Omit when the harness mounts a single fixed root.
14
+ */
15
+ component?: string;
16
+ /**
17
+ * JSON-serialisable props passed to the component. Encoded into the
18
+ * URL as `?props=<json>` for the harness's `mount.tsx` to read.
19
+ */
20
+ props?: Record<string, unknown>;
21
+ }
22
+ export interface Mounted {
23
+ page: Page;
24
+ /** Closes the page's browser context — the shared browser stays alive. */
25
+ close: () => Promise<void>;
26
+ }
27
+ /**
28
+ * Open a fresh browser context, navigate to the harness URL with the
29
+ * `component` and `props` query parameters set, and return the `Page`
30
+ * along with a `close()` that disposes the context. Use from a `bun:test`
31
+ * spec to drive component-level acceptance assertions:
32
+ *
33
+ * const { page, close } = await mountComponent({ url: HARNESS, component: 'LoginForm' })
34
+ * try { expect(await page.locator('h1').textContent()).toBe('Sign in') }
35
+ * finally { await close() }
36
+ *
37
+ * `closeBrowser()` from `afterAll` tears down the shared browser at the end of the suite.
38
+ */
39
+ export declare const mountComponent: (opts: MountOptions) => Promise<Mounted>;
40
+ //# sourceMappingURL=mount.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mount.d.ts","sourceRoot":"","sources":["../src/mount.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,YAAY,CAAA;AAGtC,MAAM,WAAW,YAAY;IAC3B;;;;;;OAMG;IACH,GAAG,EAAE,MAAM,CAAA;IACX;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAChC;AAED,MAAM,WAAW,OAAO;IACtB,IAAI,EAAE,IAAI,CAAA;IACV,0EAA0E;IAC1E,KAAK,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;CAC3B;AAYD;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,cAAc,GAAU,MAAM,YAAY,KAAG,OAAO,CAAC,OAAO,CASxE,CAAA"}
package/build/mount.js ADDED
@@ -0,0 +1,36 @@
1
+ import { launchBrowser } from './browser.js';
2
+ const buildUrl = (opts) => {
3
+ const params = new URLSearchParams();
4
+ if (opts.component != null)
5
+ params.set('component', opts.component);
6
+ if (opts.props != null)
7
+ params.set('props', JSON.stringify(opts.props));
8
+ const qs = params.toString();
9
+ if (qs === '')
10
+ return opts.url;
11
+ const sep = opts.url.includes('?') ? '&' : '?';
12
+ return `${opts.url}${sep}${qs}`;
13
+ };
14
+ /**
15
+ * Open a fresh browser context, navigate to the harness URL with the
16
+ * `component` and `props` query parameters set, and return the `Page`
17
+ * along with a `close()` that disposes the context. Use from a `bun:test`
18
+ * spec to drive component-level acceptance assertions:
19
+ *
20
+ * const { page, close } = await mountComponent({ url: HARNESS, component: 'LoginForm' })
21
+ * try { expect(await page.locator('h1').textContent()).toBe('Sign in') }
22
+ * finally { await close() }
23
+ *
24
+ * `closeBrowser()` from `afterAll` tears down the shared browser at the end of the suite.
25
+ */
26
+ export const mountComponent = async (opts) => {
27
+ const browser = await launchBrowser();
28
+ const context = await browser.newContext();
29
+ const page = await context.newPage();
30
+ await page.goto(buildUrl(opts));
31
+ return {
32
+ page,
33
+ close: async () => { await context.close(); },
34
+ };
35
+ };
36
+ //# sourceMappingURL=mount.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mount.js","sourceRoot":"","sources":["../src/mount.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,aAAa,EAAE,MAAM,cAAc,CAAA;AA6B5C,MAAM,QAAQ,GAAG,CAAC,IAAkB,EAAU,EAAE;IAC9C,MAAM,MAAM,GAAG,IAAI,eAAe,EAAE,CAAA;IACpC,IAAI,IAAI,CAAC,SAAS,IAAI,IAAI;QAAE,MAAM,CAAC,GAAG,CAAC,WAAW,EAAE,IAAI,CAAC,SAAS,CAAC,CAAA;IACnE,IAAI,IAAI,CAAC,KAAK,IAAI,IAAI;QAAE,MAAM,CAAC,GAAG,CAAC,OAAO,EAAE,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAA;IACvE,MAAM,EAAE,GAAG,MAAM,CAAC,QAAQ,EAAE,CAAA;IAC5B,IAAI,EAAE,KAAK,EAAE;QAAE,OAAO,IAAI,CAAC,GAAG,CAAA;IAC9B,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,CAAA;IAC9C,OAAO,GAAG,IAAI,CAAC,GAAG,GAAG,GAAG,GAAG,EAAE,EAAE,CAAA;AACjC,CAAC,CAAA;AAED;;;;;;;;;;;GAWG;AACH,MAAM,CAAC,MAAM,cAAc,GAAG,KAAK,EAAE,IAAkB,EAAoB,EAAE;IAC3E,MAAM,OAAO,GAAG,MAAM,aAAa,EAAE,CAAA;IACrC,MAAM,OAAO,GAAG,MAAM,OAAO,CAAC,UAAU,EAAE,CAAA;IAC1C,MAAM,IAAI,GAAG,MAAM,OAAO,CAAC,OAAO,EAAE,CAAA;IACpC,MAAM,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAA;IAC/B,OAAO;QACL,IAAI;QACJ,KAAK,EAAE,KAAK,IAAI,EAAE,GAAG,MAAM,OAAO,CAAC,KAAK,EAAE,CAAA,CAAC,CAAC;KAC7C,CAAA;AACH,CAAC,CAAA"}
@@ -0,0 +1,11 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <title>@owlmeans/test-ui harness</title>
6
+ </head>
7
+ <body>
8
+ <div id="root"></div>
9
+ <script type="module" src="/mount.tsx"></script>
10
+ </body>
11
+ </html>
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@owlmeans/test-ui",
3
+ "version": "0.1.3",
4
+ "license": "MIT",
5
+ "type": "module",
6
+ "scripts": {
7
+ "build": "tsc -b",
8
+ "dev": "nodemon -e ts,tsx,json --watch src --exec \"tsc -p ./tsconfig.json\"",
9
+ "watch": "tsc -b -w --preserveWatchOutput --pretty"
10
+ },
11
+ "main": "build/index.js",
12
+ "module": "build/index.js",
13
+ "types": "build/index.d.ts",
14
+ "exports": {
15
+ ".": {
16
+ "import": "./build/index.js",
17
+ "require": "./build/index.js",
18
+ "default": "./build/index.js",
19
+ "module": "./build/index.js",
20
+ "types": "./build/index.d.ts"
21
+ },
22
+ "./harness": "./harness/index.html"
23
+ },
24
+ "dependencies": {
25
+ "@owlmeans/test": "^0.1.3",
26
+ "playwright": "^1.49.0"
27
+ },
28
+ "peerDependencies": {
29
+ "react": "*",
30
+ "react-dom": "*"
31
+ },
32
+ "peerDependenciesMeta": {
33
+ "react": { "optional": true },
34
+ "react-dom": { "optional": true }
35
+ },
36
+ "devDependencies": {
37
+ "@owlmeans/dep-config": "^0.1.3",
38
+ "@types/bun": "^1.3.0",
39
+ "@types/react": "^19.2.7",
40
+ "nodemon": "^3.1.11",
41
+ "typescript": "^6.0.2"
42
+ },
43
+ "publishConfig": {
44
+ "access": "public"
45
+ }
46
+ }
package/src/browser.ts ADDED
@@ -0,0 +1,50 @@
1
+ import { chromium } from 'playwright'
2
+ import type { Browser, LaunchOptions } from 'playwright'
3
+
4
+ let shared: Browser | undefined
5
+ let inflight: Promise<Browser> | undefined
6
+
7
+ /**
8
+ * Launch (or return) a single shared chromium instance for the current
9
+ * `bun test` process. UI specs hold a fixture page off this browser; the
10
+ * suite calls `closeBrowser()` from `afterAll` to tear it down once.
11
+ */
12
+ export const launchBrowser = async (opts: LaunchOptions = {}): Promise<Browser> => {
13
+ if (shared != null) return shared
14
+ if (inflight != null) return inflight
15
+ inflight = chromium.launch({ headless: true, ...opts })
16
+ shared = await inflight
17
+ inflight = undefined
18
+ return shared
19
+ }
20
+
21
+ /**
22
+ * Tear down the shared browser. Idempotent — safe to call from multiple
23
+ * `afterAll` hooks.
24
+ */
25
+ export const closeBrowser = async (): Promise<void> => {
26
+ const b = shared
27
+ shared = undefined
28
+ inflight = undefined
29
+ if (b != null) {
30
+ await b.close()
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Convenience that launches the browser, runs a callback with a fresh
36
+ * context + page, and closes the context on completion. The browser
37
+ * itself stays alive across calls so multiple specs share it cheaply.
38
+ */
39
+ export const withPage = async <T>(
40
+ fn: (page: import('playwright').Page) => Promise<T>
41
+ ): Promise<T> => {
42
+ const browser = await launchBrowser()
43
+ const context = await browser.newContext()
44
+ const page = await context.newPage()
45
+ try {
46
+ return await fn(page)
47
+ } finally {
48
+ await context.close()
49
+ }
50
+ }
File without changes
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export * from './browser.js'
2
+ export * from './mount.js'
3
+ export type { Browser, BrowserContext, Locator, Page } from 'playwright'
package/src/mount.ts ADDED
@@ -0,0 +1,62 @@
1
+ import type { Page } from 'playwright'
2
+ import { launchBrowser } from './browser.js'
3
+
4
+ export interface MountOptions {
5
+ /**
6
+ * URL the harness is reachable at. The consuming package is in charge
7
+ * of producing it — typically a Vite dev server pointed at
8
+ * `node_modules/@owlmeans/test-ui/harness/` plus a per-package
9
+ * `mount.tsx` that registers the components under test, OR an
10
+ * inlined `data:text/html,...` URL for the simplest smoke tests.
11
+ */
12
+ url: string
13
+ /**
14
+ * Component name registered in the harness. Appended as `?component=`.
15
+ * Omit when the harness mounts a single fixed root.
16
+ */
17
+ component?: string
18
+ /**
19
+ * JSON-serialisable props passed to the component. Encoded into the
20
+ * URL as `?props=<json>` for the harness's `mount.tsx` to read.
21
+ */
22
+ props?: Record<string, unknown>
23
+ }
24
+
25
+ export interface Mounted {
26
+ page: Page
27
+ /** Closes the page's browser context — the shared browser stays alive. */
28
+ close: () => Promise<void>
29
+ }
30
+
31
+ const buildUrl = (opts: MountOptions): string => {
32
+ const params = new URLSearchParams()
33
+ if (opts.component != null) params.set('component', opts.component)
34
+ if (opts.props != null) params.set('props', JSON.stringify(opts.props))
35
+ const qs = params.toString()
36
+ if (qs === '') return opts.url
37
+ const sep = opts.url.includes('?') ? '&' : '?'
38
+ return `${opts.url}${sep}${qs}`
39
+ }
40
+
41
+ /**
42
+ * Open a fresh browser context, navigate to the harness URL with the
43
+ * `component` and `props` query parameters set, and return the `Page`
44
+ * along with a `close()` that disposes the context. Use from a `bun:test`
45
+ * spec to drive component-level acceptance assertions:
46
+ *
47
+ * const { page, close } = await mountComponent({ url: HARNESS, component: 'LoginForm' })
48
+ * try { expect(await page.locator('h1').textContent()).toBe('Sign in') }
49
+ * finally { await close() }
50
+ *
51
+ * `closeBrowser()` from `afterAll` tears down the shared browser at the end of the suite.
52
+ */
53
+ export const mountComponent = async (opts: MountOptions): Promise<Mounted> => {
54
+ const browser = await launchBrowser()
55
+ const context = await browser.newContext()
56
+ const page = await context.newPage()
57
+ await page.goto(buildUrl(opts))
58
+ return {
59
+ page,
60
+ close: async () => { await context.close() },
61
+ }
62
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "extends": [
3
+ "@owlmeans/dep-config/tsconfig.base.json",
4
+ "@owlmeans/dep-config/tsconfig.bun.json"
5
+ ],
6
+ "compilerOptions": {
7
+ "rootDir": "./src/",
8
+ "outDir": "./build/"
9
+ },
10
+ "exclude": [
11
+ "./dist/**/*",
12
+ "./build/**/*",
13
+ "./harness/**/*",
14
+ "./*.ts"
15
+ ]
16
+ }