@nyssance/tauri-hasgard 0.3.0 → 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.
- package/README.md +44 -0
- package/dist/chunk-N7YDYAKI.js +31 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +323 -77
- 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 +2 -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;
|
|
@@ -660,8 +884,14 @@ var HasgardWindow = class {
|
|
|
660
884
|
async scrollBy(options) {
|
|
661
885
|
await this.rpc.call("scroll", withWindow(this.label, options));
|
|
662
886
|
}
|
|
663
|
-
async press(key) {
|
|
664
|
-
|
|
887
|
+
async press(key, options) {
|
|
888
|
+
const params = { key };
|
|
889
|
+
if (options) {
|
|
890
|
+
if (options.completion !== void 0) params.completion = options.completion;
|
|
891
|
+
if (options.waitFor !== void 0) params.waitFor = options.waitFor;
|
|
892
|
+
if (options.timeoutMs !== void 0) params.timeout = options.timeoutMs;
|
|
893
|
+
}
|
|
894
|
+
await this.rpc.call("press", withWindow(this.label, params));
|
|
665
895
|
}
|
|
666
896
|
async diff(options = {}) {
|
|
667
897
|
const { reference, ...snapshotOptions } = options;
|
|
@@ -1140,7 +1370,9 @@ var HasgardLocator = class _HasgardLocator {
|
|
|
1140
1370
|
});
|
|
1141
1371
|
}
|
|
1142
1372
|
async isVisible() {
|
|
1143
|
-
return this.
|
|
1373
|
+
return this.window.runExclusive(async () => {
|
|
1374
|
+
const target = await this.resolveUnique(true);
|
|
1375
|
+
if (target === null) return false;
|
|
1144
1376
|
const value = expectRecord(await this.window.call("visible", this.scoped(target)), "visible result");
|
|
1145
1377
|
return expectBoolean(value.visible, "visible.visible");
|
|
1146
1378
|
});
|
|
@@ -1212,7 +1444,7 @@ var HasgardLocator = class _HasgardLocator {
|
|
|
1212
1444
|
last() {
|
|
1213
1445
|
return this.nth(-1);
|
|
1214
1446
|
}
|
|
1215
|
-
async resolveUnique() {
|
|
1447
|
+
async resolveUnique(allowMissing = false) {
|
|
1216
1448
|
if (this.query.kind === "selector" && !this.query.filters?.length) {
|
|
1217
1449
|
return this.query.index === void 0 ? bySelector(this.query.selector) : { selector: this.query.selector, index: this.query.index };
|
|
1218
1450
|
}
|
|
@@ -1220,10 +1452,12 @@ var HasgardLocator = class _HasgardLocator {
|
|
|
1220
1452
|
if (this.query.index !== void 0) {
|
|
1221
1453
|
const element = elements[absoluteIndex(this.query.index, elements.length)];
|
|
1222
1454
|
if (!element) {
|
|
1455
|
+
if (allowMissing) return null;
|
|
1223
1456
|
throw new Error(`Locator has no element at index ${this.query.index} (${elements.length} matched)`);
|
|
1224
1457
|
}
|
|
1225
1458
|
return byRef(element.ref);
|
|
1226
1459
|
}
|
|
1460
|
+
if (elements.length === 0 && allowMissing) return null;
|
|
1227
1461
|
if (elements.length !== 1) {
|
|
1228
1462
|
throw new Error(`Locator resolved to ${elements.length} elements; expected exactly one`);
|
|
1229
1463
|
}
|
|
@@ -1348,6 +1582,7 @@ function createHasgardTest(config) {
|
|
|
1348
1582
|
const expect = createHasgardExpect(config.expect);
|
|
1349
1583
|
const readinessTimeoutMs = config.launch ? config.launch.timeoutMs : 3e4;
|
|
1350
1584
|
const fixtureTimeoutMs = config.launch ? config.launch.timeoutMs + 3e4 : 3e4;
|
|
1585
|
+
const processes = /* @__PURE__ */ new WeakMap();
|
|
1351
1586
|
const test = base.extend({
|
|
1352
1587
|
hasgard: [
|
|
1353
1588
|
async ({}, use, workerInfo) => {
|
|
@@ -1359,6 +1594,7 @@ function createHasgardTest(config) {
|
|
|
1359
1594
|
else await process2.waitForSocket(socketPath, workerInfo.project.timeout);
|
|
1360
1595
|
await rpc.connect(workerInfo.project.timeout);
|
|
1361
1596
|
const hasgard = new HasgardApplication(rpc);
|
|
1597
|
+
processes.set(hasgard, process2);
|
|
1362
1598
|
const ping = await hasgard.ping();
|
|
1363
1599
|
if (ping.status !== "ok") throw new Error(`Unexpected Hasgard ping status: ${ping.status}`);
|
|
1364
1600
|
await hasgard.waitForWindowReady(config.windowLabel, config.readySelector, readinessTimeoutMs);
|
|
@@ -1373,11 +1609,21 @@ function createHasgardTest(config) {
|
|
|
1373
1609
|
window: async ({ hasgard }, use, testInfo) => {
|
|
1374
1610
|
await use(hasgard.window(config.windowLabel));
|
|
1375
1611
|
if (testInfo.status !== testInfo.expectedStatus) {
|
|
1376
|
-
const
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1612
|
+
const managed = processes.get(hasgard);
|
|
1613
|
+
if (managed && config.launch) {
|
|
1614
|
+
const diagnostics = await managed.diagnostics(250);
|
|
1615
|
+
await testInfo.attach("hasgard-process", {
|
|
1616
|
+
body: JSON.stringify(diagnostics, null, 2),
|
|
1617
|
+
contentType: "application/json"
|
|
1618
|
+
});
|
|
1619
|
+
if (diagnostics.exit) console.error(`Hasgard application exited: ${JSON.stringify(diagnostics)}`);
|
|
1620
|
+
}
|
|
1621
|
+
try {
|
|
1622
|
+
const screenshot = await hasgard.window(config.windowLabel).screenshot();
|
|
1623
|
+
await testInfo.attach("hasgard-screenshot", { body: screenshot, contentType: "image/png" });
|
|
1624
|
+
} catch (error) {
|
|
1625
|
+
await testInfo.attach("hasgard-screenshot-error", { body: String(error), contentType: "text/plain" });
|
|
1626
|
+
}
|
|
1381
1627
|
}
|
|
1382
1628
|
}
|
|
1383
1629
|
});
|
|
@@ -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
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nyssance/tauri-hasgard",
|
|
3
|
-
"version": "0.
|
|
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": "
|
|
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"
|