mirinjs 0.1.0-alpha.2 → 0.1.0-alpha.20

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.
Files changed (54) hide show
  1. package/package.json +4 -2
  2. package/src/{app.ts → app/facade.ts} +73 -119
  3. package/src/app/index.ts +2 -0
  4. package/src/app/lib/emitter.ts +40 -0
  5. package/src/app/lib/material.ts +9 -0
  6. package/src/app/types.ts +65 -0
  7. package/src/check/define.ts +24 -0
  8. package/src/check/index.ts +2 -0
  9. package/src/check/types.ts +95 -0
  10. package/src/client.ts +12 -11
  11. package/src/config/index.ts +1 -0
  12. package/src/{config.ts → config/manifest.ts} +152 -6
  13. package/src/devtools/devtools.ts +213 -0
  14. package/src/devtools/index.ts +11 -0
  15. package/src/devtools/lib/actions.ts +185 -0
  16. package/src/devtools/lib/ax.ts +139 -0
  17. package/src/devtools/lib/capture.ts +203 -0
  18. package/src/devtools/lib/cdp.ts +307 -0
  19. package/src/devtools/lib/http.ts +73 -0
  20. package/src/devtools/lib/inspector.ts +208 -0
  21. package/src/devtools/lib/network.ts +185 -0
  22. package/src/devtools/lib/parse.ts +49 -0
  23. package/src/devtools/lib/query.ts +102 -0
  24. package/src/devtools/lib/renderer-taps.ts +186 -0
  25. package/src/devtools/lib/routes-cdp.ts +280 -0
  26. package/src/devtools/lib/state.ts +85 -0
  27. package/src/devtools/lib/taps.ts +168 -0
  28. package/src/devtools/options.ts +109 -0
  29. package/src/devtools/session.ts +264 -0
  30. package/src/devtools/sink.ts +227 -0
  31. package/src/devtools/types.ts +88 -0
  32. package/src/dialog.ts +1 -1
  33. package/src/host.ts +34 -14
  34. package/src/index.ts +49 -31
  35. package/src/logger.ts +30 -5
  36. package/src/menu.ts +17 -4
  37. package/src/native.ts +17 -5
  38. package/src/notification.ts +19 -0
  39. package/src/rpc-server.ts +131 -4
  40. package/src/rpc.ts +4 -5
  41. package/src/runtime.ts +57 -11
  42. package/src/shared/format.ts +62 -0
  43. package/src/shortcut.ts +1 -1
  44. package/src/sidecar.ts +13 -6
  45. package/src/tray.ts +1 -1
  46. package/src/updater/index.ts +2 -0
  47. package/src/updater/lib/apply.ts +116 -0
  48. package/src/updater/lib/archive.ts +41 -0
  49. package/src/updater/lib/integrity.ts +70 -0
  50. package/src/updater/lib/manifest.ts +119 -0
  51. package/src/updater/lib/platform.ts +19 -0
  52. package/src/updater/lib/urls.ts +44 -0
  53. package/src/updater/types.ts +66 -0
  54. package/src/{updater.ts → updater/updater.ts} +83 -156
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mirinjs",
3
- "version": "0.1.0-alpha.2",
3
+ "version": "0.1.0-alpha.20",
4
4
  "description": "Build desktop apps with Bun, TypeScript, and Chromium (CEF). The Bun-native Electron alternative.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -23,7 +23,9 @@
23
23
  ],
