@zerotal/flow 1.0.4 → 1.3.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 +106 -27
- package/README.md +3 -1
- package/package.json +7 -6
- package/src/Component.ts +79 -3
- package/src/client/bridge.ts +95 -6
- package/src/compiler/index.ts +31 -0
- package/src/compiler/transform.ts +341 -2
- package/src/decorators.ts +99 -5
- package/src/index.ts +5 -5
- package/src/jsx-runtime.ts +51 -8
- package/src/mixins.ts +121 -151
- package/src/pagination.ts +2 -2
- package/src/provider/FlowProvider.ts +16 -0
- package/src/testing.ts +112 -9
- package/src/uploads/FileUploads.ts +2 -2
- package/src/uploads/TemporaryUploadedFile.ts +1 -1
- package/src/utils.ts +31 -0
package/src/jsx-runtime.ts
CHANGED
|
@@ -10,7 +10,11 @@
|
|
|
10
10
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
11
11
|
import { escapeHtml } from "@zerotal/core/helpers";
|
|
12
12
|
import { getExposedProps, getLockedProps } from "./decorators.ts";
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
_setExposedKeyCapture,
|
|
15
|
+
_consumeExposedKeyCapture,
|
|
16
|
+
_consumeExposedKeyReads,
|
|
17
|
+
} from "./utils.ts";
|
|
14
18
|
import { URL_ATTRIBUTES, sanitizeUrl } from "./urlSafety.ts";
|
|
15
19
|
|
|
16
20
|
/** The rendered output of a JSX element: an object carrying its serialized HTML string. */
|
|
@@ -557,18 +561,27 @@ export function _resolveBindName(
|
|
|
557
561
|
* capture AND a value-identity match, so a literal or server value never false-positives),
|
|
558
562
|
* or null when the attribute is just an ordinary server-rendered value.
|
|
559
563
|
* `locked` distinguishes @locked (read-only, reactive) from @expose (two-way).
|
|
564
|
+
*
|
|
565
|
+
* @remarks
|
|
566
|
+
* The getter capture is a single slot and *all* of an element's props are evaluated
|
|
567
|
+
* before `jsx()` runs, so any later prop that reads an exposed property clobbers it —
|
|
568
|
+
* `value={this.destination} disabled={this.notSure}` leaves the capture pointing at
|
|
569
|
+
* `notSure`. The freshness check then fails and the element renders with no
|
|
570
|
+
* `flow:model` at all: the field accepts typing and nothing ever reaches the server.
|
|
571
|
+
* When the capture is unusable we therefore fall back to a value-identity scan over
|
|
572
|
+
* the exposed props, exactly as `_resolveBindName` does — binding correctly whenever
|
|
573
|
+
* the answer is unambiguous, rather than silently dropping the binding.
|
|
560
574
|
*/
|
|
561
575
|
function _resolveValueBind(value: unknown): { name: string; locked: boolean } | null {
|
|
562
576
|
const page = _getRenderCtx()?.page ?? null;
|
|
563
577
|
if (!page) return null;
|
|
564
578
|
const cap = _consumeExposedKeyCapture();
|
|
565
|
-
if (!cap) return null;
|
|
566
579
|
const exposed = getExposedProps(page); // includes @locked
|
|
567
580
|
|
|
568
581
|
// Dotted capture (e.g. "form.email") — a Form field. The root must be an exposed
|
|
569
582
|
// prop and the nested value must identity-match (freshness guard). Form fields
|
|
570
583
|
// are always writable (two-way), so locked is false.
|
|
571
|
-
if (cap.includes(".")) {
|
|
584
|
+
if (cap && cap.includes(".")) {
|
|
572
585
|
const root = cap.slice(0, cap.indexOf("."));
|
|
573
586
|
if (!exposed.has(root)) return null;
|
|
574
587
|
const nested = cap
|
|
@@ -578,11 +591,29 @@ function _resolveValueBind(value: unknown): { name: string; locked: boolean } |
|
|
|
578
591
|
return { name: cap, locked: false };
|
|
579
592
|
}
|
|
580
593
|
|
|
581
|
-
|
|
582
|
-
//
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
594
|
+
// Capture fast path: trust it only when it is fresh for THIS value (guards against
|
|
595
|
+
// a stale capture lingering from a previous element that didn't consume it).
|
|
596
|
+
if (cap && exposed.has(cap) && Object.is((page as Record<string, unknown>)[cap], value)) {
|
|
597
|
+
_consumeExposedKeyReads(); // this element is resolved; don't leak reads to the next
|
|
598
|
+
return { name: cap, locked: getLockedProps(page).has(cap) };
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// The single slot was clobbered by a later prop on the same element (the B2 case:
|
|
602
|
+
// `value={this.destination} disabled={this.notSure}`). Fall back to the keys actually
|
|
603
|
+
// read while this element's props were evaluated, most recent first, and take the one
|
|
604
|
+
// whose current value IS this value.
|
|
605
|
+
//
|
|
606
|
+
// Crucially this is NOT a scan of every exposed prop: a key only qualifies if it was
|
|
607
|
+
// genuinely read through a `this.` getter, so a literal `value="CUSTOM"` that happens
|
|
608
|
+
// to equal some property's current value still binds nothing.
|
|
609
|
+
const reads = _consumeExposedKeyReads();
|
|
610
|
+
for (const key of reads) {
|
|
611
|
+
if (key === cap) continue; // already rejected above
|
|
612
|
+
if (!exposed.has(key)) continue;
|
|
613
|
+
if (!Object.is((page as Record<string, unknown>)[key], value)) continue;
|
|
614
|
+
return { name: key, locked: getLockedProps(page).has(key) };
|
|
615
|
+
}
|
|
616
|
+
return null;
|
|
586
617
|
}
|
|
587
618
|
|
|
588
619
|
/**
|
|
@@ -1061,6 +1092,18 @@ export function jsx(
|
|
|
1061
1092
|
attrs.push(`value="${escapeAttr(String(v ?? ""))}"`);
|
|
1062
1093
|
continue;
|
|
1063
1094
|
}
|
|
1095
|
+
// A radio's `value` is the option's identifier and its `checked` is a per-option
|
|
1096
|
+
// boolean — neither is a two-way binding target, and inferring one is actively
|
|
1097
|
+
// wrong. In a group rendered from a .map() the inference lands on whichever single
|
|
1098
|
+
// option currently equals the bound property, so that one radio claims the whole
|
|
1099
|
+
// group's model and overwrites the user's pick on every flush. Bind a radio group
|
|
1100
|
+
// explicitly with {...this.bind('prop', 'optionValue')}, which knows the group is
|
|
1101
|
+
// addressed as a unit.
|
|
1102
|
+
if (type === "input" && String(p["type"] ?? "") === "radio") {
|
|
1103
|
+
if (k === "value") attrs.push(`value="${escapeAttr(String(v ?? ""))}"`);
|
|
1104
|
+
else if (v) attrs.push("checked");
|
|
1105
|
+
continue;
|
|
1106
|
+
}
|
|
1064
1107
|
const bound = _resolveValueBind(v);
|
|
1065
1108
|
if (bound) {
|
|
1066
1109
|
if (bound.locked) {
|
package/src/mixins.ts
CHANGED
|
@@ -1,17 +1,9 @@
|
|
|
1
1
|
// ── Component mixins ────────────────────────────────────────────────────────
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// // before — one class carrying every feature inline, base repeated per mixin
|
|
9
|
-
// class UsersPage extends FileUploads(Pagination(Component)) { … }
|
|
10
|
-
//
|
|
11
|
-
// // after — flat, left-to-right, base baked in
|
|
12
|
-
// class UsersPage extends ComponentWith(Pagination, FileUploads) {
|
|
13
|
-
// async render() { … } // the page only owns its own markup + state
|
|
14
|
-
// }
|
|
3
|
+
// Mixin composition machinery for Flow. The entry point is `Component.using(...)`
|
|
4
|
+
// (see Component.ts) — this module owns the types it is built from, and the fold
|
|
5
|
+
// itself. Nothing here imports `Component`: the fold seeds from its receiver, so
|
|
6
|
+
// this is a leaf module.
|
|
15
7
|
//
|
|
16
8
|
// A Component mixin is the canonical generic form below — note it extends an ABSTRACT
|
|
17
9
|
// base and returns an `abstract class`, so it doesn't have to implement Component's
|
|
@@ -28,21 +20,7 @@
|
|
|
28
20
|
// return WithUpload;
|
|
29
21
|
// }
|
|
30
22
|
//
|
|
31
|
-
//
|
|
32
|
-
// `redirect()`, `validate()`, the `this.set`/`refresh`/… client magics, …) and every other
|
|
33
|
-
// mixin's members flow through to the final page — fully type-checked. The shipped
|
|
34
|
-
// `Pagination` mixin (see ./pagination.ts) is written exactly this way and composes here:
|
|
35
|
-
// `ComponentWith(Pagination)`.
|
|
36
|
-
//
|
|
37
|
-
// Mixin `@expose`/`@locked` props register on the mixin's prototype, which sits in the page's
|
|
38
|
-
// prototype chain — so they're discovered at runtime exactly like props declared on the page
|
|
39
|
-
// itself (snapshot, reactivity, client writes, `@url` sync all work). Note: pages composed
|
|
40
|
-
// with `ComponentWith(...)` render via the runtime path rather than the AOT compiler (which
|
|
41
|
-
// only statically sees a page's own `extends Component` + locally-declared members) — the same
|
|
42
|
-
// fallback complex pages already use; behaviour is identical, you just don't get the
|
|
43
|
-
// ahead-of-time compile step for that page.
|
|
44
|
-
|
|
45
|
-
import { Component } from "./Component.ts";
|
|
23
|
+
// Compose them onto a page with `Component.using(FileUpload, …)`.
|
|
46
24
|
|
|
47
25
|
/**
|
|
48
26
|
* A class constructor. Abstract-aware (`abstract new …`) so the abstract `Component` base —
|
|
@@ -65,135 +43,127 @@ export type Mixin<TIn extends Constructor = Constructor, TOut extends Constructo
|
|
|
65
43
|
Base: TIn,
|
|
66
44
|
) => TOut;
|
|
67
45
|
|
|
68
|
-
type Base = typeof Component;
|
|
69
|
-
|
|
70
46
|
/**
|
|
71
|
-
*
|
|
72
|
-
* so a page can be built from focused, reusable behaviours instead of one bloated class.
|
|
73
|
-
*
|
|
74
|
-
* @remarks
|
|
75
|
-
* Each mixin receives the accumulated base and returns an extended class, so `Component`'s full
|
|
76
|
-
* surface (`flash()`, `redirect()`, `validate()`, the client magics, …) and every mixin's
|
|
77
|
-
* `@expose`/`@locked` members flow through to the final page — fully type-checked. Mixin props
|
|
78
|
-
* register on the mixin prototype, which sits in the page's prototype chain, so snapshot,
|
|
79
|
-
* reactivity, and client writes all work. Overloads cover 1–8 mixins; nest another
|
|
80
|
-
* `ComponentWith(...)` call for more. Pages composed this way render via the runtime path rather
|
|
81
|
-
* than the AOT compiler.
|
|
82
|
-
*
|
|
83
|
-
* @param mixins mixin factories applied left-to-right (each takes the prior result as its base).
|
|
84
|
-
* @returns a class extending `Component` with every mixin's members mixed in.
|
|
47
|
+
* The call signatures behind `Component.using(...)`.
|
|
85
48
|
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
49
|
+
* Each overload threads the accumulated type through every step, so the composed class carries
|
|
50
|
+
* the base's full surface plus every mixin's members — fully type-checked. The `this: TBase`
|
|
51
|
+
* parameter is what makes the base polymorphic: `using` composes onto whatever class it is
|
|
52
|
+
* called on, not onto a hardcoded `Component`.
|
|
90
53
|
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
* @expose page = 1;
|
|
94
|
-
* @expose next() { this.page++; }
|
|
95
|
-
* }
|
|
96
|
-
* return WithPagination;
|
|
97
|
-
* }
|
|
98
|
-
*
|
|
99
|
-
* // app/flow/UsersPage.tsx
|
|
100
|
-
* class UsersPage extends ComponentWith(Pagination) {
|
|
101
|
-
* override async render() {
|
|
102
|
-
* return <div data-flow-root>Page {this.page}</div>;
|
|
103
|
-
* }
|
|
104
|
-
* }
|
|
105
|
-
* ```
|
|
54
|
+
* Overloads cover 1–8 mixins. There is no ceiling: the composed class carries `using` too, so
|
|
55
|
+
* `Base.using(a, b).using(c, d)` chains.
|
|
106
56
|
*/
|
|
107
|
-
export
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
57
|
+
export interface Compose {
|
|
58
|
+
<TBase extends Constructor, A extends Constructor>(this: TBase, a: (base: TBase) => A): A;
|
|
59
|
+
<TBase extends Constructor, A extends Constructor, B extends Constructor>(
|
|
60
|
+
this: TBase,
|
|
61
|
+
a: (base: TBase) => A,
|
|
62
|
+
b: (base: A) => B,
|
|
63
|
+
): B;
|
|
64
|
+
<TBase extends Constructor, A extends Constructor, B extends Constructor, C extends Constructor>(
|
|
65
|
+
this: TBase,
|
|
66
|
+
a: (base: TBase) => A,
|
|
67
|
+
b: (base: A) => B,
|
|
68
|
+
c: (base: B) => C,
|
|
69
|
+
): C;
|
|
70
|
+
<
|
|
71
|
+
TBase extends Constructor,
|
|
72
|
+
A extends Constructor,
|
|
73
|
+
B extends Constructor,
|
|
74
|
+
C extends Constructor,
|
|
75
|
+
D extends Constructor,
|
|
76
|
+
>(
|
|
77
|
+
this: TBase,
|
|
78
|
+
a: (base: TBase) => A,
|
|
79
|
+
b: (base: A) => B,
|
|
80
|
+
c: (base: B) => C,
|
|
81
|
+
d: (base: C) => D,
|
|
82
|
+
): D;
|
|
83
|
+
<
|
|
84
|
+
TBase extends Constructor,
|
|
85
|
+
A extends Constructor,
|
|
86
|
+
B extends Constructor,
|
|
87
|
+
C extends Constructor,
|
|
88
|
+
D extends Constructor,
|
|
89
|
+
E extends Constructor,
|
|
90
|
+
>(
|
|
91
|
+
this: TBase,
|
|
92
|
+
a: (base: TBase) => A,
|
|
93
|
+
b: (base: A) => B,
|
|
94
|
+
c: (base: B) => C,
|
|
95
|
+
d: (base: C) => D,
|
|
96
|
+
e: (base: D) => E,
|
|
97
|
+
): E;
|
|
98
|
+
<
|
|
99
|
+
TBase extends Constructor,
|
|
100
|
+
A extends Constructor,
|
|
101
|
+
B extends Constructor,
|
|
102
|
+
C extends Constructor,
|
|
103
|
+
D extends Constructor,
|
|
104
|
+
E extends Constructor,
|
|
105
|
+
F extends Constructor,
|
|
106
|
+
>(
|
|
107
|
+
this: TBase,
|
|
108
|
+
a: (base: TBase) => A,
|
|
109
|
+
b: (base: A) => B,
|
|
110
|
+
c: (base: B) => C,
|
|
111
|
+
d: (base: C) => D,
|
|
112
|
+
e: (base: D) => E,
|
|
113
|
+
f: (base: E) => F,
|
|
114
|
+
): F;
|
|
115
|
+
<
|
|
116
|
+
TBase extends Constructor,
|
|
117
|
+
A extends Constructor,
|
|
118
|
+
B extends Constructor,
|
|
119
|
+
C extends Constructor,
|
|
120
|
+
D extends Constructor,
|
|
121
|
+
E extends Constructor,
|
|
122
|
+
F extends Constructor,
|
|
123
|
+
G extends Constructor,
|
|
124
|
+
>(
|
|
125
|
+
this: TBase,
|
|
126
|
+
a: (base: TBase) => A,
|
|
127
|
+
b: (base: A) => B,
|
|
128
|
+
c: (base: B) => C,
|
|
129
|
+
d: (base: C) => D,
|
|
130
|
+
e: (base: D) => E,
|
|
131
|
+
f: (base: E) => F,
|
|
132
|
+
g: (base: F) => G,
|
|
133
|
+
): G;
|
|
134
|
+
<
|
|
135
|
+
TBase extends Constructor,
|
|
136
|
+
A extends Constructor,
|
|
137
|
+
B extends Constructor,
|
|
138
|
+
C extends Constructor,
|
|
139
|
+
D extends Constructor,
|
|
140
|
+
E extends Constructor,
|
|
141
|
+
F extends Constructor,
|
|
142
|
+
G extends Constructor,
|
|
143
|
+
H extends Constructor,
|
|
144
|
+
>(
|
|
145
|
+
this: TBase,
|
|
146
|
+
a: (base: TBase) => A,
|
|
147
|
+
b: (base: A) => B,
|
|
148
|
+
c: (base: B) => C,
|
|
149
|
+
d: (base: C) => D,
|
|
150
|
+
e: (base: D) => E,
|
|
151
|
+
f: (base: E) => F,
|
|
152
|
+
g: (base: F) => G,
|
|
153
|
+
h: (base: G) => H,
|
|
154
|
+
): H;
|
|
155
|
+
}
|
|
187
156
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
157
|
+
/**
|
|
158
|
+
* The fold behind {@link Compose}: apply each mixin left-to-right, seeding from `this` (the
|
|
159
|
+
* class `using` was called on). Assigned to `Component.using` — not exported publicly.
|
|
160
|
+
*
|
|
161
|
+
* @internal
|
|
162
|
+
*/
|
|
163
|
+
export const _compose = function (
|
|
164
|
+
this: Constructor,
|
|
192
165
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- variadic mixin folder
|
|
193
166
|
...mixins: Array<(base: any) => Constructor>
|
|
194
167
|
): Constructor {
|
|
195
|
-
return mixins.reduce<Constructor>(
|
|
196
|
-
|
|
197
|
-
Component as unknown as Constructor,
|
|
198
|
-
);
|
|
199
|
-
}
|
|
168
|
+
return mixins.reduce<Constructor>((acc, mixin) => mixin(acc), this);
|
|
169
|
+
} as Compose;
|
package/src/pagination.ts
CHANGED
|
@@ -133,7 +133,7 @@ export function paginate<T>(items: T[], page = 1, perPage = 15): Paginator<T> {
|
|
|
133
133
|
// `previousPage`, `gotoPage`, `resetPage`), WITHOUT putting any of that on
|
|
134
134
|
// the base Component.
|
|
135
135
|
//
|
|
136
|
-
// class PostsPage extends
|
|
136
|
+
// class PostsPage extends Component.using(Pagination) {
|
|
137
137
|
// @locked posts: Post[] = [];
|
|
138
138
|
// async onMount() { this.posts = await Post.all(); }
|
|
139
139
|
// async render() {
|
|
@@ -200,7 +200,7 @@ const DEFAULT_PAGE = "page";
|
|
|
200
200
|
*
|
|
201
201
|
* @example
|
|
202
202
|
* ```tsx
|
|
203
|
-
* class PostsPage extends
|
|
203
|
+
* class PostsPage extends Component.using(Pagination) {
|
|
204
204
|
* @locked posts: Post[] = [];
|
|
205
205
|
* async onMount() { this.posts = await Post.all(); }
|
|
206
206
|
* async render() {
|
|
@@ -274,6 +274,22 @@ async function _handleFlowMessage(ws: FlowBunWs, raw: string, deps: FlowWsDeps):
|
|
|
274
274
|
await deps.container.runScoped(async (scoped) => {
|
|
275
275
|
const ctx = new HttpContext(request, scoped);
|
|
276
276
|
|
|
277
|
+
// A WebSocket action has no Bun `Server` behind it, so `ctx.ip()` — which resolves
|
|
278
|
+
// through `server.requestIP(request)` — would return null for every action even
|
|
279
|
+
// though the socket's peer address is known here and was captured at upgrade. Supply
|
|
280
|
+
// a minimal provider so `request().ip()` answers the same inside an action as it does
|
|
281
|
+
// during the initial HTTP render, instead of silently degrading to null.
|
|
282
|
+
const socketIp = ws.data.remoteIp;
|
|
283
|
+
if (socketIp && socketIp !== "unknown") {
|
|
284
|
+
ctx._server = {
|
|
285
|
+
requestIP: () => ({
|
|
286
|
+
address: socketIp,
|
|
287
|
+
family: socketIp.includes(":") ? "IPv6" : "IPv4",
|
|
288
|
+
port: 0, // not carried across the upgrade; nothing reads it
|
|
289
|
+
}),
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
277
293
|
await RequestContext.run(ctx, async () => {
|
|
278
294
|
let passed = false;
|
|
279
295
|
let redirectUrl: string | undefined;
|
package/src/testing.ts
CHANGED
|
@@ -54,6 +54,10 @@ export class FlowTest<T extends Component> {
|
|
|
54
54
|
private _effects: FlowEffects | null = null;
|
|
55
55
|
private _snapshot: Snapshot | null = null;
|
|
56
56
|
private _name: string;
|
|
57
|
+
/** Set by {@link tolerateErrors}: capture action errors instead of rethrowing them. */
|
|
58
|
+
private _tolerateErrors = false;
|
|
59
|
+
/** The last non-validation error an action threw, when {@link tolerateErrors} is on. */
|
|
60
|
+
private _lastError: Error | null = null;
|
|
57
61
|
|
|
58
62
|
private constructor(page: T, name: string) {
|
|
59
63
|
this._page = page;
|
|
@@ -97,13 +101,43 @@ export class FlowTest<T extends Component> {
|
|
|
97
101
|
// ── Actions ────────────────────────────────────────────────────────────────
|
|
98
102
|
|
|
99
103
|
/**
|
|
100
|
-
* Set a property value directly (state seeding), then return for
|
|
101
|
-
* Does NOT fire the updating/updated hooks — use `update()` to
|
|
102
|
-
* client-driven property change.
|
|
104
|
+
* Set a property value directly (state seeding) and re-render, then return for
|
|
105
|
+
* chaining. Does NOT fire the updating/updated hooks — use `update()` to
|
|
106
|
+
* simulate a client-driven property change.
|
|
107
|
+
*
|
|
108
|
+
* The re-render is the point: without it `html()` and every assertion that reads
|
|
109
|
+
* it would still describe the render from *before* the assignment, so a test
|
|
110
|
+
* would silently assert against stale markup. Use {@link seed} when you are
|
|
111
|
+
* assigning several properties and only want to pay for one render.
|
|
103
112
|
* @category Actions
|
|
104
113
|
*/
|
|
105
114
|
async set(prop: keyof T, value: unknown): Promise<this> {
|
|
106
115
|
(this._page as Record<string, unknown>)[prop as string] = value;
|
|
116
|
+
await this._render();
|
|
117
|
+
return this;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Assign a property without re-rendering — the batching form of {@link set}.
|
|
122
|
+
* Nothing reads the new value until the next `set`/`update`/`call` (or an
|
|
123
|
+
* explicit {@link render}) produces a fresh render.
|
|
124
|
+
* @category Actions
|
|
125
|
+
*
|
|
126
|
+
* @example
|
|
127
|
+
* ```ts
|
|
128
|
+
* await t.seed('step', 3);
|
|
129
|
+
* await t.seed('mode', 'advanced');
|
|
130
|
+
* await t.render(); // one render for both
|
|
131
|
+
* ```
|
|
132
|
+
*/
|
|
133
|
+
async seed(prop: keyof T, value: unknown): Promise<this> {
|
|
134
|
+
(this._page as Record<string, unknown>)[prop as string] = value;
|
|
135
|
+
return this;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Re-render the page without changing state (pairs with {@link seed}). @category Actions */
|
|
139
|
+
async render(): Promise<this> {
|
|
140
|
+
await this._render();
|
|
107
141
|
return this;
|
|
108
142
|
}
|
|
109
143
|
|
|
@@ -120,15 +154,71 @@ export class FlowTest<T extends Component> {
|
|
|
120
154
|
return this;
|
|
121
155
|
}
|
|
122
156
|
|
|
157
|
+
/**
|
|
158
|
+
* Opt out of the rethrow in {@link call}: a throwing action is captured on the
|
|
159
|
+
* harness instead of failing the test, so the error path itself can be asserted.
|
|
160
|
+
*
|
|
161
|
+
* Only for tests *about* error handling. Leaving it off is what makes a broken
|
|
162
|
+
* action fail its test rather than look like a no-op.
|
|
163
|
+
* @category Actions
|
|
164
|
+
*
|
|
165
|
+
* @example
|
|
166
|
+
* ```ts
|
|
167
|
+
* const t = (await FlowTest.mount(CheckoutPage)).tolerateErrors();
|
|
168
|
+
* await t.call('submit');
|
|
169
|
+
* t.assertErrored(/payment gateway/);
|
|
170
|
+
* ```
|
|
171
|
+
*/
|
|
172
|
+
tolerateErrors(): this {
|
|
173
|
+
this._tolerateErrors = true;
|
|
174
|
+
return this;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** The last non-validation error an action threw, or null. @category Actions */
|
|
178
|
+
lastError(): Error | null {
|
|
179
|
+
return this._lastError;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Assert the last action threw, optionally matching its message.
|
|
184
|
+
* Requires {@link tolerateErrors} — without it a throwing action fails the test outright.
|
|
185
|
+
* @category Assertions
|
|
186
|
+
*/
|
|
187
|
+
assertErrored(match?: string | RegExp): this {
|
|
188
|
+
if (!this._lastError) {
|
|
189
|
+
throw new Error(
|
|
190
|
+
`[FlowTest] Expected the last action on ${this._name} to throw, but it did not.`,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
if (match !== undefined) {
|
|
194
|
+
const message = this._lastError.message;
|
|
195
|
+
const ok = typeof match === "string" ? message.includes(match) : match.test(message);
|
|
196
|
+
if (!ok) {
|
|
197
|
+
throw new Error(
|
|
198
|
+
`[FlowTest] Expected the error message to match ${String(match)}, but got: ${message}`,
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return this;
|
|
203
|
+
}
|
|
204
|
+
|
|
123
205
|
/**
|
|
124
206
|
* Call an action method and re-render. Simulates a subsequent (WebSocket) request:
|
|
125
207
|
* runs `onBoot()` + `onHydrate()`, invokes the method, then `onUpdate()` and re-render.
|
|
126
|
-
*
|
|
127
|
-
*
|
|
208
|
+
*
|
|
209
|
+
* A `ValidationError` is an expected outcome: the errors are stored and the page
|
|
210
|
+
* re-renders with the error bag populated, exactly as in production.
|
|
211
|
+
*
|
|
212
|
+
* Any other error is routed to `onError()` (mirroring production) and then
|
|
213
|
+
* **rethrown**, so a broken action fails its test instead of silently rendering
|
|
214
|
+
* an unchanged page. Call {@link tolerateErrors} first when the error path is
|
|
215
|
+
* what you are testing.
|
|
128
216
|
*
|
|
129
217
|
* @param method - Name of the method to invoke on the page.
|
|
130
218
|
* @param args - Arguments forwarded to the method.
|
|
131
219
|
* @throws If `method` is not a function on the page instance.
|
|
220
|
+
* @throws Whatever the action threw, unless it was a `ValidationError` or
|
|
221
|
+
* {@link tolerateErrors} is on.
|
|
132
222
|
* @category Actions
|
|
133
223
|
*/
|
|
134
224
|
async call(method: string, ...args: unknown[]): Promise<this> {
|
|
@@ -141,16 +231,25 @@ export class FlowTest<T extends Component> {
|
|
|
141
231
|
await this._page.onBoot();
|
|
142
232
|
await this._page.onHydrate();
|
|
143
233
|
|
|
234
|
+
this._lastError = null;
|
|
235
|
+
let thrown: Error | null = null;
|
|
236
|
+
|
|
144
237
|
try {
|
|
145
238
|
await (fn as (...a: unknown[]) => unknown).apply(this._page, args);
|
|
146
239
|
} catch (error) {
|
|
147
240
|
if (error instanceof ValidationError) {
|
|
148
|
-
// Errors already stored on page._errors
|
|
241
|
+
// Errors already stored on page._errors — a normal, assertable outcome.
|
|
149
242
|
} else {
|
|
243
|
+
thrown = error instanceof Error ? error : new Error(String(error));
|
|
244
|
+
this._lastError = thrown;
|
|
245
|
+
// Run the production error hook so its side effects (the default flashes
|
|
246
|
+
// the message) are observable either way. An onError that itself throws
|
|
247
|
+
// replaces the original — surfacing the deeper bug rather than hiding it.
|
|
150
248
|
try {
|
|
151
|
-
await this._page.onError(
|
|
152
|
-
} catch {
|
|
153
|
-
|
|
249
|
+
await this._page.onError(thrown);
|
|
250
|
+
} catch (hookError) {
|
|
251
|
+
thrown = hookError instanceof Error ? hookError : new Error(String(hookError));
|
|
252
|
+
this._lastError = thrown;
|
|
154
253
|
}
|
|
155
254
|
}
|
|
156
255
|
}
|
|
@@ -158,6 +257,10 @@ export class FlowTest<T extends Component> {
|
|
|
158
257
|
await this._page.onUpdate();
|
|
159
258
|
this._effects = this._page._drainEffects();
|
|
160
259
|
await this._render();
|
|
260
|
+
|
|
261
|
+
// Rethrow after the re-render, so an opted-in test still sees the rendered
|
|
262
|
+
// error state and a non-opted-in test gets the real stack.
|
|
263
|
+
if (thrown && !this._tolerateErrors) throw thrown;
|
|
161
264
|
return this;
|
|
162
265
|
}
|
|
163
266
|
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// This mixin adds the ergonomic server-side actions for managing those pending uploads —
|
|
8
8
|
// chiefly removing a chosen file before it's stored — without bloating the base class.
|
|
9
9
|
//
|
|
10
|
-
// class Avatar extends
|
|
10
|
+
// class Avatar extends Component.using(FileUploads) {
|
|
11
11
|
// @expose photo: TemporaryUploadedFile | null = null;
|
|
12
12
|
// @expose async save() {
|
|
13
13
|
// this.path = await this.photo?.store("avatars"); // persist permanently
|
|
@@ -48,7 +48,7 @@ type AbstractComponentCtor = abstract new (...args: any[]) => Component;
|
|
|
48
48
|
*
|
|
49
49
|
* @example
|
|
50
50
|
* ```tsx
|
|
51
|
-
* class Avatar extends
|
|
51
|
+
* class Avatar extends Component.using(FileUploads) {
|
|
52
52
|
* @expose photo: TemporaryUploadedFile | null = null;
|
|
53
53
|
* @expose path = "";
|
|
54
54
|
*
|
|
@@ -98,7 +98,7 @@ async function disk(name?: string): Promise<Disk> {
|
|
|
98
98
|
*
|
|
99
99
|
* @example
|
|
100
100
|
* ```tsx
|
|
101
|
-
* class Avatar extends
|
|
101
|
+
* class Avatar extends Component.using(FileUploads) {
|
|
102
102
|
* @expose photo: TemporaryUploadedFile | null = null;
|
|
103
103
|
* @expose path = "";
|
|
104
104
|
*
|