@nyssance/tauri-hasgard 0.2.1 → 0.4.1

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.
@@ -0,0 +1 @@
1
+ export {};
@@ -3,7 +3,25 @@ export declare class HasgardProcess {
3
3
  private child;
4
4
  private stdout;
5
5
  private stderr;
6
+ private starting;
7
+ private stopping;
8
+ private applicationPid;
9
+ private applicationExit;
10
+ /** Bounded log tails and the supervisor's actual child-close result. */
11
+ diagnostics(waitForExitMs?: number): Promise<{
12
+ pid: number | undefined;
13
+ exit: {
14
+ code: number | null;
15
+ signal: string | null;
16
+ } | undefined;
17
+ stdout: string;
18
+ stderr: string;
19
+ }>;
6
20
  start(config: HasgardLaunchConfig, socketPath: string): Promise<void>;
21
+ private launch;
22
+ private launchSupervised;
7
23
  waitForSocket(socketPath: string, timeoutMs: number): Promise<void>;
24
+ private confirmEndpointOwnership;
8
25
  stop(): Promise<void>;
26
+ private stopChild;
9
27
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,154 @@
1
+ import {
2
+ stopProcessGroup
3
+ } from "./chunk-N7YDYAKI.js";
4
+
5
+ // src/process-supervisor.js
6
+ import { spawn } from "child_process";
7
+ import { lstatSync, unlinkSync } from "fs";
8
+ import { createConnection } from "net";
9
+ import { setTimeout as delay } from "timers/promises";
10
+ var application;
11
+ var starting = false;
12
+ var stopping;
13
+ var endpoint;
14
+ var initialEndpoint;
15
+ var ownedEndpoint;
16
+ var endpointWatch;
17
+ function identity(path) {
18
+ try {
19
+ const stat = lstatSync(path);
20
+ return { dev: stat.dev, ino: stat.ino, socket: stat.isSocket() };
21
+ } catch (error) {
22
+ if (error.code === "ENOENT") return void 0;
23
+ throw error;
24
+ }
25
+ }
26
+ function sameIdentity(first, second) {
27
+ return first && second && first.dev === second.dev && first.ino === second.ino;
28
+ }
29
+ function captureEndpoint() {
30
+ if (ownedEndpoint || !endpoint) return;
31
+ const current = identity(endpoint);
32
+ if (current?.socket && !sameIdentity(current, initialEndpoint)) {
33
+ ownedEndpoint = current;
34
+ clearInterval(endpointWatch);
35
+ }
36
+ }
37
+ async function removeOwnedEndpoint() {
38
+ if (!ownedEndpoint || !sameIdentity(identity(endpoint), ownedEndpoint)) return;
39
+ const accepting = await new Promise((resolve, reject) => {
40
+ const socket = createConnection({ path: endpoint });
41
+ socket.once("connect", () => {
42
+ socket.destroy();
43
+ resolve(true);
44
+ });
45
+ socket.once("error", (error) => {
46
+ if (error.code === "ECONNREFUSED" || error.code === "ENOENT") resolve(false);
47
+ else reject(error);
48
+ });
49
+ socket.setTimeout(1e3, () => {
50
+ socket.destroy();
51
+ reject(new Error("Endpoint cleanup probe timed out"));
52
+ });
53
+ });
54
+ if (!accepting && sameIdentity(identity(endpoint), ownedEndpoint)) {
55
+ try {
56
+ unlinkSync(endpoint);
57
+ } catch (error) {
58
+ if (error.code !== "ENOENT") throw error;
59
+ }
60
+ }
61
+ }
62
+ process.stderr.on("error", (error) => {
63
+ if (error.code !== "EPIPE") shutdown(1);
64
+ });
65
+ function send(message, stream) {
66
+ if (!process.connected) return;
67
+ const writable = process.send(message, (error) => {
68
+ if (error) {
69
+ if (error.code !== "ERR_IPC_CHANNEL_CLOSED" && error.code !== "EPIPE")
70
+ process.stderr.write(`Hasgard supervisor IPC: ${error.message}
71
+ `);
72
+ if (process.connected) process.disconnect();
73
+ } else stream?.resume();
74
+ });
75
+ if (!writable) stream?.pause();
76
+ }
77
+ process.once("message", (config) => {
78
+ if (!process.connected || stopping) return;
79
+ starting = true;
80
+ try {
81
+ endpoint = config.env.TAURI_HASGARD_SOCKET;
82
+ if (typeof endpoint !== "string" || !endpoint) throw new Error("Supervisor requires TAURI_HASGARD_SOCKET");
83
+ initialEndpoint = identity(endpoint);
84
+ endpointWatch = setInterval(captureEndpoint, 25);
85
+ process.on("message", (message) => {
86
+ if (message.type !== "endpoint") return;
87
+ const current = identity(endpoint);
88
+ if (!current?.socket || !sameIdentity(current, message.identity)) {
89
+ send({ type: "error", message: "Application endpoint changed during readiness" });
90
+ return;
91
+ }
92
+ ownedEndpoint = current;
93
+ clearInterval(endpointWatch);
94
+ send({ type: "endpoint-owned" });
95
+ });
96
+ application = spawn(config.command, config.args, {
97
+ cwd: config.cwd,
98
+ env: config.env,
99
+ detached: true,
100
+ stdio: ["ignore", "pipe", "pipe"]
101
+ });
102
+ application.stdout.on("data", (chunk) => send({ type: "stdout", data: chunk.toString() }, application.stdout));
103
+ application.stderr.on("data", (chunk) => send({ type: "stderr", data: chunk.toString() }, application.stderr));
104
+ application.once("spawn", () => send({ type: "started", pid: application.pid }));
105
+ application.once("error", (error) => send({ type: "error", message: error.message, code: error.code }));
106
+ application.once("close", (code, signal) => send({ type: "exit", code, signal }));
107
+ } catch (error) {
108
+ send({ type: "error", message: error.message, code: error.code });
109
+ }
110
+ });
111
+ process.once("disconnect", () => shutdown(0));
112
+ for (const signal of ["SIGTERM", "SIGINT", "SIGHUP"]) process.on(signal, () => shutdown(0));
113
+ process.on("uncaughtException", (error) => {
114
+ process.stderr.write(`Hasgard supervisor error: ${error.stack}
115
+ `);
116
+ shutdown(1);
117
+ });
118
+ process.on("unhandledRejection", (error) => {
119
+ process.stderr.write(`Hasgard supervisor rejection: ${String(error)}
120
+ `);
121
+ shutdown(1);
122
+ });
123
+ function shutdown(exitCode) {
124
+ if (!stopping) stopping = cleanup(exitCode);
125
+ }
126
+ async function cleanup(exitCode) {
127
+ clearInterval(endpointWatch);
128
+ const pid = application?.pid;
129
+ if (!starting || pid === void 0) process.exit(exitCode);
130
+ let groupStopped = false;
131
+ let endpointFailures = 0;
132
+ for (; ; ) {
133
+ try {
134
+ let endpointError;
135
+ try {
136
+ captureEndpoint();
137
+ } catch (error) {
138
+ endpointError = error;
139
+ }
140
+ if (!groupStopped) {
141
+ await stopProcessGroup(pid);
142
+ groupStopped = true;
143
+ }
144
+ if (endpointError) throw endpointError;
145
+ await removeOwnedEndpoint();
146
+ process.exit(exitCode);
147
+ } catch (error) {
148
+ process.stderr.write(`Hasgard orphan cleanup: ${error.message}
149
+ `);
150
+ if (groupStopped && ++endpointFailures >= 3) process.exit(1);
151
+ await delay(1e3);
152
+ }
153
+ }
154
+ }
@@ -0,0 +1 @@
1
+ export declare const MAX_REQUEST_BYTES = 1048576;
@@ -0,0 +1,16 @@
1
+ import type { JsonValue } from "./types.js";
2
+ export type RpcResponse = {
3
+ jsonrpc: "2.0";
4
+ id: number;
5
+ result: JsonValue;
6
+ } | {
7
+ jsonrpc: "2.0";
8
+ id: number | null;
9
+ error: {
10
+ code: number;
11
+ message: string;
12
+ data?: JsonValue;
13
+ };
14
+ };
15
+ /** Validate the shared Hasgard envelope without inventing missing results. */
16
+ export declare function parseRpcResponse(line: string): RpcResponse;
@@ -0,0 +1 @@
1
+ export {};
package/dist/types.d.ts CHANGED
@@ -33,6 +33,11 @@ export interface SnapshotOptions {
33
33
  interactive?: boolean;
34
34
  selector?: string;
35
35
  depth?: number;
36
+ /**
37
+ * Take the snapshot inside this chain of same-origin iframes, one CSS
38
+ * selector per nesting level, instead of in the top document.
39
+ */
40
+ frame?: string[];
36
41
  }
37
42
  export interface WindowInfo {
38
43
  label: string;
@@ -71,6 +76,37 @@ export interface DragOffset {
71
76
  x: number;
72
77
  y: number;
73
78
  }
79
+ /** A held keyboard modifier. Named as the platform reports them on the event. */
80
+ export type KeyModifier = "Alt" | "Control" | "Meta" | "Shift";
81
+ /** An application postcondition to await before another native press may focus a window. */
82
+ export interface PressOptions {
83
+ completion?: "webview" | "native";
84
+ waitFor?: string;
85
+ timeoutMs?: number;
86
+ }
87
+ export type MouseButton = "left" | "middle" | "right";
88
+ /** A point inside the element, measured from its top-left corner. */
89
+ export interface ElementPosition {
90
+ x: number;
91
+ y: number;
92
+ }
93
+ export interface ClickOptions {
94
+ /**
95
+ * Modifiers held for the whole gesture. `Meta` is Command on macOS and the
96
+ * Windows key elsewhere -- the platform's own accelerator, which is why
97
+ * multi-select lists usually bind it rather than `Control`.
98
+ */
99
+ modifiers?: KeyModifier[];
100
+ /**
101
+ * Which button. `right` raises `contextmenu` and `middle` raises `auxclick`;
102
+ * neither raises `click`, exactly as a real press does not.
103
+ */
104
+ button?: MouseButton;
105
+ /** Presses in one gesture. `2` also raises a single `dblclick` at the end. */
106
+ clickCount?: number;
107
+ /** Where inside the element to press. Defaults to its centre. */
108
+ position?: ElementPosition;
109
+ }
74
110
  export interface DropFile {
75
111
  name: string;
76
112
  /** Base64-encoded file contents. */
@@ -121,6 +157,49 @@ export interface DialogListing {
121
157
  dialogs: DialogRecord[];
122
158
  policy: DialogPolicy;
123
159
  }
160
+ /** How a matched request is answered. */
161
+ export interface RouteOptions {
162
+ /**
163
+ * Glob against the full URL. `*` stays inside one path segment, `**` crosses
164
+ * separators, and the pattern is anchored at both ends -- so `**\/api` does
165
+ * not also intercept `/v2/api-docs`.
166
+ */
167
+ pattern: string;
168
+ /** Restrict to one HTTP verb. Any verb matches when omitted. */
169
+ method?: string;
170
+ /** Stop matching after this many requests, letting later ones through. */
171
+ times?: number;
172
+ }
173
+ export interface FulfillOptions extends RouteOptions {
174
+ /** Defaults to 200. */
175
+ status?: number;
176
+ body?: string;
177
+ /** Defaults to `text/plain`. */
178
+ contentType?: string;
179
+ }
180
+ export interface RouteRule {
181
+ id: number;
182
+ pattern: string;
183
+ method: string | null;
184
+ action: "fulfill" | "abort";
185
+ status: number;
186
+ times: number | null;
187
+ /** How many requests this rule has already answered. */
188
+ used: number;
189
+ }
190
+ export interface InterceptedRequest {
191
+ id: number;
192
+ timestamp: number;
193
+ route_id: number;
194
+ method: string;
195
+ url: string;
196
+ action: "fulfill" | "abort";
197
+ status: number;
198
+ }
199
+ export interface RouteListing {
200
+ routes: RouteRule[];
201
+ intercepted: InterceptedRequest[];
202
+ }
124
203
  export interface ConsoleLogOptions {
125
204
  level?: "log" | "warn" | "error" | "info";
126
205
  since?: number;
package/dist/window.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { HasgardRpcClient } from "./rpc-client.js";
2
- import type { BoundingBox, ConsoleLogEntry, ConsoleLogOptions, DialogListing, DialogPolicy, DiffOptions, DragOffset, DropFile, FileInput, FilterOptions, FormsDump, HasgardTarget, JsonValue, NativeScreenshot, NativeScreenshotOptions, NetworkRequestEntry, NetworkRequestOptions, QueryDimension, RecorderEntry, RecorderResult, RecorderStatus, RoleQuery, ScrollOptions, Snapshot, SnapshotDiff, SnapshotOptions, StorageListing, StorageOptions, TextQuery, WaitForFunctionOptions, WaitOptions, WatchChanges, WatchOptions, WindowInfo, WindowState } from "./types.js";
2
+ import type { BoundingBox, ClickOptions, ConsoleLogEntry, ConsoleLogOptions, DialogListing, DialogPolicy, DiffOptions, DragOffset, DropFile, FileInput, FilterOptions, FormsDump, FulfillOptions, HasgardTarget, JsonValue, NativeScreenshot, NativeScreenshotOptions, NetworkRequestEntry, NetworkRequestOptions, PressOptions, QueryDimension, RecorderEntry, RecorderResult, RecorderStatus, RoleQuery, RouteListing, RouteOptions, ScrollOptions, Snapshot, SnapshotDiff, SnapshotOptions, StorageListing, StorageOptions, TextQuery, WaitForFunctionOptions, WaitOptions, WatchChanges, WatchOptions, WindowInfo, WindowState } from "./types.js";
3
3
  export declare const byRef: (ref: string) => HasgardTarget;
4
4
  export declare const bySelector: (selector: string) => HasgardTarget;
5
5
  export declare const byPoint: (x: number, y: number) => HasgardTarget;
@@ -44,6 +44,15 @@ export declare class HasgardWindow {
44
44
  getByTitle(text: string, query?: TextQuery): HasgardLocator;
45
45
  /** Test ids always match exactly, so `save` cannot select `save-draft`. */
46
46
  getByTestId(testId: string): HasgardLocator;
47
+ /**
48
+ * Scope locators to a same-origin `<iframe>`.
49
+ *
50
+ * Cross-origin frames are out of reach: the same-origin policy binds injected
51
+ * script exactly as it binds the page's own, so no bridge can see into a
52
+ * third-party payment or OAuth frame. Those raise an error naming the origin
53
+ * problem rather than reporting a missing element.
54
+ */
55
+ frameLocator(selector: string): HasgardFrameLocator;
47
56
  snapshot(options?: SnapshotOptions): Promise<Snapshot>;
48
57
  state(): Promise<WindowState>;
49
58
  url(): Promise<string>;
@@ -56,7 +65,7 @@ export declare class HasgardWindow {
56
65
  navigate(url: string): Promise<void>;
57
66
  /** Scroll the document by a relative amount, or to `top`/`bottom`. */
58
67
  scrollBy(options: ScrollOptions): Promise<void>;
59
- press(key: string): Promise<void>;
68
+ press(key: string, options?: PressOptions): Promise<void>;
60
69
  diff(options?: DiffOptions): Promise<SnapshotDiff>;
61
70
  /**
62
71
  * Poll a JavaScript expression in the webview until it returns a truthy
@@ -121,6 +130,36 @@ export declare class HasgardWindow {
121
130
  /** Forget the recorded dialogs. Leaves the accept/dismiss policy alone. */
122
131
  clear: () => Promise<void>;
123
132
  };
133
+ /**
134
+ * Shape what the page's own requests receive, without a real backend.
135
+ *
136
+ * Rules are declarative rather than Playwright's per-request callback: the
137
+ * bridge only ever answers requests, so it cannot call back into this process
138
+ * and await a handler while the page sits inside `fetch`. The same constraint
139
+ * shaped `dialogs`.
140
+ *
141
+ * Rules are checked in registration order and the first match wins, so
142
+ * register the narrow ones before a catch-all. Both `fetch` and
143
+ * `XMLHttpRequest` are covered; resources the webview loads itself -- the
144
+ * document, `<img src>`, stylesheets -- are not, since no page script sees
145
+ * them.
146
+ */
147
+ readonly routes: {
148
+ /** Answer matching requests with this response instead of the network. */
149
+ fulfill: (options: FulfillOptions) => Promise<{
150
+ id: number;
151
+ }>;
152
+ /** Fail matching requests the way a dropped connection does. */
153
+ abort: (options: RouteOptions) => Promise<{
154
+ id: number;
155
+ }>;
156
+ /** The active rules, their use counts, and every request they answered. */
157
+ list: () => Promise<RouteListing>;
158
+ /** Remove every rule and its interception log. */
159
+ clear: () => Promise<{
160
+ removed: number;
161
+ }>;
162
+ };
124
163
  /** This window's operating-system window id, where the platform exposes one. */
125
164
  nativeId(): Promise<number>;
126
165
  call<T>(method: string, params?: Record<string, JsonValue>): Promise<T>;
@@ -141,12 +180,46 @@ type LocatorQuery = ({
141
180
  }) & {
142
181
  index?: number;
143
182
  filters?: FilterOptions[];
183
+ frame?: string[];
144
184
  };
185
+ /**
186
+ * The locator factories of a window, scoped to a chain of same-origin iframes.
187
+ *
188
+ * Mirrors the window's own factories rather than wrapping a locator, because a
189
+ * frame is a document to search in, not an element to search under: `#pay`
190
+ * inside the frame is not a descendant of `#pay` outside it.
191
+ */
192
+ export declare class HasgardFrameLocator {
193
+ private readonly windowRef;
194
+ private readonly frame;
195
+ constructor(window: HasgardWindow, frame: string[]);
196
+ /** Descend into a frame nested inside this one. */
197
+ frameLocator(selector: string): HasgardFrameLocator;
198
+ locator(selector: string): HasgardLocator;
199
+ getByRole(role: string, query?: RoleQuery): HasgardLocator;
200
+ getByText(text: string, query?: TextQuery): HasgardLocator;
201
+ getByLabel(text: string, query?: TextQuery): HasgardLocator;
202
+ getByPlaceholder(text: string, query?: TextQuery): HasgardLocator;
203
+ getByAltText(text: string, query?: TextQuery): HasgardLocator;
204
+ getByTitle(text: string, query?: TextQuery): HasgardLocator;
205
+ getByTestId(testId: string): HasgardLocator;
206
+ /** Snapshot the frame's own document rather than the top one. */
207
+ snapshot(options?: SnapshotOptions): Promise<Snapshot>;
208
+ private byDimension;
209
+ }
145
210
  export declare class HasgardLocator {
146
211
  readonly window: HasgardWindow;
147
212
  private readonly query;
148
213
  constructor(window: HasgardWindow, query: LocatorQuery);
149
- click(): Promise<void>;
214
+ /**
215
+ * Click this element.
216
+ *
217
+ * A right or middle click raises `contextmenu`/`auxclick` and no `click`, and
218
+ * `clickCount: 2` escalates `detail` across the presses before a single
219
+ * `dblclick` -- both matching what the platform does, so a listener bound to
220
+ * the wrong event fails here rather than in front of a user.
221
+ */
222
+ click(options?: ClickOptions): Promise<void>;
150
223
  fill(value: string): Promise<void>;
151
224
  type(text: string): Promise<void>;
152
225
  /** Empty the control, firing `input` and `change` as `fill` does. */
@@ -175,7 +248,8 @@ export declare class HasgardLocator {
175
248
  /** Flip the checkbox, whatever its current state. */
176
249
  toggle(): Promise<void>;
177
250
  hover(): Promise<void>;
178
- dblclick(): Promise<void>;
251
+ /** Shorthand for `click({ clickCount: 2 })`; other options still apply. */
252
+ dblclick(options?: Omit<ClickOptions, "clickCount">): Promise<void>;
179
253
  focus(): Promise<void>;
180
254
  blur(): Promise<void>;
181
255
  /** Scroll this element's own scrollport. */
@@ -215,6 +289,15 @@ export declare class HasgardLocator {
215
289
  first(): HasgardLocator;
216
290
  last(): HasgardLocator;
217
291
  private resolveUnique;
292
+ /**
293
+ * Serialize a target together with the frame chain this locator lives in.
294
+ *
295
+ * A ref carries its own document, so the bridge ignores `frame` for one; the
296
+ * chain still travels so that a selector or coordinate target -- which the
297
+ * bridge must resolve itself -- lands in the right document.
298
+ */
299
+ private scoped;
300
+ private frameParams;
218
301
  private withTarget;
219
302
  private resolveAll;
220
303
  private resolveBase;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nyssance/tauri-hasgard",
3
- "version": "0.2.1",
3
+ "version": "0.4.1",
4
4
  "description": "Playwright Test fixtures for native Tauri Hasgard automation",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -18,12 +18,14 @@
18
18
  "NOTICE.md"
19
19
  ],
20
20
  "scripts": {
21
- "build": "bun node_modules/tsup/dist/cli-default.js src/index.ts --format esm --outDir dist --clean && bun node_modules/typescript/bin/tsc -p tsconfig.json --noEmit false --emitDeclarationOnly --declaration true",
22
- "dev": "tsup src/index.ts --format esm --dts --outDir dist --watch",
21
+ "build": "bun node_modules/tsup/dist/cli-default.js src/index.ts src/process-supervisor.js --format esm --outDir dist --clean && bun node_modules/typescript/bin/tsc -p tsconfig.json --noEmit false --emitDeclarationOnly --declaration true",
22
+ "dev": "bun run --parallel dev:js dev:types",
23
23
  "test": "vitest run",
24
24
  "test:watch": "vitest",
25
25
  "typecheck": "tsc --noEmit",
26
- "clean": "rm -rf dist"
26
+ "clean": "rm -rf dist",
27
+ "dev:js": "tsup src/index.ts src/process-supervisor.js --format esm --outDir dist --watch",
28
+ "dev:types": "tsc -p tsconfig.json --noEmit false --emitDeclarationOnly --declaration true --watch"
27
29
  },
28
30
  "peerDependencies": {
29
31
  "@playwright/test": ">=1.61.1 <2"