@opentf/web-test 1.0.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/CHANGELOG.md +12 -0
- package/README.md +58 -0
- package/index.js +69 -0
- package/init-dom.js +12 -0
- package/package.json +15 -0
- package/setup.js +50 -0
package/CHANGELOG.md
ADDED
package/README.md
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# @opentf/web-test
|
|
2
|
+
|
|
3
|
+
A testing utility for **OTF Web**, inspired by React Testing Library.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **DOM Rendering**: Renders components into a virtual DOM (`happy-dom`).
|
|
8
|
+
- **Reactive Assertions**: Supports testing components that use signals and effects.
|
|
9
|
+
- **Compiler Integration**: Automatically transpiles JSX components using the framework's native compiler.
|
|
10
|
+
- **Testing Library Utilities**: Bundles `@testing-library/dom` for familiar querying.
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
bun add -d @opentf/web-test
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Configuration
|
|
19
|
+
|
|
20
|
+
To enable automatic JSX compilation for tests, add a `bunfig.toml` to your package or root:
|
|
21
|
+
|
|
22
|
+
```toml
|
|
23
|
+
[test]
|
|
24
|
+
preload = ["@opentf/web-test/setup"]
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Usage
|
|
28
|
+
|
|
29
|
+
### Writing a Test
|
|
30
|
+
|
|
31
|
+
```jsx
|
|
32
|
+
import { expect, test, describe } from "bun:test";
|
|
33
|
+
import { render, userEvent } from "@opentf/web-test";
|
|
34
|
+
import MyComponent from "./MyComponent.jsx";
|
|
35
|
+
|
|
36
|
+
describe("MyComponent", () => {
|
|
37
|
+
test("reacts to clicks", async () => {
|
|
38
|
+
const { getByTestId } = render(MyComponent);
|
|
39
|
+
const user = userEvent.setup();
|
|
40
|
+
const btn = getByTestId("btn");
|
|
41
|
+
|
|
42
|
+
expect(btn.textContent).toBe("Count: 0");
|
|
43
|
+
await user.click(btn);
|
|
44
|
+
expect(btn.textContent).toBe("Count: 1");
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### API Reference
|
|
50
|
+
|
|
51
|
+
#### `render(Component, props = {})`
|
|
52
|
+
Renders a component into a container. Returns an object with:
|
|
53
|
+
- `container`: The DOM element containing the component.
|
|
54
|
+
- `unmount()`: A function to remove the component and trigger cleanup.
|
|
55
|
+
- ...all queries from `@testing-library/dom` (e.g., `getByTestId`, `queryByText`).
|
|
56
|
+
|
|
57
|
+
#### `cleanup()`
|
|
58
|
+
Removes all mounted components from the DOM. This is automatically called `afterEach` test if you use the recommended setup.
|
package/index.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { queries, getQueriesForElement } from '@testing-library/dom';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
3
|
+
|
|
4
|
+
let mountedComponents = new Set();
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Renders a compiled Web App Framework component into a DOM container.
|
|
8
|
+
*
|
|
9
|
+
* @param {Function|HTMLElement} Component - The compiled component function or class.
|
|
10
|
+
* @param {Object} props - Properties to pass to the component.
|
|
11
|
+
* @returns {Object} - Testing library queries bound to the container.
|
|
12
|
+
*/
|
|
13
|
+
export function render(Component, props = {}) {
|
|
14
|
+
const container = document.createElement('div');
|
|
15
|
+
document.body.appendChild(container);
|
|
16
|
+
|
|
17
|
+
let element;
|
|
18
|
+
|
|
19
|
+
// If the Component is a function, it should return a DOM node (since our compiler generates direct DOM nodes)
|
|
20
|
+
// Wait, the compiler returns a Web Component (HTMLElement) OR an instance of a function.
|
|
21
|
+
// Actually, functional components are compiled to standard HTMLElements that extend HTMLElement.
|
|
22
|
+
if (typeof Component === 'function' && Component.prototype instanceof HTMLElement) {
|
|
23
|
+
// If it's a registered Web Component class
|
|
24
|
+
const tagName = Component.prototype.tagName?.toLowerCase() || Component.name.toLowerCase();
|
|
25
|
+
element = document.createElement(tagName);
|
|
26
|
+
Object.assign(element, props);
|
|
27
|
+
container.appendChild(element);
|
|
28
|
+
} else if (typeof Component === 'function') {
|
|
29
|
+
// If it's a functional component not converted to a class (though our compiler usually does)
|
|
30
|
+
element = Component(props);
|
|
31
|
+
if (element instanceof Node) {
|
|
32
|
+
container.appendChild(element);
|
|
33
|
+
}
|
|
34
|
+
} else if (typeof Component === 'string') {
|
|
35
|
+
// It's a tag name
|
|
36
|
+
element = document.createElement(Component);
|
|
37
|
+
Object.assign(element, props);
|
|
38
|
+
container.appendChild(element);
|
|
39
|
+
} else {
|
|
40
|
+
throw new Error("Invalid component type passed to render()");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
mountedComponents.add(container);
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
container,
|
|
47
|
+
unmount: () => {
|
|
48
|
+
if (container.parentNode) {
|
|
49
|
+
container.parentNode.removeChild(container);
|
|
50
|
+
}
|
|
51
|
+
mountedComponents.delete(container);
|
|
52
|
+
},
|
|
53
|
+
...getQueriesForElement(container, queries)
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export { userEvent };
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Cleans up all mounted components. Automatically called after each test if supported.
|
|
61
|
+
*/
|
|
62
|
+
export function cleanup() {
|
|
63
|
+
mountedComponents.forEach(container => {
|
|
64
|
+
if (container.parentNode) {
|
|
65
|
+
container.parentNode.removeChild(container);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
mountedComponents.clear();
|
|
69
|
+
}
|
package/init-dom.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { GlobalWindow } from "happy-dom";
|
|
2
|
+
|
|
3
|
+
// Initialize happy-dom explicitly
|
|
4
|
+
const win = new GlobalWindow();
|
|
5
|
+
globalThis.window = win;
|
|
6
|
+
globalThis.document = win.document;
|
|
7
|
+
globalThis.HTMLElement = win.HTMLElement;
|
|
8
|
+
globalThis.customElements = win.customElements;
|
|
9
|
+
globalThis.Node = win.Node;
|
|
10
|
+
globalThis.navigator = win.navigator;
|
|
11
|
+
globalThis.Event = win.Event;
|
|
12
|
+
globalThis.CustomEvent = win.CustomEvent;
|
package/package.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@opentf/web-test",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "DOM testing utilities for OTF Web",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"peerDependencies": {
|
|
8
|
+
"@opentf/web": "*"
|
|
9
|
+
},
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"@testing-library/dom": "^10.4.1",
|
|
12
|
+
"@testing-library/user-event": "^14.6.1",
|
|
13
|
+
"happy-dom": "^20.9.0"
|
|
14
|
+
}
|
|
15
|
+
}
|
package/setup.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import "./init-dom.js";
|
|
2
|
+
import { plugin } from "bun";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { afterEach } from "bun:test";
|
|
6
|
+
import { cleanup } from "./index.js";
|
|
7
|
+
|
|
8
|
+
// Locate the `otfwc` IR compiler (the workspace debug build), overridable via
|
|
9
|
+
// OTFWC_BIN. Walk up from this file to the Cargo workspace.
|
|
10
|
+
function findUp(name, from) {
|
|
11
|
+
let dir = from;
|
|
12
|
+
while (true) {
|
|
13
|
+
if (existsSync(join(dir, name))) return dir;
|
|
14
|
+
const parent = dirname(dir);
|
|
15
|
+
if (parent === dir) return null;
|
|
16
|
+
dir = parent;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
const workspace = findUp("Cargo.toml", import.meta.dir);
|
|
20
|
+
const otfwc =
|
|
21
|
+
process.env.OTFWC_BIN ??
|
|
22
|
+
(workspace ? join(workspace, "target", "debug", "otfwc") : "otfwc");
|
|
23
|
+
|
|
24
|
+
// Compile `.jsx`/`.tsx` test fixtures through the OTF Web compiler so tests can
|
|
25
|
+
// import components directly. Page/layout/404 modules become factories; everything
|
|
26
|
+
// else a Custom Element (matching the toolchain).
|
|
27
|
+
plugin({
|
|
28
|
+
name: "otfw-jsx",
|
|
29
|
+
setup(build) {
|
|
30
|
+
build.onLoad({ filter: /\.[jt]sx$/ }, async (args) => {
|
|
31
|
+
const base = args.path.split("/").pop().replace(/\.[jt]sx$/, "");
|
|
32
|
+
const isPage = base === "page" || base === "layout" || base === "404";
|
|
33
|
+
const argv = ["build"];
|
|
34
|
+
if (!isPage) argv.push("--component");
|
|
35
|
+
argv.push("--stdin", args.path);
|
|
36
|
+
const source = await Bun.file(args.path).text();
|
|
37
|
+
const proc = Bun.spawnSync([otfwc, ...argv], {
|
|
38
|
+
stdin: new TextEncoder().encode(source),
|
|
39
|
+
});
|
|
40
|
+
if (proc.exitCode !== 0) {
|
|
41
|
+
throw new Error(`otfwc failed for ${args.path}:\n${proc.stderr.toString()}`);
|
|
42
|
+
}
|
|
43
|
+
return { contents: proc.stdout.toString(), loader: "js" };
|
|
44
|
+
});
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
afterEach(() => {
|
|
49
|
+
cleanup();
|
|
50
|
+
});
|