@zerotal/flow 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 +61 -0
- package/LICENSE +21 -0
- package/README.md +148 -0
- package/package.json +84 -0
- package/src/Component.ts +1498 -0
- package/src/Form.ts +189 -0
- package/src/Layout.ts +47 -0
- package/src/augment.ts +94 -0
- package/src/client/FlowClientError.ts +12 -0
- package/src/client/FlowComponent.ts +96 -0
- package/src/client/alpinejs-morph.d.ts +5 -0
- package/src/client/bridge.ts +3463 -0
- package/src/client/confirm.ts +338 -0
- package/src/client/cspAdapter.ts +79 -0
- package/src/client/cspEvaluator.ts +494 -0
- package/src/client/errorOverlay.ts +124 -0
- package/src/client/headless.ts +948 -0
- package/src/client/index.csp.ts +47 -0
- package/src/client/index.ts +56 -0
- package/src/client/optimistic.ts +52 -0
- package/src/client/timeline.ts +160 -0
- package/src/client/url.ts +51 -0
- package/src/commands/MakeFlowCommand.ts +290 -0
- package/src/compiler/cache.ts +101 -0
- package/src/compiler/directives.ts +107 -0
- package/src/compiler/helpers.ts +26 -0
- package/src/compiler/index.ts +172 -0
- package/src/compiler/transform.ts +2070 -0
- package/src/components.ts +1465 -0
- package/src/config.ts +52 -0
- package/src/decorators.ts +978 -0
- package/src/dehydrate.ts +333 -0
- package/src/durable.ts +243 -0
- package/src/events.ts +118 -0
- package/src/frameworkEvents.ts +40 -0
- package/src/headless.ts +1147 -0
- package/src/index.ts +267 -0
- package/src/jsx-dev-runtime.ts +14 -0
- package/src/jsx-runtime.ts +1272 -0
- package/src/mixins.ts +199 -0
- package/src/observability.ts +167 -0
- package/src/pagination.ts +300 -0
- package/src/presence.ts +81 -0
- package/src/provider/FlowProvider.ts +1401 -0
- package/src/registry.ts +118 -0
- package/src/renderContext.ts +55 -0
- package/src/router.ts +401 -0
- package/src/shared.ts +226 -0
- package/src/store.ts +116 -0
- package/src/synths/CarbonIntervalSynth.ts +22 -0
- package/src/synths/CarbonSynth.ts +20 -0
- package/src/synths/CollectionSynth.ts +62 -0
- package/src/synths/DateSynth.ts +24 -0
- package/src/synths/ModelSynth.ts +78 -0
- package/src/synths/TemporaryUploadedFileSynth.ts +19 -0
- package/src/synths/index.ts +148 -0
- package/src/testing.ts +361 -0
- package/src/types.ts +315 -0
- package/src/uploads/FileUploads.ts +94 -0
- package/src/uploads/TemporaryUploadedFile.ts +274 -0
- package/src/uploads/sign.ts +18 -0
- package/src/urlSafety.ts +0 -0
- package/src/utils.ts +216 -0
- package/src/validation.ts +55 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// ── Synthesizer registry ──────────────────────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// Each synthesizer knows how to:
|
|
4
|
+
// - match a JS value at dehydration time
|
|
5
|
+
// - convert it to a JSON-safe primitive + metadata object
|
|
6
|
+
// - reconstruct the original value from that primitive + metadata
|
|
7
|
+
//
|
|
8
|
+
// Pattern lifted directly from Livewire v3's synthesizer system.
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A synthesizer ("synth") teaches flow how to send a non-JSON type across the
|
|
12
|
+
* wire: it matches a live value, dehydrates it to a JSON-safe primitive plus
|
|
13
|
+
* metadata for the snapshot, and hydrates it back on the other side.
|
|
14
|
+
*
|
|
15
|
+
* @remarks
|
|
16
|
+
* This is the extension point behind first-class support for `Date`, `Carbon`,
|
|
17
|
+
* ORM models, collections, and {@link TemporaryUploadedFile}. During
|
|
18
|
+
* serialization the registry tries each synth's {@link match} in order; the first
|
|
19
|
+
* match's {@link dehydrate} produces the stored data and its {@link key} is
|
|
20
|
+
* written to `meta.s`. On the way back, {@link key} selects the synth whose
|
|
21
|
+
* {@link hydrate} reconstructs the value. `hydrate` may be async (e.g. a model
|
|
22
|
+
* synth re-fetches by id). The pattern is lifted from Livewire v3's synthesizers.
|
|
23
|
+
*
|
|
24
|
+
* @typeParam T - The live value type this synth handles.
|
|
25
|
+
*/
|
|
26
|
+
export interface Synth<T = unknown> {
|
|
27
|
+
/** Short tag stored as `meta.s` in the snapshot, e.g. `'cbn'`, `'mdl'`. */
|
|
28
|
+
key: string;
|
|
29
|
+
/** Type guard: whether this synth handles `value` (checked in registration order). */
|
|
30
|
+
match(value: unknown): value is T;
|
|
31
|
+
/** Serialize `value` to a JSON-safe form; may mutate `meta` to stash extra fields for hydrate. */
|
|
32
|
+
dehydrate(value: T, meta: Record<string, unknown>): unknown;
|
|
33
|
+
/** Reconstruct the live value from serialized `data` and its `meta` (may be async). */
|
|
34
|
+
hydrate(data: unknown, meta: Record<string, unknown>): T | Promise<T>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const _synths: Synth[] = [];
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Register a synthesizer so its type can cross the wire in snapshots. Newly
|
|
41
|
+
* registered synths are tried first, so a later registration can override an
|
|
42
|
+
* earlier (built-in) match.
|
|
43
|
+
*
|
|
44
|
+
* @param synth - The {@link Synth} to add to the registry.
|
|
45
|
+
*
|
|
46
|
+
* @example
|
|
47
|
+
* ```ts
|
|
48
|
+
* class Money { constructor(public cents: number, public currency: string) {} }
|
|
49
|
+
*
|
|
50
|
+
* registerSynth({
|
|
51
|
+
* key: "money",
|
|
52
|
+
* match: (v): v is Money => v instanceof Money,
|
|
53
|
+
* dehydrate: (v, meta) => { meta.cur = v.currency; return v.cents; },
|
|
54
|
+
* hydrate: (data, meta) => new Money(data as number, meta.cur as string),
|
|
55
|
+
* });
|
|
56
|
+
* ```
|
|
57
|
+
*/
|
|
58
|
+
export function registerSynth(synth: Synth): void {
|
|
59
|
+
_synths.unshift(synth); // user-registered synths take priority
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The registered synths, in match order (most-recently-registered first). */
|
|
63
|
+
export function getSynths(): readonly Synth[] {
|
|
64
|
+
return _synths;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Serialize a single value through the synth registry, recursing into plain
|
|
69
|
+
* arrays and objects (each element/property stored as its own `[data, meta]`
|
|
70
|
+
* tuple so per-element synth metadata is preserved).
|
|
71
|
+
*
|
|
72
|
+
* @param value - The value to serialize.
|
|
73
|
+
* @returns A `[data, meta]` tuple; `meta.s` names the synth that matched, if any.
|
|
74
|
+
*/
|
|
75
|
+
export function serializeValue(value: unknown): [data: unknown, meta: Record<string, unknown>] {
|
|
76
|
+
const meta: Record<string, unknown> = {};
|
|
77
|
+
|
|
78
|
+
for (const synth of _synths) {
|
|
79
|
+
if (synth.match(value)) {
|
|
80
|
+
const data = synth.dehydrate(value as never, meta);
|
|
81
|
+
meta["s"] = synth.key;
|
|
82
|
+
return [data, meta];
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Recursive: plain arrays and objects.
|
|
87
|
+
// Store full [data, meta] tuples per element so per-element synth metadata
|
|
88
|
+
// (e.g. { s: 'mdl', class: 'Post' } for ORM models, Date, etc.) is preserved.
|
|
89
|
+
if (Array.isArray(value)) {
|
|
90
|
+
return [value.map((v) => serializeValue(v)), meta];
|
|
91
|
+
}
|
|
92
|
+
if (typeof value === "object" && value !== null) {
|
|
93
|
+
const obj: Record<string, unknown> = {};
|
|
94
|
+
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
95
|
+
obj[k] = serializeValue(v);
|
|
96
|
+
}
|
|
97
|
+
return [obj, meta];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return [value, meta];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Deserialize a `[data, meta]` tuple produced by {@link serializeValue} back into
|
|
105
|
+
* a live value, recursing into plain arrays and objects.
|
|
106
|
+
*
|
|
107
|
+
* @param data - The serialized data.
|
|
108
|
+
* @param meta - Its metadata; `meta.s`, when present, selects the synth to hydrate with.
|
|
109
|
+
* @returns The reconstructed value.
|
|
110
|
+
* @throws Error if `meta.s` names a synth key that isn't registered.
|
|
111
|
+
*/
|
|
112
|
+
export async function deserializeValue(
|
|
113
|
+
data: unknown,
|
|
114
|
+
meta: Record<string, unknown>,
|
|
115
|
+
): Promise<unknown> {
|
|
116
|
+
const synthKey = meta["s"] as string | undefined;
|
|
117
|
+
if (synthKey) {
|
|
118
|
+
const synth = _synths.find((s) => s.key === synthKey);
|
|
119
|
+
if (!synth) throw new Error(`[Flow] Unknown synth key: "${synthKey}"`);
|
|
120
|
+
return synth.hydrate(data, meta);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Recursive: plain arrays/objects (no synth key).
|
|
124
|
+
// Each element/value is a [data, meta] tuple stored by serializeValue.
|
|
125
|
+
if (Array.isArray(data)) {
|
|
126
|
+
return Promise.all(
|
|
127
|
+
data.map((item) =>
|
|
128
|
+
Array.isArray(item) && item.length === 2
|
|
129
|
+
? deserializeValue(item[0], item[1] as Record<string, unknown>)
|
|
130
|
+
: deserializeValue(item, {}),
|
|
131
|
+
),
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
if (typeof data === "object" && data !== null) {
|
|
135
|
+
const result: Record<string, unknown> = {};
|
|
136
|
+
await Promise.all(
|
|
137
|
+
Object.entries(data as Record<string, unknown>).map(async ([k, v]) => {
|
|
138
|
+
result[k] =
|
|
139
|
+
Array.isArray(v) && v.length === 2
|
|
140
|
+
? await deserializeValue(v[0], v[1] as Record<string, unknown>)
|
|
141
|
+
: await deserializeValue(v, {});
|
|
142
|
+
}),
|
|
143
|
+
);
|
|
144
|
+
return result;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return data;
|
|
148
|
+
}
|
package/src/testing.ts
ADDED
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@zerotal/flow/testing` — in-process test helpers for Flow components.
|
|
3
|
+
*
|
|
4
|
+
* Runs the full server-side Flow request pipeline in-process for Bun's test runner:
|
|
5
|
+
* no WebSocket connection is opened — actions are dispatched directly against a mounted
|
|
6
|
+
* page instance, driving its real lifecycle hooks (boot/mount/hydrate/update). The entry
|
|
7
|
+
* point is {@link FlowTest.mount}, which returns a chainable {@link FlowTest} exposing
|
|
8
|
+
* state-driving actions (`set`/`update`/`call`) and fluent assertions (`assertSee`,
|
|
9
|
+
* `assertRedirectedTo`, `assertHasErrors`, `assertFlashed`, …). `hydrate` is re-exported
|
|
10
|
+
* for advanced snapshot-level tests.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* ```ts
|
|
14
|
+
* import { FlowTest } from '@zerotal/flow/testing';
|
|
15
|
+
*
|
|
16
|
+
* const t = await FlowTest.mount(LoginPage);
|
|
17
|
+
* await t.set('email', 'test@example.com');
|
|
18
|
+
* await t.set('password', 'secret');
|
|
19
|
+
* await t.call('login');
|
|
20
|
+
* t.assertRedirectedTo('/dashboard');
|
|
21
|
+
* ```
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* ```ts
|
|
25
|
+
* const t = await FlowTest.mount(CreatePostPage);
|
|
26
|
+
* await t.call('save');
|
|
27
|
+
* t.assertHasErrors('title');
|
|
28
|
+
* t.assertSee('The title field is required.');
|
|
29
|
+
* ```
|
|
30
|
+
*
|
|
31
|
+
* @packageDocumentation
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import type { Component } from "./Component.ts";
|
|
35
|
+
import type { FlowEffects } from "./Component.ts";
|
|
36
|
+
import { _renderFlowPage } from "./jsx-runtime.ts";
|
|
37
|
+
import { dehydrate, hydrate } from "./dehydrate.ts";
|
|
38
|
+
import { ValidationError } from "./validation.ts";
|
|
39
|
+
import type { Snapshot } from "./types.ts";
|
|
40
|
+
|
|
41
|
+
type PageClass<T extends Component> = new () => T;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A fluent test harness around a single mounted Flow page/component. Obtain one with
|
|
45
|
+
* {@link FlowTest.mount}, drive state with `set`/`update`/`call`, then chain assertions
|
|
46
|
+
* against the rendered HTML, redirects, validation errors, flashes, and dispatched events.
|
|
47
|
+
* Every action and assertion returns `this` (or a Promise of it) for chaining.
|
|
48
|
+
*
|
|
49
|
+
* @typeParam T - The Component subclass under test.
|
|
50
|
+
*/
|
|
51
|
+
export class FlowTest<T extends Component> {
|
|
52
|
+
private _page: T;
|
|
53
|
+
private _html: string = "";
|
|
54
|
+
private _effects: FlowEffects | null = null;
|
|
55
|
+
private _snapshot: Snapshot | null = null;
|
|
56
|
+
private _name: string;
|
|
57
|
+
|
|
58
|
+
private constructor(page: T, name: string) {
|
|
59
|
+
this._page = page;
|
|
60
|
+
this._name = name;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ── Factory ────────────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Mount a page: instantiate it, seed optional props, run its initial-request lifecycle
|
|
67
|
+
* (`onBoot()` then `onMount()`), render once, and return the harness for chaining.
|
|
68
|
+
*
|
|
69
|
+
* @param PageClass - The Component subclass to mount.
|
|
70
|
+
* @param props - Optional initial property values assigned before `onMount()` runs.
|
|
71
|
+
* @returns A resolved {@link FlowTest} wrapping the mounted page.
|
|
72
|
+
* @category Lifecycle
|
|
73
|
+
*
|
|
74
|
+
* @example
|
|
75
|
+
* ```ts
|
|
76
|
+
* const t = await FlowTest.mount(DashboardPage);
|
|
77
|
+
* ```
|
|
78
|
+
*/
|
|
79
|
+
static async mount<T extends Component>(
|
|
80
|
+
PageClass: PageClass<T>,
|
|
81
|
+
props: Partial<T> = {},
|
|
82
|
+
): Promise<FlowTest<T>> {
|
|
83
|
+
const page = new PageClass();
|
|
84
|
+
page._flowId = `test-${PageClass.name.toLowerCase()}`;
|
|
85
|
+
page._flowPath = "/test";
|
|
86
|
+
|
|
87
|
+
Object.assign(page, props);
|
|
88
|
+
// Initial request lifecycle: boot() runs on every request, then mount() once.
|
|
89
|
+
await page.onBoot();
|
|
90
|
+
await page.onMount();
|
|
91
|
+
|
|
92
|
+
const t = new FlowTest<T>(page, PageClass.name);
|
|
93
|
+
await t._render();
|
|
94
|
+
return t;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── Actions ────────────────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Set a property value directly (state seeding), then return for chaining.
|
|
101
|
+
* Does NOT fire the updating/updated hooks — use `update()` to simulate a
|
|
102
|
+
* client-driven property change.
|
|
103
|
+
* @category Actions
|
|
104
|
+
*/
|
|
105
|
+
async set(prop: keyof T, value: unknown): Promise<this> {
|
|
106
|
+
(this._page as Record<string, unknown>)[prop as string] = value;
|
|
107
|
+
return this;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Simulate a client-driven property update (as a `value`/`checked` input or
|
|
112
|
+
* `$flow.$set` would produce): routes through the allowlist and fires the
|
|
113
|
+
* `onUpdating()` / `onUpdated()` hooks (and their per-property variants), then
|
|
114
|
+
* re-renders.
|
|
115
|
+
* @category Actions
|
|
116
|
+
*/
|
|
117
|
+
async update(prop: keyof T, value: unknown): Promise<this> {
|
|
118
|
+
await this._page._applyClientUpdate(prop as string, value);
|
|
119
|
+
await this._render();
|
|
120
|
+
return this;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Call an action method and re-render. Simulates a subsequent (WebSocket) request:
|
|
125
|
+
* runs `onBoot()` + `onHydrate()`, invokes the method, then `onUpdate()` and re-render.
|
|
126
|
+
* If the action throws ValidationError, the errors are stored and the page re-renders
|
|
127
|
+
* with the error bag populated; any other thrown error is routed to `onError()`.
|
|
128
|
+
*
|
|
129
|
+
* @param method - Name of the method to invoke on the page.
|
|
130
|
+
* @param args - Arguments forwarded to the method.
|
|
131
|
+
* @throws If `method` is not a function on the page instance.
|
|
132
|
+
* @category Actions
|
|
133
|
+
*/
|
|
134
|
+
async call(method: string, ...args: unknown[]): Promise<this> {
|
|
135
|
+
const fn = (this._page as Record<string, unknown>)[method];
|
|
136
|
+
if (typeof fn !== "function") {
|
|
137
|
+
throw new Error(`[FlowTest] Method "${method}" not found on ${this._name}`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Each call simulates a subsequent (WebSocket) request: boot() then hydrate().
|
|
141
|
+
await this._page.onBoot();
|
|
142
|
+
await this._page.onHydrate();
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
await (fn as (...a: unknown[]) => unknown).apply(this._page, args);
|
|
146
|
+
} catch (error) {
|
|
147
|
+
if (error instanceof ValidationError) {
|
|
148
|
+
// Errors already stored on page._errors
|
|
149
|
+
} else {
|
|
150
|
+
try {
|
|
151
|
+
await this._page.onError(error instanceof Error ? error : new Error(String(error)));
|
|
152
|
+
} catch {
|
|
153
|
+
// ignore
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
await this._page.onUpdate();
|
|
159
|
+
this._effects = this._page._drainEffects();
|
|
160
|
+
await this._render();
|
|
161
|
+
return this;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ── Render ─────────────────────────────────────────────────────────────────
|
|
165
|
+
|
|
166
|
+
private async _render(): Promise<void> {
|
|
167
|
+
const compId = this._page._flowId;
|
|
168
|
+
|
|
169
|
+
if (!this._effects) {
|
|
170
|
+
this._html = await _renderFlowPage(this._page, () => this._page.render()).catch(() => "");
|
|
171
|
+
} else {
|
|
172
|
+
if (!this._effects.redirectUrl) {
|
|
173
|
+
this._html = await _renderFlowPage(this._page, () => this._page.render()).catch(() => "");
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// dehydrate() hook runs at the end of the request, before serialisation.
|
|
178
|
+
await this._page.onDehydrate();
|
|
179
|
+
this._snapshot = dehydrate(this._page, {
|
|
180
|
+
id: compId,
|
|
181
|
+
name: this._name,
|
|
182
|
+
path: this._page._flowPath,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ── HTML assertions ────────────────────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Assert the rendered HTML contains the given string.
|
|
190
|
+
* @category Assertions
|
|
191
|
+
*/
|
|
192
|
+
assertSee(text: string): this {
|
|
193
|
+
if (!this._html.includes(text)) {
|
|
194
|
+
throw new Error(
|
|
195
|
+
`[FlowTest] Expected to see "${text}" but it was not found in:\n${this._html}`,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
return this;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Assert the rendered HTML does NOT contain the given string.
|
|
203
|
+
* @category Assertions
|
|
204
|
+
*/
|
|
205
|
+
assertDontSee(text: string): this {
|
|
206
|
+
if (this._html.includes(text)) {
|
|
207
|
+
throw new Error(`[FlowTest] Expected NOT to see "${text}" but it was found in the HTML.`);
|
|
208
|
+
}
|
|
209
|
+
return this;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ── Redirect assertions ────────────────────────────────────────────────────
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Assert the last action redirected to the given URL.
|
|
216
|
+
* @category Assertions
|
|
217
|
+
*/
|
|
218
|
+
assertRedirectedTo(url: string): this {
|
|
219
|
+
const actual = this._effects?.redirectUrl;
|
|
220
|
+
if (actual !== url) {
|
|
221
|
+
throw new Error(`[FlowTest] Expected redirect to "${url}" but got: ${actual ?? "(none)"}`);
|
|
222
|
+
}
|
|
223
|
+
return this;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Assert the last action did NOT redirect.
|
|
228
|
+
* @category Assertions
|
|
229
|
+
*/
|
|
230
|
+
assertNotRedirected(): this {
|
|
231
|
+
if (this._effects?.redirectUrl) {
|
|
232
|
+
throw new Error(`[FlowTest] Expected no redirect but got: ${this._effects.redirectUrl}`);
|
|
233
|
+
}
|
|
234
|
+
return this;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ── Validation assertions ──────────────────────────────────────────────────
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Assert that a validation error exists for the given field, optionally that one of
|
|
241
|
+
* its messages contains `message`.
|
|
242
|
+
* @category Assertions
|
|
243
|
+
*/
|
|
244
|
+
assertHasErrors(field: string, message?: string): this {
|
|
245
|
+
const errors = this._page._errors;
|
|
246
|
+
if (!errors[field] || errors[field]!.length === 0) {
|
|
247
|
+
throw new Error(`[FlowTest] Expected validation error for "${field}" but none found.`);
|
|
248
|
+
}
|
|
249
|
+
if (message && !errors[field]!.some((m) => m.includes(message))) {
|
|
250
|
+
throw new Error(
|
|
251
|
+
`[FlowTest] Expected error for "${field}" to contain "${message}" but got: ${errors[field]!.join(", ")}`,
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
return this;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Assert no validation errors exist.
|
|
259
|
+
* @category Assertions
|
|
260
|
+
*/
|
|
261
|
+
assertNoErrors(): this {
|
|
262
|
+
const count = Object.keys(this._page._errors).length;
|
|
263
|
+
if (count > 0) {
|
|
264
|
+
const summary = Object.entries(this._page._errors)
|
|
265
|
+
.map(([f, msgs]) => ` ${f}: ${msgs.join(", ")}`)
|
|
266
|
+
.join("\n");
|
|
267
|
+
throw new Error(`[FlowTest] Expected no validation errors but found:\n${summary}`);
|
|
268
|
+
}
|
|
269
|
+
return this;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ── Flash assertions ───────────────────────────────────────────────────────
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* The full flash payloads emitted by the last action (title/position/duration/…).
|
|
276
|
+
* @category Assertions
|
|
277
|
+
*/
|
|
278
|
+
flashes(): FlowEffects["flashes"] {
|
|
279
|
+
return this._effects?.flashes ?? [];
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Assert a flash notification was emitted, optionally filtered by `level` and/or a
|
|
284
|
+
* substring of its `message`.
|
|
285
|
+
* @category Assertions
|
|
286
|
+
*/
|
|
287
|
+
assertFlashed(level?: string, message?: string): this {
|
|
288
|
+
const flashes = this._effects?.flashes ?? [];
|
|
289
|
+
const match = flashes.find(
|
|
290
|
+
(f) => (!level || f.level === level) && (!message || f.message.includes(message)),
|
|
291
|
+
);
|
|
292
|
+
if (!match) {
|
|
293
|
+
const desc = [level, message].filter(Boolean).join("/");
|
|
294
|
+
throw new Error(
|
|
295
|
+
`[FlowTest] Expected flash "${desc}" but flashes were: ${JSON.stringify(flashes)}`,
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
return this;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ── Event assertions ───────────────────────────────────────────────────────
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Assert a cross-component event was dispatched by the last action.
|
|
305
|
+
* @category Assertions
|
|
306
|
+
*/
|
|
307
|
+
assertDispatched(eventName: string): this {
|
|
308
|
+
const events = this._effects?.events ?? [];
|
|
309
|
+
if (!events.some((e) => e.name === eventName)) {
|
|
310
|
+
throw new Error(
|
|
311
|
+
`[FlowTest] Expected event "${eventName}" to be dispatched but it wasn't. Events: ${events.map((e) => e.name).join(", ") || "(none)"}`,
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
return this;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// ── State accessors ────────────────────────────────────────────────────────
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Get the rendered HTML.
|
|
321
|
+
* @category State
|
|
322
|
+
*/
|
|
323
|
+
html(): string {
|
|
324
|
+
return this._html;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Get the last drained effects (flashes, events, redirect, downloads).
|
|
329
|
+
* @category State
|
|
330
|
+
*/
|
|
331
|
+
effects(): FlowEffects | null {
|
|
332
|
+
return this._effects;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Get the current page state snapshot.
|
|
337
|
+
* @category State
|
|
338
|
+
*/
|
|
339
|
+
snapshot(): Snapshot | null {
|
|
340
|
+
return this._snapshot;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Get the current page instance (for direct property inspection).
|
|
345
|
+
* @category State
|
|
346
|
+
*/
|
|
347
|
+
page(): T {
|
|
348
|
+
return this._page;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Get current validation errors.
|
|
353
|
+
* @category State
|
|
354
|
+
*/
|
|
355
|
+
errors(): Record<string, string[]> {
|
|
356
|
+
return this._page._errors;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// ── Re-export hydrate for advanced use ────────────────────────────────────────
|
|
361
|
+
export { hydrate } from "./dehydrate.ts";
|