@reckona/mreact-test-utils 0.0.66 → 0.0.67
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/package.json +6 -5
- package/src/index.ts +182 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reckona/mreact-test-utils",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.67",
|
|
4
4
|
"description": "Integration test helpers for mreact app-router applications.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"fixtures",
|
|
@@ -24,7 +24,8 @@
|
|
|
24
24
|
"dist/**/*.js",
|
|
25
25
|
"dist/**/*.js.map",
|
|
26
26
|
"dist/**/*.d.ts",
|
|
27
|
-
"dist/**/*.d.ts.map"
|
|
27
|
+
"dist/**/*.d.ts.map",
|
|
28
|
+
"src/**/*"
|
|
28
29
|
],
|
|
29
30
|
"type": "module",
|
|
30
31
|
"sideEffects": false,
|
|
@@ -39,8 +40,8 @@
|
|
|
39
40
|
"access": "public"
|
|
40
41
|
},
|
|
41
42
|
"dependencies": {
|
|
42
|
-
"@reckona/mreact-reactive-
|
|
43
|
-
"@reckona/mreact-reactive-
|
|
44
|
-
"@reckona/mreact-router": "0.0.
|
|
43
|
+
"@reckona/mreact-reactive-dom": "0.0.67",
|
|
44
|
+
"@reckona/mreact-reactive-core": "0.0.67",
|
|
45
|
+
"@reckona/mreact-router": "0.0.67"
|
|
45
46
|
}
|
|
46
47
|
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import {
|
|
5
|
+
batchAsync,
|
|
6
|
+
cell,
|
|
7
|
+
computed,
|
|
8
|
+
type Cell,
|
|
9
|
+
type ReadonlyCell,
|
|
10
|
+
} from "@reckona/mreact-reactive-core";
|
|
11
|
+
import { flushEffects } from "@reckona/mreact-reactive-core/testing";
|
|
12
|
+
import { createRoot, type Dispose, type RenderValue } from "@reckona/mreact-reactive-dom";
|
|
13
|
+
import {
|
|
14
|
+
isNotFoundError,
|
|
15
|
+
isRedirectError,
|
|
16
|
+
renderAppRequest,
|
|
17
|
+
type RenderAppRequestOptions,
|
|
18
|
+
} from "@reckona/mreact-router";
|
|
19
|
+
|
|
20
|
+
export interface AppFixture {
|
|
21
|
+
readonly appDir: string;
|
|
22
|
+
render(path: string, options?: AppFixtureRenderOptions | undefined): Promise<Response>;
|
|
23
|
+
write(path: string, contents: string): Promise<void>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type AppFixtureRenderOptions = Omit<RenderAppRequestOptions, "appDir" | "request"> & {
|
|
27
|
+
request?: RequestInit | undefined;
|
|
28
|
+
origin?: string | undefined;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export interface DehydratedQueryState {
|
|
32
|
+
queries: Array<{
|
|
33
|
+
data: unknown;
|
|
34
|
+
queryHash: string;
|
|
35
|
+
queryKey: readonly unknown[];
|
|
36
|
+
updatedAt: number;
|
|
37
|
+
}>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ComponentRenderResult {
|
|
41
|
+
readonly container: HTMLElement;
|
|
42
|
+
rerender(value: ComponentRenderInput): void;
|
|
43
|
+
unmount(): void;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type ComponentRenderInput = RenderValue | (() => RenderValue);
|
|
47
|
+
|
|
48
|
+
export type RouteHandler<TContext = undefined> = (
|
|
49
|
+
request: Request,
|
|
50
|
+
context: TContext,
|
|
51
|
+
) => Response | Promise<Response>;
|
|
52
|
+
|
|
53
|
+
export interface ComponentRenderOptions {
|
|
54
|
+
container?: HTMLElement | undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const queryStateScriptPattern =
|
|
58
|
+
/<script\b[^>]*\bid=(?:"__mreact_query_state"|'__mreact_query_state')[^>]*>([\s\S]*?)<\/script>/i;
|
|
59
|
+
|
|
60
|
+
export async function createAppFixture(prefix = "mreact-app-fixture"): Promise<AppFixture> {
|
|
61
|
+
const appDir = await mkdtemp(join(tmpdir(), `${prefix}-`));
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
appDir,
|
|
65
|
+
render(path, options = {}) {
|
|
66
|
+
const origin = options.origin ?? "http://local.test";
|
|
67
|
+
const request = new Request(new URL(path, origin), options.request);
|
|
68
|
+
const { origin: _origin, request: _request, ...routerOptions } = options;
|
|
69
|
+
|
|
70
|
+
void _origin;
|
|
71
|
+
void _request;
|
|
72
|
+
|
|
73
|
+
return renderAppRequest({
|
|
74
|
+
...routerOptions,
|
|
75
|
+
appDir,
|
|
76
|
+
request,
|
|
77
|
+
});
|
|
78
|
+
},
|
|
79
|
+
async write(path, contents) {
|
|
80
|
+
const file = join(appDir, path);
|
|
81
|
+
await mkdir(dirname(file), { recursive: true });
|
|
82
|
+
await writeFile(file, contents);
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function responseText(response: Response): Promise<string> {
|
|
88
|
+
return response.text();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function invokeRouteHandler<TContext = undefined>(
|
|
92
|
+
handler: RouteHandler<TContext>,
|
|
93
|
+
request: Request,
|
|
94
|
+
context?: TContext,
|
|
95
|
+
): Promise<Response> {
|
|
96
|
+
try {
|
|
97
|
+
const response = await handler(request, context as TContext);
|
|
98
|
+
|
|
99
|
+
return response instanceof Response
|
|
100
|
+
? response
|
|
101
|
+
: new Response("Invalid route response", { status: 500 });
|
|
102
|
+
} catch (error) {
|
|
103
|
+
if (error instanceof Response) {
|
|
104
|
+
return error;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (isRedirectError(error)) {
|
|
108
|
+
return new Response(null, {
|
|
109
|
+
headers: { location: error.location },
|
|
110
|
+
status: error.status,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (isNotFoundError(error)) {
|
|
115
|
+
return new Response("Not Found", { status: 404 });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function render(
|
|
123
|
+
value: ComponentRenderInput,
|
|
124
|
+
options: ComponentRenderOptions = {},
|
|
125
|
+
): ComponentRenderResult {
|
|
126
|
+
const container = options.container ?? document.createElement("div");
|
|
127
|
+
let current = value;
|
|
128
|
+
let dispose = mountComponent(container, current);
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
container,
|
|
132
|
+
rerender(next) {
|
|
133
|
+
dispose();
|
|
134
|
+
current = next;
|
|
135
|
+
dispose = mountComponent(container, current);
|
|
136
|
+
},
|
|
137
|
+
unmount() {
|
|
138
|
+
dispose();
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export async function act<T>(fn: () => Promise<T> | T): Promise<T> {
|
|
144
|
+
const result = await batchAsync(fn);
|
|
145
|
+
await flushReactive();
|
|
146
|
+
return result;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export async function flushReactive(): Promise<void> {
|
|
150
|
+
await flushEffects();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function createCellMock<T>(initial: T): Cell<T> {
|
|
154
|
+
return cell(initial);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function createComputedMock<T>(fn: () => T): ReadonlyCell<T> {
|
|
158
|
+
return computed(fn);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function readQueryState(html: string): DehydratedQueryState | undefined {
|
|
162
|
+
const encoded = queryStateScriptPattern.exec(html)?.[1];
|
|
163
|
+
|
|
164
|
+
if (encoded === undefined) {
|
|
165
|
+
return undefined;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return JSON.parse(unescapeJsonForHtml(encoded)) as DehydratedQueryState;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function unescapeJsonForHtml(value: string): string {
|
|
172
|
+
return value
|
|
173
|
+
.replaceAll("\\u003c", "<")
|
|
174
|
+
.replaceAll("\\u003e", ">")
|
|
175
|
+
.replaceAll("\\u0026", "&")
|
|
176
|
+
.replaceAll("\\u2028", "\u2028")
|
|
177
|
+
.replaceAll("\\u2029", "\u2029");
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function mountComponent(container: HTMLElement, value: ComponentRenderInput): Dispose {
|
|
181
|
+
return createRoot(container, () => (typeof value === "function" ? value() : value));
|
|
182
|
+
}
|