@firsthandjs/testing 0.1.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/LICENSE +21 -0
- package/README.md +38 -0
- package/dist/index.d.ts +75 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +79 -0
- package/package.json +47 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Firsthand contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# @firsthandjs/testing
|
|
2
|
+
|
|
3
|
+
Mounting, cleanup and leak probes. Deliberately small: Firsthand renders real DOM,
|
|
4
|
+
so the DOM is the API — there is no wrapper object to learn and no query
|
|
5
|
+
language re-implemented on top of `querySelector`.
|
|
6
|
+
|
|
7
|
+
**Documentation:** [guide](https://github.com/firsthandjs/firsthand/blob/main/docs/guide/12-testing.md) · [API reference](https://github.com/firsthandjs/firsthand/blob/main/docs/reference/testing.md) · [all docs](https://github.com/firsthandjs/firsthand/blob/main/docs/README.md)
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { afterEach } from 'vitest';
|
|
11
|
+
import { cleanup, mount } from '@firsthandjs/testing';
|
|
12
|
+
|
|
13
|
+
afterEach(cleanup);
|
|
14
|
+
|
|
15
|
+
it('counts up', () => {
|
|
16
|
+
const view = mount(() => <Counter initial={0} />);
|
|
17
|
+
view.get<HTMLButtonElement>('button').click();
|
|
18
|
+
expect(view.text()).toBe('1'); // no await: writes are synchronous
|
|
19
|
+
});
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
| Export | What it is for |
|
|
23
|
+
| ------------------------- | --------------------------------------------------------------------------------------------------- |
|
|
24
|
+
| `mount(view, parent?)` | Renders into a fresh container attached to the document, with `text()`, `get()` and `all()` helpers |
|
|
25
|
+
| `cleanup()` | Unmounts everything still standing |
|
|
26
|
+
| `autoCleanup()` | Registers `cleanup` with whatever `afterEach` the environment has |
|
|
27
|
+
| `withRoot(fn)` | A reactive root with no DOM, for testing signals and context in plain Node |
|
|
28
|
+
| `subscriberCount(source)` | How many live subscribers a signal has — the number a leak test wants |
|
|
29
|
+
| `tick(ms?)` | Waits for asynchronous _application_ code. Never needed after a signal write |
|
|
30
|
+
|
|
31
|
+
The container is attached to the document rather than detached: layout, focus,
|
|
32
|
+
events and `:hover` all behave differently in a detached tree, and a test that
|
|
33
|
+
only passes detached is a test of something else.
|
|
34
|
+
|
|
35
|
+
Full guide, including Vitest environments and Playwright:
|
|
36
|
+
[the testing guide](../../docs/guide/12-testing.md).
|
|
37
|
+
|
|
38
|
+
MIT licensed.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@firsthandjs/testing` — mounting, cleanup and leak probes.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately small. Firsthand renders real DOM, so the DOM is the API: there is
|
|
5
|
+
* no wrapper object to learn and no query language to re-implement. What a test
|
|
6
|
+
* actually needs from a framework is somewhere to mount, a guarantee that the
|
|
7
|
+
* previous test's tree is gone, and a way to ask whether disposal really
|
|
8
|
+
* released everything. That is what is here.
|
|
9
|
+
*
|
|
10
|
+
* It is runner-agnostic. `autoCleanup()` hooks into whatever `afterEach` the
|
|
11
|
+
* environment provides — Vitest, Jest, Mocha, Playwright's component runner —
|
|
12
|
+
* and does nothing if there is none.
|
|
13
|
+
*/
|
|
14
|
+
import { type Dispose } from '@firsthandjs/core';
|
|
15
|
+
import { type View } from '@firsthandjs/dom';
|
|
16
|
+
/** What `mount` hands back. */
|
|
17
|
+
export interface Mounted {
|
|
18
|
+
/** The element the view was rendered into. */
|
|
19
|
+
readonly container: HTMLElement;
|
|
20
|
+
/** Disposes the view and removes its container. Idempotent. */
|
|
21
|
+
readonly unmount: Dispose;
|
|
22
|
+
/** Shorthand for `container.textContent`, which most assertions want. */
|
|
23
|
+
text(): string;
|
|
24
|
+
/** `querySelector` that throws instead of returning null. */
|
|
25
|
+
get<E extends Element = HTMLElement>(selector: string): E;
|
|
26
|
+
/** `querySelectorAll` as a real array. */
|
|
27
|
+
all<E extends Element = HTMLElement>(selector: string): E[];
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Renders a view into a fresh container attached to the document.
|
|
31
|
+
*
|
|
32
|
+
* Attached, not detached: layout, focus, events and `:hover` all behave
|
|
33
|
+
* differently in a detached tree, and a test that passes only detached is a
|
|
34
|
+
* test of something else.
|
|
35
|
+
*/
|
|
36
|
+
export declare function mount(view: () => View, parent?: ParentNode): Mounted;
|
|
37
|
+
/** Unmounts everything `mount` created and still holds. */
|
|
38
|
+
export declare function cleanup(): void;
|
|
39
|
+
/**
|
|
40
|
+
* Registers `cleanup` with the surrounding test framework, if there is one.
|
|
41
|
+
*
|
|
42
|
+
* Call it once in a setup file. A test that forgets to unmount then cannot
|
|
43
|
+
* leak into the next one, which is the failure mode that makes a suite
|
|
44
|
+
* mysteriously order-dependent.
|
|
45
|
+
*/
|
|
46
|
+
export declare function autoCleanup(): boolean;
|
|
47
|
+
/**
|
|
48
|
+
* Runs `fn` in its own reactive root and returns the result with a disposer.
|
|
49
|
+
*
|
|
50
|
+
* For testing reactivity without any DOM — signals, computeds, effects and
|
|
51
|
+
* context all work in a plain Node environment, because the core has no DOM
|
|
52
|
+
* dependency.
|
|
53
|
+
*/
|
|
54
|
+
export declare function withRoot<T>(fn: () => T): {
|
|
55
|
+
value: T;
|
|
56
|
+
dispose: Dispose;
|
|
57
|
+
};
|
|
58
|
+
/**
|
|
59
|
+
* How many live subscribers a signal or computed currently has.
|
|
60
|
+
*
|
|
61
|
+
* The number a leak test wants: after disposing whatever was watching it, a
|
|
62
|
+
* source that still has subscribers is still reachable from the graph, and
|
|
63
|
+
* nothing it references can be collected. This reads the graph rather than
|
|
64
|
+
* guessing from the heap, so it is deterministic in every engine.
|
|
65
|
+
*/
|
|
66
|
+
export declare function subscriberCount(source: object): number;
|
|
67
|
+
/**
|
|
68
|
+
* Waits for pending microtasks and, optionally, a timer.
|
|
69
|
+
*
|
|
70
|
+
* Firsthand updates the DOM synchronously (ADR-0006), so this is *not* needed
|
|
71
|
+
* after a signal write. It is here for the ordinary reason any test needs it:
|
|
72
|
+
* an `await` in application code, a `fetch`, a `queueMicrotask`.
|
|
73
|
+
*/
|
|
74
|
+
export declare function tick(ms?: number): Promise<void>;
|
|
75
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,OAAO,EAAc,KAAK,OAAO,EAAE,MAAM,mBAAmB,CAAC;AAC7D,OAAO,EAAU,KAAK,IAAI,EAAE,MAAM,kBAAkB,CAAC;AAUrD,+BAA+B;AAC/B,MAAM,WAAW,OAAO;IACtB,8CAA8C;IAC9C,QAAQ,CAAC,SAAS,EAAE,WAAW,CAAC;IAChC,+DAA+D;IAC/D,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B,yEAAyE;IACzE,IAAI,IAAI,MAAM,CAAC;IACf,6DAA6D;IAC7D,GAAG,CAAC,CAAC,SAAS,OAAO,GAAG,WAAW,EAAE,QAAQ,EAAE,MAAM,GAAG,CAAC,CAAC;IAC1D,0CAA0C;IAC1C,GAAG,CAAC,CAAC,SAAS,OAAO,GAAG,WAAW,EAAE,QAAQ,EAAE,MAAM,GAAG,CAAC,EAAE,CAAC;CAC7D;AAID;;;;;;GAMG;AACH,wBAAgB,KAAK,CAAC,IAAI,EAAE,MAAM,IAAI,EAAE,MAAM,GAAE,UAA0B,GAAG,OAAO,CAkCnF;AAED,2DAA2D;AAC3D,wBAAgB,OAAO,IAAI,IAAI,CAI9B;AAID;;;;;;GAMG;AACH,wBAAgB,WAAW,IAAI,OAAO,CAOrC;AAED;;;;;;GAMG;AACH,wBAAgB,QAAQ,CAAC,CAAC,EAAE,EAAE,EAAE,MAAM,CAAC,GAAG;IAAE,KAAK,EAAE,CAAC,CAAC;IAAC,OAAO,EAAE,OAAO,CAAA;CAAE,CAOvE;AAED;;;;;;;GAOG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAQtD;AAED;;;;;;GAMG;AACH,wBAAsB,IAAI,CAAC,EAAE,SAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAEhD"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// packages/testing/src/index.ts
|
|
2
|
+
import { createRoot } from "@firsthandjs/core";
|
|
3
|
+
import { render } from "@firsthandjs/dom";
|
|
4
|
+
var active = /* @__PURE__ */ new Set();
|
|
5
|
+
function mount(view, parent = document.body) {
|
|
6
|
+
const container = document.createElement("div");
|
|
7
|
+
parent.appendChild(container);
|
|
8
|
+
const dispose = render(view, container);
|
|
9
|
+
let done = false;
|
|
10
|
+
const unmount = () => {
|
|
11
|
+
if (done) {
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
done = true;
|
|
15
|
+
active.delete(unmount);
|
|
16
|
+
dispose();
|
|
17
|
+
container.remove();
|
|
18
|
+
};
|
|
19
|
+
active.add(unmount);
|
|
20
|
+
return {
|
|
21
|
+
container,
|
|
22
|
+
unmount,
|
|
23
|
+
text: () => container.textContent,
|
|
24
|
+
get: (selector) => {
|
|
25
|
+
const found = container.querySelector(selector);
|
|
26
|
+
if (found === null) {
|
|
27
|
+
throw new Error(
|
|
28
|
+
`No element matched ${selector}. The container holds:
|
|
29
|
+
${container.innerHTML}`
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
return found;
|
|
33
|
+
},
|
|
34
|
+
all: (selector) => [
|
|
35
|
+
...container.querySelectorAll(selector)
|
|
36
|
+
]
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
function cleanup() {
|
|
40
|
+
for (const unmount of [...active]) {
|
|
41
|
+
unmount();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function autoCleanup() {
|
|
45
|
+
const hook = globalThis.afterEach;
|
|
46
|
+
if (typeof hook !== "function") {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
hook(cleanup);
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
function withRoot(fn) {
|
|
53
|
+
let dispose;
|
|
54
|
+
const value = createRoot((stop) => {
|
|
55
|
+
dispose = stop;
|
|
56
|
+
return fn();
|
|
57
|
+
});
|
|
58
|
+
return { value, dispose };
|
|
59
|
+
}
|
|
60
|
+
function subscriberCount(source) {
|
|
61
|
+
let link = source.subs;
|
|
62
|
+
let total = 0;
|
|
63
|
+
while (link !== void 0) {
|
|
64
|
+
total++;
|
|
65
|
+
link = link.nextSub;
|
|
66
|
+
}
|
|
67
|
+
return total;
|
|
68
|
+
}
|
|
69
|
+
async function tick(ms = 0) {
|
|
70
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
71
|
+
}
|
|
72
|
+
export {
|
|
73
|
+
autoCleanup,
|
|
74
|
+
cleanup,
|
|
75
|
+
mount,
|
|
76
|
+
subscriberCount,
|
|
77
|
+
tick,
|
|
78
|
+
withRoot
|
|
79
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@firsthandjs/testing",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Test helpers for Firsthand: mounting, cleanup and leak probes. Works with any runner.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"default": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"main": "./dist/index.js",
|
|
16
|
+
"files": [
|
|
17
|
+
"dist",
|
|
18
|
+
"README.md",
|
|
19
|
+
"LICENSE"
|
|
20
|
+
],
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@firsthandjs/core": "0.1.0",
|
|
23
|
+
"@firsthandjs/dom": "0.1.0"
|
|
24
|
+
},
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=20.11.0"
|
|
27
|
+
},
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public",
|
|
30
|
+
"provenance": true
|
|
31
|
+
},
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/firsthandjs/firsthand.git",
|
|
35
|
+
"directory": "packages/testing"
|
|
36
|
+
},
|
|
37
|
+
"bugs": {
|
|
38
|
+
"url": "https://github.com/firsthandjs/firsthand/issues"
|
|
39
|
+
},
|
|
40
|
+
"homepage": "https://github.com/firsthandjs/firsthand#readme",
|
|
41
|
+
"keywords": [
|
|
42
|
+
"firsthand",
|
|
43
|
+
"testing",
|
|
44
|
+
"vitest",
|
|
45
|
+
"playwright"
|
|
46
|
+
]
|
|
47
|
+
}
|