@nyssance/tauri-hasgard 0.3.0 → 0.5.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/README.md +44 -0
- package/dist/chunk-N7YDYAKI.js +31 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +363 -80
- package/dist/process-group.test.d.ts +1 -0
- package/dist/process-manager.d.ts +18 -0
- package/dist/process-manager.test.d.ts +1 -0
- package/dist/process-supervisor.js +154 -0
- package/dist/protocol-limits.d.ts +1 -0
- package/dist/protocol.d.ts +16 -0
- package/dist/protocol.test.d.ts +1 -0
- package/dist/types.d.ts +6 -0
- package/dist/window.d.ts +19 -2
- package/package.json +6 -4
package/README.md
CHANGED
|
@@ -29,6 +29,50 @@ export const { test, expect } = createHasgardTest({
|
|
|
29
29
|
|
|
30
30
|
The fixture passes the selected socket to the application as `TAURI_HASGARD_SOCKET`, so each Playwright worker receives an isolated connection.
|
|
31
31
|
|
|
32
|
+
On macOS, a managed launch owns a detached process group. Teardown
|
|
33
|
+
waits for that group to exit, including children left behind by a launcher,
|
|
34
|
+
and escalates from SIGTERM to SIGKILL after five seconds. Startup failures also
|
|
35
|
+
clean up, and concurrent `stop()` calls share the same cleanup. A live endpoint
|
|
36
|
+
is rejected rather than unlinked; readiness requires a successful connection.
|
|
37
|
+
If cleanup fails, ownership is retained so `stop()` can be retried; another
|
|
38
|
+
launch is blocked until that cleanup succeeds.
|
|
39
|
+
Diagnostics retain the last 64 Ki characters per output stream with a truncation
|
|
40
|
+
marker. `await process.diagnostics()` exposes those tails and the actual exit
|
|
41
|
+
code or signal. Failed tests attach them; a failed screenshot does not replace
|
|
42
|
+
the original test error. Teardown removes the owned stale socket after its
|
|
43
|
+
process group exits, checking its identity and preserving live or replaced paths.
|
|
44
|
+
|
|
45
|
+
An independent supervisor owns the application and watches the worker's IPC
|
|
46
|
+
connection. If the worker is killed with SIGKILL, the supervisor still terminates
|
|
47
|
+
the application group and exits. Killing the supervisor itself or children that
|
|
48
|
+
deliberately create a new session or process group are outside this guarantee.
|
|
49
|
+
Attach-only fixtures do not terminate an application they did not launch.
|
|
50
|
+
|
|
51
|
+
`window.press(key)` uses native OS input and, by default, waits for a trusted
|
|
52
|
+
WebView keyup before the next press may switch windows. For OS shortcuts and
|
|
53
|
+
keys that navigate or close the document, explicitly use
|
|
54
|
+
`window.press(key, { completion: "native" })`; that mode confirms posting only.
|
|
55
|
+
Bare modifiers also use native posting completion. The active input method
|
|
56
|
+
remains in effect: use `fill` for exact text, and `Shift+m` for a shifted key.
|
|
57
|
+
|
|
58
|
+
To keep keyboard ownership until an application state is observable:
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
await window.press("Tab", {
|
|
62
|
+
waitFor: "document.activeElement.id === 'save'",
|
|
63
|
+
timeoutMs: 1_000
|
|
64
|
+
})
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
The postcondition timeout is capped at ten seconds. Failures retain their
|
|
68
|
+
`HasgardRpcError.data.phase` and `injected` status; never blindly retry an
|
|
69
|
+
already-injected or uncertain press. See [the wire contract](../../docs/protocol.md#native-keyboard-completion).
|
|
70
|
+
|
|
71
|
+
Requests are limited to 1,048,576 UTF-8 bytes including the trailing newline,
|
|
72
|
+
matching the plugin. Oversized requests fail before transmission. An explicit
|
|
73
|
+
null result is supported; malformed responses are rejected instead of returning
|
|
74
|
+
an invented value.
|
|
75
|
+
|
|
32
76
|
## Test
|
|
33
77
|
|
|
34
78
|
```ts
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// src/process-group.js
|
|
2
|
+
import { setTimeout as delay } from "timers/promises";
|
|
3
|
+
function signalGroup(pid, signal) {
|
|
4
|
+
try {
|
|
5
|
+
process.kill(-pid, signal);
|
|
6
|
+
return true;
|
|
7
|
+
} catch (error) {
|
|
8
|
+
if (error.code === "ESRCH") return false;
|
|
9
|
+
if (signal === 0 && error.code === "EPERM") return true;
|
|
10
|
+
throw error;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
async function waitForExit(pid, timeoutMs) {
|
|
14
|
+
const deadline = Date.now() + timeoutMs;
|
|
15
|
+
while (signalGroup(pid, 0)) {
|
|
16
|
+
if (Date.now() >= deadline) return false;
|
|
17
|
+
await delay(25);
|
|
18
|
+
}
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
async function stopProcessGroup(pid) {
|
|
22
|
+
signalGroup(pid, "SIGTERM");
|
|
23
|
+
if (!await waitForExit(pid, 5e3)) {
|
|
24
|
+
signalGroup(pid, "SIGKILL");
|
|
25
|
+
if (!await waitForExit(pid, 5e3)) throw new Error(`Hasgard process group ${pid} survived SIGKILL`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export {
|
|
30
|
+
stopProcessGroup
|
|
31
|
+
};
|
package/dist/index.d.ts
CHANGED
|
@@ -3,5 +3,5 @@ export { createHasgardExpect } from "./expect.js";
|
|
|
3
3
|
export { createHasgardTest } from "./fixture.js";
|
|
4
4
|
export { HasgardProcess } from "./process-manager.js";
|
|
5
5
|
export { HasgardRpcClient, HasgardRpcError } from "./rpc-client.js";
|
|
6
|
-
export type { BoundingBox, ClickOptions, ConsoleLogEntry, ConsoleLogOptions, DialogListing, DialogPolicy, DialogRecord, DialogType, DiffChange, DiffOptions, DragOffset, DropFile, ElementPosition, FileInput, FilterOptions, FormEntry, FormField, FormsDump, FulfillOptions, HasgardFixtures, HasgardLaunchConfig, HasgardTarget, HasgardTestConfig, HasgardWorkerFixtures, InterceptedRequest, JsonPrimitive, JsonValue, KeyModifier, MouseButton, NativeScreenshot, NativeScreenshotOptions, NetworkRequestEntry, NetworkRequestOptions, QueryDimension, RecorderEntry, RecorderResult, RecorderStatus, RoleQuery, RouteListing, RouteOptions, RouteRule, ScrollOptions, Snapshot, SnapshotDiff, SnapshotElement, SnapshotOptions, StorageEntry, StorageListing, StorageOptions, TextQuery, WaitForFunctionOptions, WaitOptions, WatchChanges, WatchNode, WatchOptions, WindowInfo, WindowState } from "./types.js";
|
|
6
|
+
export type { BoundingBox, ClickOptions, ConsoleLogEntry, ConsoleLogOptions, DialogListing, DialogPolicy, DialogRecord, DialogType, DiffChange, DiffOptions, DragOffset, DropFile, ElementPosition, FileInput, FilterOptions, FormEntry, FormField, FormsDump, FulfillOptions, HasgardFixtures, HasgardLaunchConfig, HasgardTarget, HasgardTestConfig, HasgardWorkerFixtures, InterceptedRequest, JsonPrimitive, JsonValue, KeyModifier, MouseButton, NativeScreenshot, NativeScreenshotOptions, NetworkRequestEntry, NetworkRequestOptions, PressOptions, QueryDimension, RecorderEntry, RecorderResult, RecorderStatus, RoleQuery, RouteListing, RouteOptions, RouteRule, ScrollOptions, Snapshot, SnapshotDiff, SnapshotElement, SnapshotOptions, StorageEntry, StorageListing, StorageOptions, TextQuery, WaitForFunctionOptions, WaitOptions, WatchChanges, WatchNode, WatchOptions, WindowInfo, WindowState } from "./types.js";
|
|
7
7
|
export { byPoint, byRef, bySelector, HasgardApplication, HasgardFrameLocator, HasgardLocator, HasgardWindow } from "./window.js";
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import {
|
|
2
|
+
stopProcessGroup
|
|
3
|
+
} from "./chunk-N7YDYAKI.js";
|
|
4
|
+
|
|
1
5
|
// src/endpoint.ts
|
|
2
6
|
import { tmpdir } from "os";
|
|
3
7
|
import { join } from "path";
|
|
@@ -11,20 +15,18 @@ function hasgardEndpointPath(name) {
|
|
|
11
15
|
|
|
12
16
|
// src/expect.ts
|
|
13
17
|
async function poll(read, matches, timeoutMs) {
|
|
18
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs < 0)
|
|
19
|
+
throw new RangeError("Assertion timeout must be a finite non-negative number");
|
|
14
20
|
const deadline = Date.now() + timeoutMs;
|
|
15
|
-
|
|
16
|
-
let error;
|
|
17
|
-
while (Date.now() <= deadline) {
|
|
21
|
+
for (; ; ) {
|
|
18
22
|
try {
|
|
19
|
-
value = await read();
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
error = caught;
|
|
23
|
+
const value = await read();
|
|
24
|
+
if (matches(value) || Date.now() >= deadline) return value;
|
|
25
|
+
} catch (error) {
|
|
26
|
+
if (Date.now() >= deadline) throw error;
|
|
24
27
|
}
|
|
25
|
-
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
28
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(50, Math.max(0, deadline - Date.now()))));
|
|
26
29
|
}
|
|
27
|
-
return { pass: false, value, error };
|
|
28
30
|
}
|
|
29
31
|
function createHasgardExpect(playwrightExpect) {
|
|
30
32
|
return playwrightExpect.extend({
|
|
@@ -36,8 +38,8 @@ function createHasgardExpect(playwrightExpect) {
|
|
|
36
38
|
timeout
|
|
37
39
|
);
|
|
38
40
|
return {
|
|
39
|
-
pass: result
|
|
40
|
-
message: () => this.isNot ? "expected Hasgard locator not to be visible" : `expected Hasgard locator to be visible within ${timeout}ms
|
|
41
|
+
pass: result === true,
|
|
42
|
+
message: () => this.isNot ? "expected Hasgard locator not to be visible" : `expected Hasgard locator to be visible within ${timeout}ms`
|
|
41
43
|
};
|
|
42
44
|
},
|
|
43
45
|
async toHaveText(locator, expected, options = {}) {
|
|
@@ -53,24 +55,55 @@ function createHasgardExpect(playwrightExpect) {
|
|
|
53
55
|
timeout
|
|
54
56
|
);
|
|
55
57
|
return {
|
|
56
|
-
pass:
|
|
57
|
-
message: () => this.isNot ? `expected Hasgard locator text not to match ${String(expected)}, received ${String(result
|
|
58
|
+
pass: matches(result),
|
|
59
|
+
message: () => this.isNot ? `expected Hasgard locator text not to match ${String(expected)}, received ${String(result)}` : `expected Hasgard locator text to match ${String(expected)}, received ${String(result)}`
|
|
58
60
|
};
|
|
59
61
|
}
|
|
60
62
|
});
|
|
61
63
|
}
|
|
62
64
|
|
|
63
65
|
// src/process-manager.ts
|
|
64
|
-
import { spawn } from "child_process";
|
|
65
|
-
import {
|
|
66
|
+
import { fork, spawn } from "child_process";
|
|
67
|
+
import { lstat } from "fs/promises";
|
|
66
68
|
import { createConnection } from "net";
|
|
69
|
+
import { setTimeout as delay } from "timers/promises";
|
|
67
70
|
var HasgardProcess = class {
|
|
68
71
|
child;
|
|
69
72
|
stdout = "";
|
|
70
73
|
stderr = "";
|
|
74
|
+
starting;
|
|
75
|
+
stopping;
|
|
76
|
+
applicationPid;
|
|
77
|
+
applicationExit;
|
|
78
|
+
/** Bounded log tails and the supervisor's actual child-close result. */
|
|
79
|
+
async diagnostics(waitForExitMs = 0) {
|
|
80
|
+
if (!Number.isFinite(waitForExitMs) || waitForExitMs < 0) throw new RangeError("Invalid diagnostics wait");
|
|
81
|
+
const deadline = Date.now() + waitForExitMs;
|
|
82
|
+
while (this.child && !this.applicationExit && Date.now() < deadline)
|
|
83
|
+
await delay(Math.min(25, deadline - Date.now()));
|
|
84
|
+
return { pid: this.applicationPid, exit: this.applicationExit, stdout: this.stdout, stderr: this.stderr };
|
|
85
|
+
}
|
|
71
86
|
async start(config, socketPath) {
|
|
72
|
-
if (this.child) throw new Error("Hasgard app process is already running");
|
|
73
|
-
|
|
87
|
+
if (this.child || this.stopping || this.starting) throw new Error("Hasgard app process is already running");
|
|
88
|
+
this.starting = this.launch(config, socketPath);
|
|
89
|
+
try {
|
|
90
|
+
await this.starting;
|
|
91
|
+
} finally {
|
|
92
|
+
this.starting = void 0;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
async launch(config, socketPath) {
|
|
96
|
+
this.stdout = "";
|
|
97
|
+
this.stderr = "";
|
|
98
|
+
this.applicationPid = void 0;
|
|
99
|
+
this.applicationExit = void 0;
|
|
100
|
+
if (process.platform !== "win32" && await endpointAcceptsConnections(socketPath)) {
|
|
101
|
+
throw new Error(`Hasgard endpoint is already in use: ${socketPath}`);
|
|
102
|
+
}
|
|
103
|
+
if (process.platform !== "win32") {
|
|
104
|
+
await this.launchSupervised(config, socketPath);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
74
107
|
const child = spawn(config.command, config.args, {
|
|
75
108
|
cwd: config.cwd,
|
|
76
109
|
env: { ...process.env, ...config.env, TAURI_HASGARD_SOCKET: socketPath },
|
|
@@ -79,68 +112,206 @@ var HasgardProcess = class {
|
|
|
79
112
|
});
|
|
80
113
|
this.child = child;
|
|
81
114
|
child.stdout?.on("data", (data) => {
|
|
82
|
-
this.stdout
|
|
115
|
+
this.stdout = appendOutput(this.stdout, data.toString());
|
|
83
116
|
});
|
|
84
117
|
child.stderr?.on("data", (data) => {
|
|
85
|
-
this.stderr
|
|
118
|
+
this.stderr = appendOutput(this.stderr, data.toString());
|
|
86
119
|
});
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
120
|
+
try {
|
|
121
|
+
await new Promise((resolve, reject) => {
|
|
122
|
+
child.once("spawn", resolve);
|
|
123
|
+
child.once("error", reject);
|
|
124
|
+
});
|
|
125
|
+
await this.waitForSocket(socketPath, config.timeoutMs);
|
|
126
|
+
} catch (error) {
|
|
127
|
+
try {
|
|
128
|
+
await this.stopChild();
|
|
129
|
+
} catch (cleanupError) {
|
|
130
|
+
throw new AggregateError([error, cleanupError], "Hasgard launch failed and process cleanup failed");
|
|
131
|
+
}
|
|
132
|
+
throw error;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
async launchSupervised(config, socketPath) {
|
|
136
|
+
const child = fork(new URL("./process-supervisor.js", import.meta.url), [], {
|
|
137
|
+
detached: true,
|
|
138
|
+
execArgv: [],
|
|
139
|
+
stdio: ["ignore", "ignore", "pipe", "ipc"]
|
|
140
|
+
});
|
|
141
|
+
this.child = child;
|
|
142
|
+
child.stderr?.on("data", (data) => {
|
|
143
|
+
this.stderr = appendOutput(this.stderr, String(data));
|
|
90
144
|
});
|
|
91
145
|
try {
|
|
146
|
+
await new Promise((resolve, reject) => {
|
|
147
|
+
const timer = setTimeout(() => reject(new Error("Hasgard process supervisor did not start")), config.timeoutMs);
|
|
148
|
+
const failed = (error) => {
|
|
149
|
+
clearTimeout(timer);
|
|
150
|
+
reject(error);
|
|
151
|
+
};
|
|
152
|
+
child.once("error", failed);
|
|
153
|
+
child.once("exit", () => failed(new Error("Hasgard process supervisor exited")));
|
|
154
|
+
child.on(
|
|
155
|
+
"message",
|
|
156
|
+
(message) => {
|
|
157
|
+
if (message.type === "stdout") this.stdout = appendOutput(this.stdout, message.data);
|
|
158
|
+
else if (message.type === "stderr") this.stderr = appendOutput(this.stderr, message.data);
|
|
159
|
+
else if (message.type === "exit") this.applicationExit = { code: message.code, signal: message.signal };
|
|
160
|
+
else if (message.type === "error") failed(Object.assign(new Error(message.message), { code: message.code }));
|
|
161
|
+
else if (message.type === "started") {
|
|
162
|
+
if (!Number.isSafeInteger(message.pid) || message.pid <= 1) {
|
|
163
|
+
failed(new Error("Invalid supervised application PID"));
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
this.applicationPid = message.pid;
|
|
167
|
+
clearTimeout(timer);
|
|
168
|
+
resolve();
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
);
|
|
172
|
+
child.send({ ...config, env: { ...process.env, ...config.env, TAURI_HASGARD_SOCKET: socketPath } }, (error) => {
|
|
173
|
+
if (error) failed(error);
|
|
174
|
+
});
|
|
175
|
+
});
|
|
92
176
|
await this.waitForSocket(socketPath, config.timeoutMs);
|
|
177
|
+
await this.confirmEndpointOwnership(child, socketPath, config.timeoutMs);
|
|
93
178
|
} catch (error) {
|
|
94
|
-
|
|
179
|
+
try {
|
|
180
|
+
await this.stopChild();
|
|
181
|
+
} catch (cleanupError) {
|
|
182
|
+
throw new AggregateError([error, cleanupError], "Hasgard launch failed and process cleanup failed");
|
|
183
|
+
}
|
|
95
184
|
throw error;
|
|
96
185
|
}
|
|
97
186
|
}
|
|
98
187
|
async waitForSocket(socketPath, timeoutMs) {
|
|
99
188
|
const deadline = Date.now() + timeoutMs;
|
|
100
189
|
while (Date.now() <= deadline) {
|
|
101
|
-
|
|
190
|
+
const child = this.child;
|
|
191
|
+
if (this.applicationExit) {
|
|
192
|
+
const { code, signal } = this.applicationExit;
|
|
102
193
|
throw new Error(
|
|
103
|
-
`Tauri process exited with code ${
|
|
194
|
+
`Tauri process exited with ${signal ? `signal ${signal}` : `code ${code}`}
|
|
104
195
|
stdout:
|
|
105
196
|
${this.stdout}
|
|
106
197
|
stderr:
|
|
107
198
|
${this.stderr}`
|
|
108
199
|
);
|
|
109
200
|
}
|
|
110
|
-
if (
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
if (code !== "ENOENT") throw error;
|
|
119
|
-
}
|
|
201
|
+
if (child && (child.exitCode !== null || child.signalCode !== null)) {
|
|
202
|
+
throw new Error(
|
|
203
|
+
`Tauri process exited with ${child.signalCode ? `signal ${child.signalCode}` : `code ${child.exitCode}`}
|
|
204
|
+
stdout:
|
|
205
|
+
${this.stdout}
|
|
206
|
+
stderr:
|
|
207
|
+
${this.stderr}`
|
|
208
|
+
);
|
|
120
209
|
}
|
|
121
|
-
|
|
210
|
+
if (await endpointAcceptsConnections(socketPath)) return;
|
|
211
|
+
await delay(50);
|
|
122
212
|
}
|
|
123
213
|
throw new Error(`Hasgard socket did not appear within ${timeoutMs}ms: ${socketPath}`);
|
|
124
214
|
}
|
|
215
|
+
async confirmEndpointOwnership(child, path, timeoutMs) {
|
|
216
|
+
const stat = await lstat(path);
|
|
217
|
+
if (!stat.isSocket()) throw new Error(`Hasgard endpoint is not a socket: ${path}`);
|
|
218
|
+
await new Promise((resolve, reject) => {
|
|
219
|
+
const done = (error) => {
|
|
220
|
+
clearTimeout(timer);
|
|
221
|
+
child.off("message", message);
|
|
222
|
+
child.off("exit", exited);
|
|
223
|
+
if (error) reject(error);
|
|
224
|
+
else resolve();
|
|
225
|
+
};
|
|
226
|
+
const message = (value) => {
|
|
227
|
+
if (value.type === "endpoint-owned") done();
|
|
228
|
+
else if (value.type === "error") done(new Error(value.message));
|
|
229
|
+
};
|
|
230
|
+
const exited = () => done(new Error("Hasgard supervisor exited before acknowledging endpoint ownership"));
|
|
231
|
+
const timer = setTimeout(
|
|
232
|
+
() => done(new Error("Hasgard supervisor did not acknowledge endpoint ownership")),
|
|
233
|
+
timeoutMs
|
|
234
|
+
);
|
|
235
|
+
child.on("message", message);
|
|
236
|
+
child.once("exit", exited);
|
|
237
|
+
child.send({ type: "endpoint", identity: { dev: stat.dev, ino: stat.ino } }, (error) => {
|
|
238
|
+
if (error) done(error);
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
}
|
|
125
242
|
async stop() {
|
|
243
|
+
if (this.starting) await this.starting;
|
|
244
|
+
if (this.stopping) return this.stopping;
|
|
245
|
+
this.stopping = this.stopChild();
|
|
246
|
+
try {
|
|
247
|
+
await this.stopping;
|
|
248
|
+
} finally {
|
|
249
|
+
this.stopping = void 0;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
async stopChild() {
|
|
126
253
|
const child = this.child;
|
|
127
|
-
if (!child
|
|
254
|
+
if (!child) return;
|
|
255
|
+
if (process.platform !== "win32") {
|
|
256
|
+
if (this.applicationPid !== void 0) {
|
|
257
|
+
await stopProcessGroup(this.applicationPid);
|
|
258
|
+
}
|
|
259
|
+
const exited2 = child.exitCode !== null || child.signalCode !== null ? Promise.resolve() : new Promise((resolve) => child.once("exit", () => resolve()));
|
|
260
|
+
if (child.connected) child.disconnect();
|
|
261
|
+
const abort2 = new AbortController();
|
|
262
|
+
try {
|
|
263
|
+
if (await Promise.race([exited2, delay(11e3, "timeout", { signal: abort2.signal })]) === "timeout") {
|
|
264
|
+
throw new Error(`Hasgard process supervisor ${child.pid} did not finish cleanup`);
|
|
265
|
+
}
|
|
266
|
+
} finally {
|
|
267
|
+
abort2.abort();
|
|
268
|
+
}
|
|
269
|
+
this.child = void 0;
|
|
270
|
+
this.applicationPid = void 0;
|
|
271
|
+
if (child.exitCode !== 0 || child.signalCode !== null) {
|
|
272
|
+
throw new Error(`Hasgard supervisor cleanup failed (${child.signalCode ?? child.exitCode}):
|
|
273
|
+
${this.stderr}`);
|
|
274
|
+
}
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
if (child.pid === void 0 || child.exitCode !== null || child.signalCode !== null) {
|
|
278
|
+
this.child = void 0;
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
128
281
|
const exited = new Promise((resolve) => child.once("exit", () => resolve()));
|
|
129
282
|
if (process.platform === "win32" && child.pid !== void 0) {
|
|
130
283
|
await terminateWindowsProcessTree(child.pid);
|
|
131
284
|
} else if (child.pid !== void 0) {
|
|
132
285
|
process.kill(-child.pid, "SIGTERM");
|
|
133
286
|
}
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
287
|
+
const abort = new AbortController();
|
|
288
|
+
const timeout = delay(5e3, "timeout", { signal: abort.signal });
|
|
289
|
+
try {
|
|
290
|
+
if (await Promise.race([exited, timeout]) === "timeout") {
|
|
291
|
+
if (process.platform === "win32" && child.pid !== void 0) await terminateWindowsProcessTree(child.pid);
|
|
292
|
+
else if (child.pid !== void 0) process.kill(-child.pid, "SIGKILL");
|
|
293
|
+
await exited;
|
|
294
|
+
}
|
|
295
|
+
} finally {
|
|
296
|
+
abort.abort();
|
|
139
297
|
}
|
|
140
298
|
this.child = void 0;
|
|
141
299
|
}
|
|
142
300
|
};
|
|
143
|
-
|
|
301
|
+
function appendOutput(previous, chunk) {
|
|
302
|
+
const output = previous + chunk;
|
|
303
|
+
return output.length > 65536 ? `[earlier output truncated]
|
|
304
|
+
${output.slice(-65536)}` : output;
|
|
305
|
+
}
|
|
306
|
+
async function endpointAcceptsConnections(path) {
|
|
307
|
+
if (process.platform !== "win32") {
|
|
308
|
+
try {
|
|
309
|
+
if (!(await lstat(path)).isSocket()) throw new Error(`Hasgard endpoint exists but is not a socket: ${path}`);
|
|
310
|
+
} catch (error) {
|
|
311
|
+
if (error.code === "ENOENT") return false;
|
|
312
|
+
throw error;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
144
315
|
return new Promise((resolve, reject) => {
|
|
145
316
|
const socket = createConnection({ path });
|
|
146
317
|
socket.once("connect", () => {
|
|
@@ -168,6 +339,32 @@ async function terminateWindowsProcessTree(pid) {
|
|
|
168
339
|
// src/rpc-client.ts
|
|
169
340
|
import { createConnection as createConnection2 } from "net";
|
|
170
341
|
import { createInterface } from "readline";
|
|
342
|
+
|
|
343
|
+
// src/protocol.ts
|
|
344
|
+
function isObject(value) {
|
|
345
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
346
|
+
}
|
|
347
|
+
function parseRpcResponse(line) {
|
|
348
|
+
const value = JSON.parse(line);
|
|
349
|
+
const invalid = () => new Error(`Invalid Hasgard JSON-RPC envelope: ${line}`);
|
|
350
|
+
if (!isObject(value) || value.jsonrpc !== "2.0" || !Object.hasOwn(value, "id")) throw invalid();
|
|
351
|
+
if (value.id !== null && (!Number.isSafeInteger(value.id) || value.id < 0)) throw invalid();
|
|
352
|
+
const hasResult = Object.hasOwn(value, "result");
|
|
353
|
+
const hasError = Object.hasOwn(value, "error");
|
|
354
|
+
if (hasResult === hasError) throw invalid();
|
|
355
|
+
if (hasResult && value.id === null) throw invalid();
|
|
356
|
+
if (hasError) {
|
|
357
|
+
const error = value.error;
|
|
358
|
+
if (!isObject(error) || !Number.isInteger(error.code) || error.code < -2147483648 || error.code > 2147483647 || typeof error.message !== "string")
|
|
359
|
+
throw invalid();
|
|
360
|
+
}
|
|
361
|
+
return value;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// src/protocol-limits.ts
|
|
365
|
+
var MAX_REQUEST_BYTES = 1048576;
|
|
366
|
+
|
|
367
|
+
// src/rpc-client.ts
|
|
171
368
|
var HasgardRpcError = class extends Error {
|
|
172
369
|
code;
|
|
173
370
|
data;
|
|
@@ -191,39 +388,66 @@ var HasgardRpcClient = class {
|
|
|
191
388
|
if (this.socket) throw new Error("Hasgard RPC client is already connected");
|
|
192
389
|
const socket = createConnection2({ path: this.socketPath });
|
|
193
390
|
this.socket = socket;
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
391
|
+
try {
|
|
392
|
+
await new Promise((resolve, reject) => {
|
|
393
|
+
const cleanup = () => {
|
|
394
|
+
clearTimeout(timeout);
|
|
395
|
+
socket.off("connect", connected);
|
|
396
|
+
socket.off("error", failed);
|
|
397
|
+
socket.off("close", closed);
|
|
398
|
+
};
|
|
399
|
+
const connected = () => {
|
|
400
|
+
cleanup();
|
|
401
|
+
resolve();
|
|
402
|
+
};
|
|
403
|
+
const failed = (error) => {
|
|
404
|
+
cleanup();
|
|
405
|
+
reject(error);
|
|
406
|
+
};
|
|
407
|
+
const closed = () => failed(new Error("Hasgard socket closed while connecting"));
|
|
408
|
+
const timeout = setTimeout(() => {
|
|
409
|
+
failed(new Error(`Hasgard socket connection timed out after ${timeoutMs}ms: ${this.socketPath}`));
|
|
410
|
+
socket.destroy();
|
|
411
|
+
}, timeoutMs);
|
|
412
|
+
socket.once("connect", connected);
|
|
413
|
+
socket.once("error", failed);
|
|
414
|
+
socket.once("close", closed);
|
|
206
415
|
});
|
|
207
|
-
})
|
|
416
|
+
} catch (error) {
|
|
417
|
+
if (this.socket === socket) this.socket = void 0;
|
|
418
|
+
socket.destroy();
|
|
419
|
+
throw error;
|
|
420
|
+
}
|
|
208
421
|
this.lines = createInterface({ input: socket });
|
|
209
422
|
this.lines.on("line", (line) => this.handleLine(line));
|
|
210
|
-
socket.on("close", () =>
|
|
211
|
-
|
|
423
|
+
socket.on("close", () => {
|
|
424
|
+
if (this.socket !== socket) return;
|
|
425
|
+
this.rejectPending(new Error("Hasgard socket closed"));
|
|
426
|
+
this.disconnect();
|
|
427
|
+
});
|
|
428
|
+
socket.on("error", (error) => {
|
|
429
|
+
if (this.socket === socket) this.rejectPending(error);
|
|
430
|
+
});
|
|
212
431
|
}
|
|
213
432
|
async call(method, params) {
|
|
214
433
|
const socket = this.socket;
|
|
215
|
-
if (!socket) throw new Error("Hasgard RPC client is not connected");
|
|
434
|
+
if (!socket || socket.destroyed || !socket.writable) throw new Error("Hasgard RPC client is not connected");
|
|
216
435
|
const id = this.nextId;
|
|
217
436
|
this.nextId += 1;
|
|
218
437
|
const request = params ? { jsonrpc: "2.0", id, method, params } : { jsonrpc: "2.0", id, method };
|
|
438
|
+
const line = `${JSON.stringify(request)}
|
|
439
|
+
`;
|
|
440
|
+
const bytes = Buffer.byteLength(line, "utf8");
|
|
441
|
+
if (bytes > MAX_REQUEST_BYTES) {
|
|
442
|
+
throw new Error(`Hasgard request is ${bytes} bytes; maximum is ${MAX_REQUEST_BYTES} bytes including newline`);
|
|
443
|
+
}
|
|
219
444
|
const result = new Promise((resolve, reject) => {
|
|
220
445
|
this.pending.set(id, {
|
|
221
446
|
resolve: (value) => resolve(value),
|
|
222
447
|
reject
|
|
223
448
|
});
|
|
224
449
|
});
|
|
225
|
-
socket.write(
|
|
226
|
-
`, (error) => {
|
|
450
|
+
socket.write(line, (error) => {
|
|
227
451
|
if (!error) return;
|
|
228
452
|
const pending = this.pending.get(id);
|
|
229
453
|
if (!pending) return;
|
|
@@ -242,16 +466,16 @@ var HasgardRpcClient = class {
|
|
|
242
466
|
handleLine(line) {
|
|
243
467
|
let response;
|
|
244
468
|
try {
|
|
245
|
-
response =
|
|
246
|
-
} catch {
|
|
247
|
-
this.rejectPending(
|
|
469
|
+
response = parseRpcResponse(line);
|
|
470
|
+
} catch (error) {
|
|
471
|
+
this.rejectPending(error instanceof Error ? error : new Error(String(error)));
|
|
248
472
|
return;
|
|
249
473
|
}
|
|
250
|
-
if (
|
|
251
|
-
this.rejectPending(new
|
|
474
|
+
if ("error" in response && response.id === null) {
|
|
475
|
+
this.rejectPending(new HasgardRpcError(response.error.code, response.error.message, response.error.data));
|
|
252
476
|
return;
|
|
253
477
|
}
|
|
254
|
-
const pending = this.pending.get(response.id);
|
|
478
|
+
const pending = response.id === null ? void 0 : this.pending.get(response.id);
|
|
255
479
|
if (!pending) {
|
|
256
480
|
this.rejectPending(new Error(`Unexpected Hasgard JSON-RPC response id: ${response.id}`));
|
|
257
481
|
return;
|
|
@@ -515,9 +739,11 @@ var HasgardApplication = class {
|
|
|
515
739
|
while (Date.now() <= deadline) {
|
|
516
740
|
if ((await this.windows()).some((window) => window.label === label)) {
|
|
517
741
|
const window = this.window(label);
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
return window;
|
|
742
|
+
try {
|
|
743
|
+
const state = await window.state();
|
|
744
|
+
if (state.url !== "about:blank" && state.readyState !== "loading") return window;
|
|
745
|
+
} catch (error) {
|
|
746
|
+
if (!(error instanceof HasgardRpcError) || error.code !== -32002) throw error;
|
|
521
747
|
}
|
|
522
748
|
}
|
|
523
749
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
@@ -660,8 +886,14 @@ var HasgardWindow = class {
|
|
|
660
886
|
async scrollBy(options) {
|
|
661
887
|
await this.rpc.call("scroll", withWindow(this.label, options));
|
|
662
888
|
}
|
|
663
|
-
async press(key) {
|
|
664
|
-
|
|
889
|
+
async press(key, options) {
|
|
890
|
+
const params = { key };
|
|
891
|
+
if (options) {
|
|
892
|
+
if (options.completion !== void 0) params.completion = options.completion;
|
|
893
|
+
if (options.waitFor !== void 0) params.waitFor = options.waitFor;
|
|
894
|
+
if (options.timeoutMs !== void 0) params.timeout = options.timeoutMs;
|
|
895
|
+
}
|
|
896
|
+
await this.rpc.call("press", withWindow(this.label, params));
|
|
665
897
|
}
|
|
666
898
|
async diff(options = {}) {
|
|
667
899
|
const { reference, ...snapshotOptions } = options;
|
|
@@ -776,6 +1008,32 @@ var HasgardWindow = class {
|
|
|
776
1008
|
if (!dataUrl.startsWith(prefix)) throw new Error("screenshot result is not a PNG data URL");
|
|
777
1009
|
return Buffer.from(dataUrl.slice(prefix.length), "base64");
|
|
778
1010
|
}
|
|
1011
|
+
/** Start a bounded recording of this native window. macOS only; requires ffmpeg. */
|
|
1012
|
+
async startVideo(options) {
|
|
1013
|
+
await this.rpc.call("video.start", {
|
|
1014
|
+
window: this.label,
|
|
1015
|
+
output_path: options.outputPath,
|
|
1016
|
+
...options.fps === void 0 ? {} : { fps: options.fps },
|
|
1017
|
+
...options.maxDurationMs === void 0 ? {} : { max_duration_ms: options.maxDurationMs }
|
|
1018
|
+
});
|
|
1019
|
+
}
|
|
1020
|
+
/** Stop recording; capture and encoder failures reject rather than returning an empty artifact. */
|
|
1021
|
+
async stopVideo() {
|
|
1022
|
+
const value = expectRecord(await this.rpc.call("video.stop", { window: this.label }), "video.stop result");
|
|
1023
|
+
return {
|
|
1024
|
+
outputPath: expectString(value.output_path, "video.output_path"),
|
|
1025
|
+
frames: expectNumber(value.frames, "video.frames"),
|
|
1026
|
+
durationMs: expectNumber(value.duration_ms, "video.duration_ms"),
|
|
1027
|
+
byteSize: expectNumber(value.byte_size, "video.byte_size")
|
|
1028
|
+
};
|
|
1029
|
+
}
|
|
1030
|
+
async videoStatus() {
|
|
1031
|
+
const value = expectRecord(await this.rpc.call("video.status", { window: this.label }), "video.status result");
|
|
1032
|
+
return {
|
|
1033
|
+
active: expectBoolean(value.active, "video.active"),
|
|
1034
|
+
pendingResult: expectBoolean(value.pending_result, "video.pending_result")
|
|
1035
|
+
};
|
|
1036
|
+
}
|
|
779
1037
|
/**
|
|
780
1038
|
* Capture this window through the native compositor, writing a PNG to
|
|
781
1039
|
* `outputPath`. macOS only.
|
|
@@ -1140,7 +1398,9 @@ var HasgardLocator = class _HasgardLocator {
|
|
|
1140
1398
|
});
|
|
1141
1399
|
}
|
|
1142
1400
|
async isVisible() {
|
|
1143
|
-
return this.
|
|
1401
|
+
return this.window.runExclusive(async () => {
|
|
1402
|
+
const target = await this.resolveUnique(true);
|
|
1403
|
+
if (target === null) return false;
|
|
1144
1404
|
const value = expectRecord(await this.window.call("visible", this.scoped(target)), "visible result");
|
|
1145
1405
|
return expectBoolean(value.visible, "visible.visible");
|
|
1146
1406
|
});
|
|
@@ -1212,7 +1472,7 @@ var HasgardLocator = class _HasgardLocator {
|
|
|
1212
1472
|
last() {
|
|
1213
1473
|
return this.nth(-1);
|
|
1214
1474
|
}
|
|
1215
|
-
async resolveUnique() {
|
|
1475
|
+
async resolveUnique(allowMissing = false) {
|
|
1216
1476
|
if (this.query.kind === "selector" && !this.query.filters?.length) {
|
|
1217
1477
|
return this.query.index === void 0 ? bySelector(this.query.selector) : { selector: this.query.selector, index: this.query.index };
|
|
1218
1478
|
}
|
|
@@ -1220,10 +1480,12 @@ var HasgardLocator = class _HasgardLocator {
|
|
|
1220
1480
|
if (this.query.index !== void 0) {
|
|
1221
1481
|
const element = elements[absoluteIndex(this.query.index, elements.length)];
|
|
1222
1482
|
if (!element) {
|
|
1483
|
+
if (allowMissing) return null;
|
|
1223
1484
|
throw new Error(`Locator has no element at index ${this.query.index} (${elements.length} matched)`);
|
|
1224
1485
|
}
|
|
1225
1486
|
return byRef(element.ref);
|
|
1226
1487
|
}
|
|
1488
|
+
if (elements.length === 0 && allowMissing) return null;
|
|
1227
1489
|
if (elements.length !== 1) {
|
|
1228
1490
|
throw new Error(`Locator resolved to ${elements.length} elements; expected exactly one`);
|
|
1229
1491
|
}
|
|
@@ -1348,6 +1610,7 @@ function createHasgardTest(config) {
|
|
|
1348
1610
|
const expect = createHasgardExpect(config.expect);
|
|
1349
1611
|
const readinessTimeoutMs = config.launch ? config.launch.timeoutMs : 3e4;
|
|
1350
1612
|
const fixtureTimeoutMs = config.launch ? config.launch.timeoutMs + 3e4 : 3e4;
|
|
1613
|
+
const processes = /* @__PURE__ */ new WeakMap();
|
|
1351
1614
|
const test = base.extend({
|
|
1352
1615
|
hasgard: [
|
|
1353
1616
|
async ({}, use, workerInfo) => {
|
|
@@ -1359,10 +1622,20 @@ function createHasgardTest(config) {
|
|
|
1359
1622
|
else await process2.waitForSocket(socketPath, workerInfo.project.timeout);
|
|
1360
1623
|
await rpc.connect(workerInfo.project.timeout);
|
|
1361
1624
|
const hasgard = new HasgardApplication(rpc);
|
|
1625
|
+
processes.set(hasgard, process2);
|
|
1362
1626
|
const ping = await hasgard.ping();
|
|
1363
1627
|
if (ping.status !== "ok") throw new Error(`Unexpected Hasgard ping status: ${ping.status}`);
|
|
1364
1628
|
await hasgard.waitForWindowReady(config.windowLabel, config.readySelector, readinessTimeoutMs);
|
|
1365
1629
|
await use(hasgard);
|
|
1630
|
+
} catch (error) {
|
|
1631
|
+
if (config.launch) {
|
|
1632
|
+
try {
|
|
1633
|
+
console.error(`Hasgard fixture failed: ${JSON.stringify(await process2.diagnostics(250))}`);
|
|
1634
|
+
} catch (diagnosticError) {
|
|
1635
|
+
console.error("Hasgard diagnostics failed:", diagnosticError);
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
throw error;
|
|
1366
1639
|
} finally {
|
|
1367
1640
|
rpc.disconnect();
|
|
1368
1641
|
await process2.stop();
|
|
@@ -1373,11 +1646,21 @@ function createHasgardTest(config) {
|
|
|
1373
1646
|
window: async ({ hasgard }, use, testInfo) => {
|
|
1374
1647
|
await use(hasgard.window(config.windowLabel));
|
|
1375
1648
|
if (testInfo.status !== testInfo.expectedStatus) {
|
|
1376
|
-
const
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1649
|
+
const managed = processes.get(hasgard);
|
|
1650
|
+
if (managed && config.launch) {
|
|
1651
|
+
const diagnostics = await managed.diagnostics(250);
|
|
1652
|
+
await testInfo.attach("hasgard-process", {
|
|
1653
|
+
body: JSON.stringify(diagnostics, null, 2),
|
|
1654
|
+
contentType: "application/json"
|
|
1655
|
+
});
|
|
1656
|
+
if (diagnostics.exit) console.error(`Hasgard application exited: ${JSON.stringify(diagnostics)}`);
|
|
1657
|
+
}
|
|
1658
|
+
try {
|
|
1659
|
+
const screenshot = await hasgard.window(config.windowLabel).screenshot();
|
|
1660
|
+
await testInfo.attach("hasgard-screenshot", { body: screenshot, contentType: "image/png" });
|
|
1661
|
+
} catch (error) {
|
|
1662
|
+
await testInfo.attach("hasgard-screenshot-error", { body: String(error), contentType: "text/plain" });
|
|
1663
|
+
}
|
|
1381
1664
|
}
|
|
1382
1665
|
}
|
|
1383
1666
|
});
|
|
@@ -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
|
@@ -78,6 +78,12 @@ export interface DragOffset {
|
|
|
78
78
|
}
|
|
79
79
|
/** A held keyboard modifier. Named as the platform reports them on the event. */
|
|
80
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
|
+
}
|
|
81
87
|
export type MouseButton = "left" | "middle" | "right";
|
|
82
88
|
/** A point inside the element, measured from its top-left corner. */
|
|
83
89
|
export interface ElementPosition {
|
package/dist/window.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { HasgardRpcClient } from "./rpc-client.js";
|
|
2
|
-
import type { BoundingBox, ClickOptions, ConsoleLogEntry, ConsoleLogOptions, DialogListing, DialogPolicy, DiffOptions, DragOffset, DropFile, FileInput, FilterOptions, FormsDump, FulfillOptions, HasgardTarget, JsonValue, NativeScreenshot, NativeScreenshotOptions, NetworkRequestEntry, NetworkRequestOptions, QueryDimension, RecorderEntry, RecorderResult, RecorderStatus, RoleQuery, RouteListing, RouteOptions, 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;
|
|
@@ -65,7 +65,7 @@ export declare class HasgardWindow {
|
|
|
65
65
|
navigate(url: string): Promise<void>;
|
|
66
66
|
/** Scroll the document by a relative amount, or to `top`/`bottom`. */
|
|
67
67
|
scrollBy(options: ScrollOptions): Promise<void>;
|
|
68
|
-
press(key: string): Promise<void>;
|
|
68
|
+
press(key: string, options?: PressOptions): Promise<void>;
|
|
69
69
|
diff(options?: DiffOptions): Promise<SnapshotDiff>;
|
|
70
70
|
/**
|
|
71
71
|
* Poll a JavaScript expression in the webview until it returns a truthy
|
|
@@ -91,6 +91,23 @@ export declare class HasgardWindow {
|
|
|
91
91
|
evaluate<T>(script: string): Promise<T>;
|
|
92
92
|
invoke<T>(command: string, args: Record<string, JsonValue>): Promise<T>;
|
|
93
93
|
screenshot(selector?: string): Promise<Buffer>;
|
|
94
|
+
/** Start a bounded recording of this native window. macOS only; requires ffmpeg. */
|
|
95
|
+
startVideo(options: {
|
|
96
|
+
outputPath: string;
|
|
97
|
+
fps?: number;
|
|
98
|
+
maxDurationMs?: number;
|
|
99
|
+
}): Promise<void>;
|
|
100
|
+
/** Stop recording; capture and encoder failures reject rather than returning an empty artifact. */
|
|
101
|
+
stopVideo(): Promise<{
|
|
102
|
+
outputPath: string;
|
|
103
|
+
frames: number;
|
|
104
|
+
durationMs: number;
|
|
105
|
+
byteSize: number;
|
|
106
|
+
}>;
|
|
107
|
+
videoStatus(): Promise<{
|
|
108
|
+
active: boolean;
|
|
109
|
+
pendingResult: boolean;
|
|
110
|
+
}>;
|
|
94
111
|
/**
|
|
95
112
|
* Capture this window through the native compositor, writing a PNG to
|
|
96
113
|
* `outputPath`. macOS only.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nyssance/tauri-hasgard",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
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": "
|
|
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"
|