24
24
  "exports": {
25
25
  ".": "./src/index.ts",
26
- "./config": "./src/config.ts",
26
+ "./config": "./src/config/index.ts",
27
+ "./check": "./src/check/index.ts",
28
+ "./devtools/session": "./src/devtools/session.ts",
27
29
  "./rpc": "./src/rpc.ts",
28
30
  "./client": "./src/client.ts",
29
31
  "./codec": "./src/codec.ts",
@@ -3,102 +3,59 @@
3
3
  * (docs/api-design.md). Talks to the native core through the runtime.
4
4
  */
5
5
 
6
- import { runtime, onNativeEvent, resolveUrl, type NativeEvent } from "./runtime.ts";
7
- import type { Router, EventProc } from "./rpc.ts";
8
- import type { WindowConfig, WindowMaterial, WindowMaterialOptions } from "./config.ts";
9
- import { updater } from "./updater.ts";
10
- import type { Updater } from "./updater.ts";
11
- import { sidecar as spawnSidecar } from "./sidecar.ts";
12
- import type { SidecarOptions, SidecarProcess } from "./sidecar.ts";
13
-
14
- /** Which native backend a window's `material` resolved to. */
15
- export type WindowMaterialInfo = {
16
- /** The material that was requested. */
17
- requested: string;
18
- /** What actually rendered: real Liquid Glass, a vibrancy material, or none. */
19
- backend: "liquidGlass" | "vibrancy" | "none";
20
- /** Whether Apple's Liquid Glass (NSGlassEffectView, macOS 26+) is available. */
21
- liquidGlassAvailable: boolean;
22
- };
23
-
24
- export type WindowEvents = {
25
- focus: void;
26
- blur: void;
27
- moved: void;
28
- resized: void;
29
- closed: void;
30
- /** Fired when a native background material is applied (see setMaterial). */
31
- material: WindowMaterialInfo;
32
- };
33
-
34
- export type AppEvents = {
35
- ready: void;
36
- "window-all-closed": void;
37
- /** A deep-link URL (a registered `urlSchemes` scheme) opened the app, or
38
- * arrived while it was running. Includes the launch URL. */
39
- "open-url": string;
40
- };
41
-
42
- /** A window's frame in screen points (bottom-left origin, like AppKit). */
43
- export interface WindowFrame {
44
- x: number;
45
- y: number;
46
- width: number;
47
- height: number;
48
- }
49
-
50
- type Listener<P> = (payload: P) => void;
51
-
52
- class Emitter<Events extends Record<string, unknown>> {
53
- #listeners = new Map<keyof Events, Set<Listener<unknown>>>();
6
+ import type { WindowMaterial, WindowMaterialOptions } from "../config/index.ts";
7
+ import type { Router } from "../rpc.ts";
8
+ import { type NativeEvent, onNativeEvent, resolveUrl, runtime } from "../runtime.ts";
9
+ import type { SidecarOptions, SidecarProcess } from "../sidecar.ts";
10
+ import { sidecar as spawnSidecar } from "../sidecar.ts";
11
+ import type { Updater } from "../updater/index.ts";
12
+ import { updater } from "../updater/index.ts";
13
+ import { Emitter } from "./lib/emitter.ts";
14
+ import { normalizeMaterial } from "./lib/material.ts";
15
+ import type {
16
+ AppEvents,
17
+ BroadcastEmitters,
18
+ Dock,
19
+ ServeHandle,
20
+ WindowEvents,
21
+ WindowFrame,
22
+ WindowMaterialInfo,
23
+ WindowOpenOptions,
24
+ } from "./types.ts";
54
25
 
55
- on<K extends keyof Events>(type: K, listener: Listener<Events[K]>): () => void {
56
- let set = this.#listeners.get(type);
57
- if (!set) this.#listeners.set(type, (set = new Set()));
58
- set.add(listener as Listener<unknown>);
59
- return () => set!.delete(listener as Listener<unknown>);
60
- }
26
+ /** A live, typed handle to an open window. */
27
+ export class WindowHandle extends Emitter<WindowEvents> {
28
+ readonly id: number;
29
+ /** Always set: a window opened without a name is given one from its id. */
30
+ readonly name: string;
31
+ #url: string;
32
+ #title: string | undefined;
61
33
 
62
- async *events<K extends keyof Events>(type: K): AsyncIterableIterator<Events[K]> {
63
- const queue: Events[K][] = [];
64
- let wake: (() => void) | undefined;
65
- const off = this.on(type, (payload) => {
66
- queue.push(payload);
67
- wake?.();
68
- });
69
- try {
70
- while (true) {
71
- while (queue.length) yield queue.shift()!;
72
- await new Promise<void>((resolve) => (wake = resolve));
73
- }
74
- } finally {
75
- off();
76
- }
34
+ constructor(id: number, name: string, url = "", title?: string) {
35
+ super();
36
+ this.id = id;
37
+ this.name = name;
38
+ this.#url = url;
39
+ this.#title = title;
77
40
  }
78
41
 
79
- protected emit<K extends keyof Events>(type: K, payload: Events[K]): void {
80
- this.#listeners.get(type)?.forEach((fn) => fn(payload));
42
+ /** The URL this window was last asked to load. */
43
+ get url(): string {
44
+ return this.#url;
81
45
  }
82
- }
83
-
84
- export interface WindowOpenOptions extends WindowConfig {
85
- name?: string;
86
- }
87
46
 
88
- /** A live, typed handle to an open window. */
89
- export class WindowHandle extends Emitter<WindowEvents> {
90
- constructor(
91
- readonly id: number,
92
- readonly name: string | undefined,
93
- ) {
94
- super();
47
+ /** The title this window was last given, if the app set one. */
48
+ get title(): string | undefined {
49
+ return this.#title;
95
50
  }
96
51
 
97
52
  async setTitle(title: string): Promise<void> {
53
+ this.#title = title;
98
54
  runtime().core.windowSetTitle(this.id, title);
99
55
  }
100
56
 
101
57
  async loadUrl(url: string): Promise<void> {
58
+ this.#url = url;
102
59
  runtime().core.windowLoadUrl(this.id, url);
103
60
  }
104
61
 
@@ -182,7 +139,10 @@ export class WindowHandle extends Emitter<WindowEvents> {
182
139
  }
183
140
  }
184
141
 
185
- type RpcEmitters = Record<string, { emit(payload: unknown): void; broadcast(payload: unknown): void }>;
142
+ type RpcEmitters = Record<
143
+ string,
144
+ { emit(payload: unknown): void; broadcast(payload: unknown): void }
145
+ >;
186
146
 
187
147
  function rpcEmitters(send: (method: string, payload: unknown) => void): RpcEmitters {
188
148
  return new Proxy({} as RpcEmitters, {
@@ -195,20 +155,6 @@ function rpcEmitters(send: (method: string, payload: unknown) => void): RpcEmitt
195
155
  });
196
156
  }
197
157
 
198
- /** Typed push emitters derived from a router's `event` procedures. */
199
- export type BroadcastEmitters<R extends Router<any>> =
200
- R extends Router<infer T>
201
- ? {
202
- [K in keyof T as T[K] extends EventProc<any> ? K : never]: T[K] extends EventProc<infer P>
203
- ? { broadcast(payload: P): void }
204
- : never;
205
- }
206
- : never;
207
-
208
- export interface ServeHandle<R extends Router<any>> {
209
- readonly rpc: BroadcastEmitters<R>;
210
- }
211
-
212
158
  class Windows {
213
159
  #byName = new Map<string, WindowHandle>();
214
160
  #byId = new Map<number, WindowHandle>();
@@ -230,21 +176,24 @@ class Windows {
230
176
 
231
177
  async open(options: WindowOpenOptions | string): Promise<WindowHandle> {
232
178
  const opts = typeof options === "string" ? manifestWindow(options) : options;
179
+ const url = resolveUrl(opts.url);
233
180
  const id = runtime().core.windowCreate(
234
181
  JSON.stringify({
235
182
  ...opts,
236
- url: resolveUrl(opts.url),
183
+ url,
237
184
  material: normalizeMaterial(opts.material),
238
185
  }),
239
186
  );
240
- const handle = new WindowHandle(id, opts.name);
187
+ // Fall back to an id-derived name so every window is addressable by name, not
188
+ // just the ones declared in the manifest.
189
+ const handle = new WindowHandle(id, opts.name ?? `window-${id}`, url, opts.title);
241
190
  this.#register(handle);
242
191
  return handle;
243
192
  }
244
193
 
245
194
  #register(handle: WindowHandle): void {
246
195
  this.#byId.set(handle.id, handle);
247
- if (handle.name) this.#byName.set(handle.name, handle);
196
+ this.#byName.set(handle.name, handle);
248
197
  }
249
198
 
250
199
  /** @internal */
@@ -256,7 +205,8 @@ class Windows {
256
205
  const handle = this.#byId.get(id);
257
206
  if (handle) {
258
207
  this.#byId.delete(id);
259
- if (handle.name) this.#byName.delete(handle.name);
208
+ // Only if it is still the binding: a later window may have taken the name.
209
+ if (this.#byName.get(handle.name) === handle) this.#byName.delete(handle.name);
260
210
  }
261
211
  }
262
212
  }
@@ -267,28 +217,31 @@ function manifestWindow(name: string): WindowOpenOptions {
267
217
  return found;
268
218
  }
269
219
 
270
- /** Normalize the `material` option (name or object, or null) to native form. */
271
- function normalizeMaterial(
272
- material: WindowMaterial | WindowMaterialOptions | null | undefined,
273
- ): WindowMaterialOptions | null {
274
- if (!material) return null;
275
- return typeof material === "string" ? { type: material } : material;
276
- }
277
-
278
- /** macOS Dock-icon controls (no-ops off macOS). */
279
- export interface Dock {
280
- /** Hide the Dock icon and menu-bar presence (agent/accessory app). */
281
- hide(): void;
282
- /** Restore the Dock icon and menu bar. */
283
- show(): void;
284
- }
285
-
286
220
  class MirinApp extends Emitter<AppEvents> {
287
221
  readonly windows = new Windows();
288
222
 
289
- /** Auto-updater (macOS). Inert unless the app was built with `release` set. */
223
+ /** Cross-platform auto-updater. Inert unless the app was built with `release` set. */
290
224
  readonly updater: Updater = updater;
291
225
 
226
+ /** This app's bundle id (mirin.config `id`). `undefined` when run detached. */
227
+ get id(): string | undefined {
228
+ try {
229
+ return runtime().id;
230
+ } catch {
231
+ return undefined;
232
+ }
233
+ }
234
+
235
+ /** True under `mirin dev`; false in a packaged build (and when detached). Use it
236
+ * to keep dev and installed data (caches, databases) separate. */
237
+ get isDev(): boolean {
238
+ try {
239
+ return runtime().isDev;
240
+ } catch {
241
+ return false;
242
+ }
243
+ }
244
+
292
245
  /** macOS Dock-icon controls. Hiding suits resident, hotkey-summoned apps. */
293
246
  readonly dock: Dock = {
294
247
  hide: () => this.#setDock(false),
@@ -309,6 +262,7 @@ class MirinApp extends Emitter<AppEvents> {
309
262
  }
310
263
  }
311
264
 
265
+ /** Type-level `any` preserves the caller's concrete router shape. */
312
266
  serve<R extends Router<any>>(router: R): ServeHandle<R> {
313
267
  runtime().rpc.setRouter(router);
314
268
  return { rpc: this.rpc as BroadcastEmitters<R> };
@@ -0,0 +1,2 @@
1
+ export * from "./facade.ts";
2
+ export * from "./types.ts";
@@ -0,0 +1,40 @@
1
+ type Listener<P> = (payload: P) => void;
2
+
3
+ export class Emitter<Events extends Record<string, unknown>> {
4
+ #listeners = new Map<keyof Events, Set<Listener<unknown>>>();
5
+
6
+ on<K extends keyof Events>(type: K, listener: Listener<Events[K]>): () => void {
7
+ let set = this.#listeners.get(type);
8
+ if (!set) {
9
+ set = new Set();
10
+ this.#listeners.set(type, set);
11
+ }
12
+ set.add(listener as Listener<unknown>);
13
+ return () => {
14
+ set.delete(listener as Listener<unknown>);
15
+ };
16
+ }
17
+
18
+ async *events<K extends keyof Events>(type: K): AsyncIterableIterator<Events[K]> {
19
+ const queue: Events[K][] = [];
20
+ let wake: (() => void) | undefined;
21
+ const off = this.on(type, (payload) => {
22
+ queue.push(payload);
23
+ wake?.();
24
+ });
25
+ try {
26
+ while (true) {
27
+ while (queue.length) yield queue.shift()!;
28
+ await new Promise<void>((resolve) => {
29
+ wake = resolve;
30
+ });
31
+ }
32
+ } finally {
33
+ off();
34
+ }
35
+ }
36
+
37
+ protected emit<K extends keyof Events>(type: K, payload: Events[K]): void {
38
+ for (const fn of this.#listeners.get(type) ?? []) fn(payload);
39
+ }
40
+ }
@@ -0,0 +1,9 @@
1
+ import type { WindowMaterial, WindowMaterialOptions } from "../../config/index.ts";
2
+
3
+ /** Normalize the `material` option (name or object, or null) to native form. */
4
+ export function normalizeMaterial(
5
+ material: WindowMaterial | WindowMaterialOptions | null | undefined,
6
+ ): WindowMaterialOptions | null {
7
+ if (!material) return null;
8
+ return typeof material === "string" ? { type: material } : material;
9
+ }
@@ -0,0 +1,65 @@
1
+ import type { WindowConfig } from "../config/index.ts";
2
+ import type { EventProc, Router } from "../rpc.ts";
3
+
4
+ /** Which native backend a window's `material` resolved to. */
5
+ export type WindowMaterialInfo = {
6
+ /** The material that was requested. */
7
+ requested: string;
8
+ /** What actually rendered: real Liquid Glass, a vibrancy material, or none. */
9
+ backend: "liquidGlass" | "vibrancy" | "none";
10
+ /** Whether Apple's Liquid Glass (NSGlassEffectView, macOS 26+) is available. */
11
+ liquidGlassAvailable: boolean;
12
+ };
13
+
14
+ export type WindowEvents = {
15
+ focus: void;
16
+ blur: void;
17
+ moved: void;
18
+ resized: void;
19
+ closed: void;
20
+ /** Fired when a native background material is applied (see setMaterial). */
21
+ material: WindowMaterialInfo;
22
+ };
23
+
24
+ export type AppEvents = {
25
+ ready: void;
26
+ "window-all-closed": void;
27
+ /** A deep-link URL (a registered `urlSchemes` scheme) opened the app, or
28
+ * arrived while it was running. Includes the launch URL. */
29
+ "open-url": string;
30
+ };
31
+
32
+ /** A window's frame in screen points (bottom-left origin, like AppKit). */
33
+ export interface WindowFrame {
34
+ x: number;
35
+ y: number;
36
+ width: number;
37
+ height: number;
38
+ }
39
+
40
+ export interface WindowOpenOptions extends WindowConfig {
41
+ name?: string;
42
+ }
43
+
44
+ /** Typed push emitters derived from a router's `event` procedures.
45
+ * Type-level `any` keeps the caller's router payload inference intact. */
46
+ export type BroadcastEmitters<R extends Router<any>> = R extends Router<infer T>
47
+ ? {
48
+ [K in keyof T as T[K] extends EventProc<any> ? K : never]: T[K] extends EventProc<infer P>
49
+ ? { broadcast(payload: P): void }
50
+ : never;
51
+ }
52
+ : never;
53
+
54
+ /** Type-level `any` preserves the caller's concrete router shape. */
55
+ export interface ServeHandle<R extends Router<any>> {
56
+ readonly rpc: BroadcastEmitters<R>;
57
+ }
58
+
59
+ /** macOS Dock-icon controls (no-ops off macOS). */
60
+ export interface Dock {
61
+ /** Hide the Dock icon and menu-bar presence (agent/accessory app). */
62
+ hide(): void;
63
+ /** Restore the Dock icon and menu bar. */
64
+ show(): void;
65
+ }
@@ -0,0 +1,24 @@
1
+ import type { CheckScenario } from "./types.ts";
2
+
3
+ /**
4
+ * Declare a `mirin check` scenario. Default-export the result from the file you
5
+ * pass to `mirin check --scenario`.
6
+ *
7
+ * ```ts
8
+ * import { defineCheck } from "mirinjs/check";
9
+ *
10
+ * export default defineCheck(async (app) => {
11
+ * app.step("add a todo");
12
+ * await app.click("text=add a todo");
13
+ * await app.type("ship it");
14
+ * await app.click("text=Add");
15
+ * await app.expectText("ship it");
16
+ * });
17
+ * ```
18
+ *
19
+ * Identity at runtime — its whole job is to type the callback, so `app` is
20
+ * inferred without the file having to annotate it.
21
+ */
22
+ export function defineCheck(scenario: CheckScenario): CheckScenario {
23
+ return scenario;
24
+ }
@@ -0,0 +1,2 @@
1
+ export { defineCheck } from "./define.ts";
2
+ export type { CheckDriver, CheckScenario, CheckWindow, WindowTarget } from "./types.ts";
@@ -0,0 +1,95 @@
1
+ /**
2
+ * The contract a `mirin check` scenario is written against. Types live in the
3
+ * runtime package so an app can import them without the CLI's internals; the
4
+ * implementation lives in the CLI, which holds the inspector connection.
5
+ */
6
+
7
+ import type { DevEvent, DevEventQuery } from "../devtools/types.ts";
8
+
9
+ /** A window as the scenario sees it. */
10
+ export interface CheckWindow {
11
+ id: number;
12
+ name: string;
13
+ url?: string;
14
+ title?: string;
15
+ }
16
+
17
+ /** Which window an action targets. Omit when the app has only one. */
18
+ export interface WindowTarget {
19
+ window?: number;
20
+ }
21
+
22
+ /**
23
+ * The app under check. Every method talks to the running app's inspector.
24
+ * Anything that cannot be done rejects, and an unhandled rejection fails the check.
25
+ */
26
+ export interface CheckDriver {
27
+ /** Click an element. Selectors accept CSS or `text=Save`. */
28
+ click(selector: string, options?: WindowTarget & { clickCount?: number }): Promise<void>;
29
+ /** Type text, optionally focusing `selector` first. */
30
+ type(text: string, options?: WindowTarget & { selector?: string }): Promise<void>;
31
+ /** Press a named key, e.g. `Enter`, `Escape`, `Tab`. */
32
+ key(name: string, options?: WindowTarget): Promise<void>;
33
+ scroll(options: WindowTarget & { deltaY?: number; deltaX?: number }): Promise<void>;
34
+ /** Wait for a selector to match, rejecting if it never does. */
35
+ waitFor(selector: string, options?: WindowTarget & { timeoutMs?: number }): Promise<void>;
36
+ /** Wait a fixed time. Prefer `waitFor` — a sleep is a guess. */
37
+ sleep(ms: number): Promise<void>;
38
+
39
+ /** The accessibility tree, or the DOM with `format: "dom"`. */
40
+ snapshot(options?: WindowTarget & { format?: "ax" | "dom" }): Promise<string>;
41
+ /** Evaluate an expression in the page and return its value. */
42
+ evaluate<T = unknown>(expression: string, options?: WindowTarget): Promise<T>;
43
+ /** Capture a screenshot into the session dir; returns its path. */
44
+ screenshot(label?: string, options?: WindowTarget): Promise<string>;
45
+ windows(): Promise<CheckWindow[]>;
46
+ /** Slices the app published with `devtools.expose`. */
47
+ exposed(): Promise<Record<string, unknown>>;
48
+ /** The event stream so far, filtered the same way as the inspector's `/logs`. */
49
+ logs(query?: DevEventQuery): Promise<DevEvent[]>;
50
+ /**
51
+ * Send a raw DevTools-protocol command, for anything the methods above do not cover.
52
+ *
53
+ * ```ts
54
+ * const [call] = await app.logs({ type: "network.response", contains: "/api/todos" });
55
+ * const body = await app.cdp<{ body: string }>("Network.getResponseBody", {
56
+ * requestId: call.data.requestId,
57
+ * });
58
+ * ```
59
+ */
60
+ cdp<T = Record<string, unknown>>(
61
+ method: string,
62
+ params?: Record<string, unknown>,
63
+ options?: WindowTarget,
64
+ ): Promise<T>;
65
+
66
+ /** Fail the check unless `condition` is truthy, right now. After an interaction,
67
+ * prefer `waitUntil` — the RPC round trip has not landed yet. */
68
+ assert(condition: unknown, message: string): asserts condition;
69
+ /**
70
+ * Poll `condition` until it holds, failing the check with `message` if it never
71
+ * does. The general form of `expectText`.
72
+ *
73
+ * ```ts
74
+ * await app.waitUntil(
75
+ * async () => (await app.evaluate<number>("todos.length")) > 0,
76
+ * "the todo never reached the list",
77
+ * );
78
+ * ```
79
+ */
80
+ waitUntil(
81
+ condition: () => Promise<unknown> | unknown,
82
+ message: string,
83
+ options?: { timeoutMs?: number },
84
+ ): Promise<void>;
85
+ /** Fail the check unless the window's accessibility tree contains `text`. Polls
86
+ * until the timeout (2s by default): a single read would be flaky by construction. */
87
+ expectText(text: string, options?: WindowTarget & { timeoutMs?: number }): Promise<void>;
88
+
89
+ /** Name the phase the scenario is in. Steps appear in the report and the event
90
+ * stream, so a failure says which step it happened in. */
91
+ step(name: string): void;
92
+ }
93
+
94
+ /** A scenario: drive the app, throw to fail. */
95
+ export type CheckScenario = (app: CheckDriver) => Promise<void> | void;
package/src/client.ts CHANGED
@@ -7,18 +7,18 @@
7
7
 
8
8
  import type { EventProc, MutationProc, QueryProc, Router } from "./rpc.ts";
9
9
 
10
- export type ClientFor<R extends Router<any>> =
11
- R extends Router<infer T>
12
- ? {
13
- [K in keyof T]: T[K] extends QueryProc<infer I, infer O>
10
+ // Type-level `any` preserves the app router's concrete procedure inference for UI callers.
11
+ export type ClientFor<R extends Router<any>> = R extends Router<infer T>
12
+ ? {
13
+ [K in keyof T]: T[K] extends QueryProc<infer I, infer O>
14
+ ? (input: I) => Promise<Awaited<O>>
15
+ : T[K] extends MutationProc<infer I, infer O>
14
16
  ? (input: I) => Promise<Awaited<O>>
15
- : T[K] extends MutationProc<infer I, infer O>
16
- ? (input: I) => Promise<Awaited<O>>
17
- : T[K] extends EventProc<infer P>
18
- ? { on(listener: (payload: P) => void): () => void }
19
- : never;
20
- }
21
- : never;
17
+ : T[K] extends EventProc<infer P>
18
+ ? { on(listener: (payload: P) => void): () => void }
19
+ : never;
20
+ }
21
+ : never;
22
22
 
23
23
  interface MirinTransport {
24
24
  call(method: string, input: unknown): Promise<unknown>;
@@ -49,6 +49,7 @@ function transport(): MirinTransport {
49
49
  * Create the typed client for the app's router. Pass the Router *type* only —
50
50
  * never import the router value (and its handlers) into UI code.
51
51
  */
52
+ // Type-level `any` keeps `client<AppRouter>()` inference intact.
52
53
  export function client<R extends Router<any>>(): ClientFor<R> {
53
54
  const eventCache = new Map<string, { on(l: (p: unknown) => void): () => void }>();
54
55
 
@@ -0,0 +1 @@
1
+ export * from "./manifest.ts";