haltija 1.1.21 → 1.2.3

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/dist/test.js ADDED
@@ -0,0 +1,847 @@
1
+ // @bun
2
+ var __defProp = Object.defineProperty;
3
+ var __returnValue = (v) => v;
4
+ function __exportSetter(name, newValue) {
5
+ this[name] = __returnValue.bind(null, newValue);
6
+ }
7
+ var __export = (target, all) => {
8
+ for (var name in all)
9
+ __defProp(target, name, {
10
+ get: all[name],
11
+ enumerable: true,
12
+ configurable: true,
13
+ set: __exportSetter.bind(all, name)
14
+ });
15
+ };
16
+ var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
17
+ var __require = import.meta.require;
18
+
19
+ // src/client.ts
20
+ class DevChannelClient {
21
+ baseUrl;
22
+ constructor(serverUrl = "http://localhost:8700") {
23
+ this.baseUrl = serverUrl;
24
+ }
25
+ async status() {
26
+ const res = await fetch(`${this.baseUrl}/status`);
27
+ return res.json();
28
+ }
29
+ async send(channel, action, payload) {
30
+ const res = await fetch(`${this.baseUrl}/send`, {
31
+ method: "POST",
32
+ headers: { "Content-Type": "application/json" },
33
+ body: JSON.stringify({ channel, action, payload })
34
+ });
35
+ return res.json();
36
+ }
37
+ async request(channel, action, payload, timeout = 5000) {
38
+ const res = await fetch(`${this.baseUrl}/request`, {
39
+ method: "POST",
40
+ headers: { "Content-Type": "application/json" },
41
+ body: JSON.stringify({ channel, action, payload, timeout })
42
+ });
43
+ return res.json();
44
+ }
45
+ async query(selector) {
46
+ const res = await fetch(`${this.baseUrl}/query`, {
47
+ method: "POST",
48
+ headers: { "Content-Type": "application/json" },
49
+ body: JSON.stringify({ selector, all: false })
50
+ });
51
+ const response = await res.json();
52
+ return response.success ? response.data : null;
53
+ }
54
+ async queryAll(selector) {
55
+ const res = await fetch(`${this.baseUrl}/query`, {
56
+ method: "POST",
57
+ headers: { "Content-Type": "application/json" },
58
+ body: JSON.stringify({ selector, all: true })
59
+ });
60
+ const response = await res.json();
61
+ return response.success ? response.data : [];
62
+ }
63
+ async getConsole(since = 0) {
64
+ const res = await fetch(`${this.baseUrl}/console?since=${since}`);
65
+ const response = await res.json();
66
+ return response.success ? response.data : [];
67
+ }
68
+ async clearConsole() {
69
+ await this.request("console", "clear", {});
70
+ }
71
+ async eval(code) {
72
+ const res = await fetch(`${this.baseUrl}/eval`, {
73
+ method: "POST",
74
+ headers: { "Content-Type": "application/json" },
75
+ body: JSON.stringify({ code })
76
+ });
77
+ const response = await res.json();
78
+ if (!response.success) {
79
+ throw new Error(response.error || "Eval failed");
80
+ }
81
+ return response.data;
82
+ }
83
+ async click(selector, options) {
84
+ const res = await fetch(`${this.baseUrl}/click`, {
85
+ method: "POST",
86
+ headers: { "Content-Type": "application/json" },
87
+ body: JSON.stringify({ selector, options })
88
+ });
89
+ const response = await res.json();
90
+ if (!response.success) {
91
+ throw new Error(response.error || `Click failed on ${selector}`);
92
+ }
93
+ }
94
+ async type(selector, text) {
95
+ const res = await fetch(`${this.baseUrl}/type`, {
96
+ method: "POST",
97
+ headers: { "Content-Type": "application/json" },
98
+ body: JSON.stringify({ selector, text })
99
+ });
100
+ const response = await res.json();
101
+ if (!response.success) {
102
+ throw new Error(response.error || `Type failed on ${selector}`);
103
+ }
104
+ }
105
+ async dispatch(event) {
106
+ const response = await this.request("events", "dispatch", event);
107
+ if (!response.success) {
108
+ throw new Error(response.error || "Dispatch failed");
109
+ }
110
+ }
111
+ async focus(selector) {
112
+ await this.dispatch({ selector, event: "focus" });
113
+ }
114
+ async blur(selector) {
115
+ await this.dispatch({ selector, event: "blur" });
116
+ }
117
+ async press(key, modifiers) {
118
+ await this.dispatch({
119
+ selector: "body",
120
+ event: "keydown",
121
+ options: {
122
+ key,
123
+ altKey: modifiers?.alt,
124
+ ctrlKey: modifiers?.ctrl,
125
+ metaKey: modifiers?.meta,
126
+ shiftKey: modifiers?.shift
127
+ }
128
+ });
129
+ }
130
+ async refresh(hard = false) {
131
+ const res = await fetch(`${this.baseUrl}/refresh`, {
132
+ method: "POST",
133
+ headers: { "Content-Type": "application/json" },
134
+ body: JSON.stringify({ hard })
135
+ });
136
+ const response = await res.json();
137
+ if (!response.success) {
138
+ throw new Error(response.error || "Refresh failed");
139
+ }
140
+ }
141
+ async navigate(url) {
142
+ const res = await fetch(`${this.baseUrl}/navigate`, {
143
+ method: "POST",
144
+ headers: { "Content-Type": "application/json" },
145
+ body: JSON.stringify({ url })
146
+ });
147
+ const response = await res.json();
148
+ if (!response.success) {
149
+ throw new Error(response.error || "Navigate failed");
150
+ }
151
+ }
152
+ async getLocation() {
153
+ const res = await fetch(`${this.baseUrl}/location`);
154
+ const response = await res.json();
155
+ if (!response.success) {
156
+ throw new Error(response.error || "Get location failed");
157
+ }
158
+ return response.data;
159
+ }
160
+ async startRecording(name) {
161
+ const res = await fetch(`${this.baseUrl}/recording/start`, {
162
+ method: "POST",
163
+ headers: { "Content-Type": "application/json" },
164
+ body: JSON.stringify({ name })
165
+ });
166
+ const response = await res.json();
167
+ if (!response.success) {
168
+ throw new Error(response.error || "Start recording failed");
169
+ }
170
+ return response.data.sessionId;
171
+ }
172
+ async stopRecording() {
173
+ const res = await fetch(`${this.baseUrl}/recording/stop`, {
174
+ method: "POST"
175
+ });
176
+ const response = await res.json();
177
+ if (!response.success) {
178
+ throw new Error(response.error || "Stop recording failed");
179
+ }
180
+ return response.data;
181
+ }
182
+ async replayRecording(session, speed = 1) {
183
+ const response = await this.request("recording", "replay", { session, speed });
184
+ if (!response.success) {
185
+ throw new Error(response.error || "Replay failed");
186
+ }
187
+ }
188
+ async publishBuild(event) {
189
+ await fetch(`${this.baseUrl}/build`, {
190
+ method: "POST",
191
+ headers: { "Content-Type": "application/json" },
192
+ body: JSON.stringify(event)
193
+ });
194
+ }
195
+ async watchEvents(options) {
196
+ const response = await this.request("events", "watch", options);
197
+ if (!response.success) {
198
+ throw new Error(response.error || "Watch failed");
199
+ }
200
+ return response.data.watchId;
201
+ }
202
+ async unwatchEvents(watchId) {
203
+ const response = await this.request("events", "unwatch", { watchId });
204
+ if (!response.success) {
205
+ throw new Error(response.error || "Unwatch failed");
206
+ }
207
+ }
208
+ async watchMutations(options) {
209
+ const res = await fetch(`${this.baseUrl}/mutations/watch`, {
210
+ method: "POST",
211
+ headers: { "Content-Type": "application/json" },
212
+ body: JSON.stringify(options || {})
213
+ });
214
+ const response = await res.json();
215
+ if (!response.success) {
216
+ throw new Error(response.error || "Watch mutations failed");
217
+ }
218
+ }
219
+ async unwatchMutations() {
220
+ const res = await fetch(`${this.baseUrl}/mutations/unwatch`, {
221
+ method: "POST"
222
+ });
223
+ const response = await res.json();
224
+ if (!response.success) {
225
+ throw new Error(response.error || "Unwatch mutations failed");
226
+ }
227
+ }
228
+ async getMutationStatus() {
229
+ const res = await fetch(`${this.baseUrl}/mutations/status`);
230
+ const response = await res.json();
231
+ if (!response.success) {
232
+ throw new Error(response.error || "Get mutation status failed");
233
+ }
234
+ return response.data;
235
+ }
236
+ async getMessages(since = 0) {
237
+ const res = await fetch(`${this.baseUrl}/messages?since=${since}`);
238
+ return res.json();
239
+ }
240
+ async getInteractiveElements() {
241
+ const [buttons, links, inputs] = await Promise.all([
242
+ this.queryAll("button"),
243
+ this.queryAll("a[href]"),
244
+ this.queryAll("input, textarea, select")
245
+ ]);
246
+ return {
247
+ buttons: buttons.map((el) => ({
248
+ selector: el.id ? `#${el.id}` : `button`,
249
+ text: el.innerText?.slice(0, 50) || ""
250
+ })),
251
+ links: links.map((el) => ({
252
+ selector: el.id ? `#${el.id}` : `a[href="${el.attributes?.href}"]`,
253
+ text: el.innerText?.slice(0, 50) || "",
254
+ href: el.attributes?.href || ""
255
+ })),
256
+ inputs: inputs.map((el) => ({
257
+ selector: el.id ? `#${el.id}` : `${el.tagName}[name="${el.attributes?.name}"]`,
258
+ type: el.attributes?.type,
259
+ name: el.attributes?.name,
260
+ placeholder: el.attributes?.placeholder
261
+ }))
262
+ };
263
+ }
264
+ async getCustomElements() {
265
+ return this.eval(`
266
+ Array.from(document.querySelectorAll('*'))
267
+ .filter(el => el.tagName.includes('-'))
268
+ .reduce((acc, el) => {
269
+ const tag = el.tagName.toLowerCase()
270
+ if (!acc[tag]) acc[tag] = { count: 0, examples: [] }
271
+ acc[tag].count++
272
+ if (acc[tag].examples.length < 3) {
273
+ acc[tag].examples.push({
274
+ id: el.id || null,
275
+ classes: el.className?.split?.(' ')?.slice(0, 3) || [],
276
+ text: el.textContent?.slice(0, 50)
277
+ })
278
+ }
279
+ return acc
280
+ }, {})
281
+ `);
282
+ }
283
+ async doAndWait(action, options) {
284
+ const timeout = options?.timeout || 2000;
285
+ const debounce = options?.debounce || 100;
286
+ await this.watchMutations({ debounce });
287
+ const startTime = Date.now();
288
+ await action();
289
+ let mutations = [];
290
+ let lastMutationTime = Date.now();
291
+ while (Date.now() - startTime < timeout) {
292
+ const messages = await this.getMessages(startTime);
293
+ const newMutations = messages.filter((m) => m.channel === "mutations" && m.action === "batch");
294
+ if (newMutations.length > mutations.length) {
295
+ mutations = newMutations;
296
+ lastMutationTime = Date.now();
297
+ } else if (mutations.length > 0 && Date.now() - lastMutationTime > debounce * 2) {
298
+ break;
299
+ }
300
+ await new Promise((r) => setTimeout(r, 50));
301
+ }
302
+ await this.unwatchMutations();
303
+ return {
304
+ mutations,
305
+ duration: Date.now() - startTime
306
+ };
307
+ }
308
+ async validateTest(test) {
309
+ const res = await fetch(`${this.baseUrl}/test/validate`, {
310
+ method: "POST",
311
+ headers: { "Content-Type": "application/json" },
312
+ body: JSON.stringify(test)
313
+ });
314
+ return res.json();
315
+ }
316
+ async runTest(test, options) {
317
+ const res = await fetch(`${this.baseUrl}/test/run`, {
318
+ method: "POST",
319
+ headers: { "Content-Type": "application/json" },
320
+ body: JSON.stringify({ test, ...options })
321
+ });
322
+ const result = await res.json();
323
+ return {
324
+ test,
325
+ passed: result.passed,
326
+ startTime: Date.now() - result.duration,
327
+ endTime: Date.now(),
328
+ steps: result.steps,
329
+ error: result.steps.find((s) => !s.passed)?.error
330
+ };
331
+ }
332
+ async runTestSuite(tests, options) {
333
+ const res = await fetch(`${this.baseUrl}/test/suite`, {
334
+ method: "POST",
335
+ headers: { "Content-Type": "application/json" },
336
+ body: JSON.stringify({ tests, ...options })
337
+ });
338
+ return res.json();
339
+ }
340
+ async loadTest(source) {
341
+ if (source.startsWith("http://") || source.startsWith("https://")) {
342
+ const res = await fetch(source);
343
+ return res.json();
344
+ }
345
+ return JSON.parse(source);
346
+ }
347
+ formatTestResult(result) {
348
+ const lines = [];
349
+ const icon = result.passed ? "\u2713" : "\u2717";
350
+ const status = result.passed ? "PASSED" : "FAILED";
351
+ lines.push(`${icon} ${result.test.name} - ${status}`);
352
+ lines.push(` Duration: ${result.endTime - result.startTime}ms`);
353
+ lines.push("");
354
+ for (const step of result.steps) {
355
+ const stepIcon = step.passed ? " \u2713" : " \u2717";
356
+ const desc = step.description || `Step ${step.index + 1}`;
357
+ lines.push(`${stepIcon} ${desc}`);
358
+ if (!step.passed && step.error) {
359
+ lines.push(` Error: ${step.error}`);
360
+ if (step.purpose) {
361
+ lines.push(` Purpose: ${step.purpose}`);
362
+ }
363
+ if (step.context) {
364
+ lines.push(` Context: ${JSON.stringify(step.context)}`);
365
+ }
366
+ }
367
+ }
368
+ return lines.join(`
369
+ `);
370
+ }
371
+ }
372
+ var devChannel = new DevChannelClient;
373
+
374
+ // src/test.ts
375
+ import { readFileSync, readdirSync, statSync } from "fs";
376
+ import { join, resolve } from "path";
377
+
378
+ // src/test-formatters.ts
379
+ function formatTestGitHub(result, test, testFile) {
380
+ const lines = [];
381
+ for (const step of result.steps.filter((s) => !s.passed)) {
382
+ const annotation = buildAnnotation(step, test, testFile);
383
+ lines.push(annotation);
384
+ }
385
+ lines.push("");
386
+ lines.push("---SUMMARY---");
387
+ lines.push(buildTestSummary(result, test));
388
+ return lines.join(`
389
+ `);
390
+ }
391
+ function formatSuiteGitHub(result, tests, testFiles) {
392
+ const lines = [];
393
+ result.results.forEach((testResult, i) => {
394
+ const test = tests[i];
395
+ const testFile = testFiles?.[i];
396
+ for (const step of testResult.steps.filter((s) => !s.passed)) {
397
+ lines.push(buildAnnotation(step, test, testFile));
398
+ }
399
+ });
400
+ lines.push("");
401
+ lines.push("---SUMMARY---");
402
+ lines.push(buildSuiteSummary(result, tests));
403
+ return lines.join(`
404
+ `);
405
+ }
406
+ function buildAnnotation(step, test, testFile) {
407
+ const title = buildFailureTitle(step, test);
408
+ const message = buildFailureMessage(step);
409
+ const file = testFile ? `file=${testFile},` : "";
410
+ return `::error ${file}title=${escapeAnnotation(title)}::${escapeAnnotation(message)}`;
411
+ }
412
+ function buildFailureTitle(step, test) {
413
+ const stepDesc = step.description || `Step ${step.index + 1}`;
414
+ const purpose = step.purpose ? ` (${step.purpose})` : "";
415
+ return `${stepDesc}${purpose}`;
416
+ }
417
+ function buildFailureMessage(step) {
418
+ const parts = [];
419
+ const reason = step.context?.reason || step.error || "Unknown error";
420
+ parts.push(reason);
421
+ if (step.context?.buttonsOnPage?.length) {
422
+ parts.push(`Page shows: ${step.context.buttonsOnPage.join(", ")}`);
423
+ }
424
+ if (step.context?.actual !== undefined && step.context?.expected !== undefined) {
425
+ parts.push(`Expected "${step.context.expected}", got "${step.context.actual}"`);
426
+ }
427
+ if (step.context?.suggestion) {
428
+ parts.push(step.context.suggestion);
429
+ }
430
+ return parts.join(". ");
431
+ }
432
+ function buildTestSummary(result, test) {
433
+ const icon = result.passed ? "\u2705" : "\u274C";
434
+ const lines = [];
435
+ lines.push(`## ${icon} ${test.name}`);
436
+ lines.push("");
437
+ if (test.description) {
438
+ lines.push(`> ${test.description}`);
439
+ lines.push("");
440
+ }
441
+ if (!result.passed) {
442
+ const failed = result.steps.filter((s) => !s.passed);
443
+ for (const step of failed) {
444
+ lines.push(`### Step ${step.index + 1}: ${step.description || "Unknown step"}`);
445
+ lines.push("");
446
+ if (step.purpose) {
447
+ lines.push(`**Tried to:** ${step.purpose}`);
448
+ }
449
+ lines.push(`**What happened:** ${step.context?.reason || step.error || "Unknown error"}`);
450
+ if (step.context?.buttonsOnPage?.length) {
451
+ lines.push(`**What's on the page:** ${step.context.buttonsOnPage.join(", ")}`);
452
+ }
453
+ if (step.context?.actual !== undefined) {
454
+ lines.push(`**Expected:** ${step.context.expected}`);
455
+ lines.push(`**Actual:** ${step.context.actual}`);
456
+ }
457
+ if (step.context?.suggestion) {
458
+ lines.push(`**Likely cause:** ${step.context.suggestion}`);
459
+ }
460
+ if (step.step.planRef) {
461
+ lines.push(`**Plan:** ${step.step.planRef}`);
462
+ }
463
+ lines.push("");
464
+ }
465
+ }
466
+ lines.push("| Metric | Value |");
467
+ lines.push("|--------|-------|");
468
+ lines.push(`| Duration | ${result.duration}ms |`);
469
+ lines.push(`| Steps | ${result.summary.passed}/${result.summary.total} passed |`);
470
+ if (result.patience) {
471
+ const p = result.patience;
472
+ lines.push(`| Patience | ${p.remaining}/${p.allowed} remaining (${p.failures} failures, streak limit ${p.streak}) |`);
473
+ lines.push(`| Final Timeout | ${p.finalTimeoutMs}ms |`);
474
+ }
475
+ if (result.snapshotId) {
476
+ lines.push(`| Snapshot | ${result.snapshotId} |`);
477
+ }
478
+ return lines.join(`
479
+ `);
480
+ }
481
+ function buildSuiteSummary(result, tests) {
482
+ const icon = result.summary.failed === 0 ? "\u2705" : "\u274C";
483
+ const lines = [];
484
+ lines.push(`## ${icon} Test Suite Results`);
485
+ lines.push("");
486
+ lines.push(`**${result.summary.passed}/${result.summary.total} tests passed** in ${result.duration}ms`);
487
+ lines.push("");
488
+ lines.push("| Test | Status | Duration | Issue |");
489
+ lines.push("|------|--------|----------|-------|");
490
+ result.results.forEach((testResult, i) => {
491
+ const test = tests[i];
492
+ const status = testResult.passed ? "\u2705" : "\u274C";
493
+ const issue = testResult.passed ? "" : testResult.steps.find((s) => !s.passed)?.description || "Unknown";
494
+ lines.push(`| ${test.name} | ${status} | ${testResult.duration}ms | ${issue} |`);
495
+ });
496
+ lines.push("");
497
+ const failedResults = result.results.filter((r) => !r.passed);
498
+ if (failedResults.length > 0) {
499
+ lines.push("---");
500
+ lines.push("");
501
+ for (let i = 0;i < result.results.length; i++) {
502
+ if (!result.results[i].passed) {
503
+ lines.push(buildTestSummary(result.results[i], tests[i]));
504
+ lines.push("");
505
+ }
506
+ }
507
+ }
508
+ return lines.join(`
509
+ `);
510
+ }
511
+ function formatTestHuman(result, test) {
512
+ const lines = [];
513
+ const icon = result.passed ? "\u2713" : "\u2717";
514
+ const color = result.passed ? "\x1B[32m" : "\x1B[31m";
515
+ const reset = "\x1B[0m";
516
+ lines.push(`${color}${icon} ${test.name}${reset} (${result.duration}ms)`);
517
+ if (!result.passed) {
518
+ const failed = result.steps.filter((s) => !s.passed);
519
+ for (const step of failed) {
520
+ lines.push("");
521
+ lines.push(` ${color}Step ${step.index + 1}:${reset} ${step.description || "Unknown step"}`);
522
+ if (step.purpose) {
523
+ lines.push(` Tried to: ${step.purpose}`);
524
+ }
525
+ lines.push(` ${color}Error:${reset} ${step.context?.reason || step.error || "Unknown error"}`);
526
+ if (step.context?.buttonsOnPage?.length) {
527
+ lines.push(` Page shows: ${step.context.buttonsOnPage.join(", ")}`);
528
+ }
529
+ if (step.context?.actual !== undefined) {
530
+ lines.push(` Expected: ${step.context.expected}`);
531
+ lines.push(` Actual: ${step.context.actual}`);
532
+ }
533
+ if (step.context?.suggestion) {
534
+ lines.push(` ${color}Likely:${reset} ${step.context.suggestion}`);
535
+ }
536
+ }
537
+ }
538
+ lines.push("");
539
+ lines.push(` ${result.summary.passed}/${result.summary.total} steps passed`);
540
+ if (result.patience) {
541
+ const p = result.patience;
542
+ const pColor = p.remaining > 0 ? "\x1B[33m" : "\x1B[31m";
543
+ lines.push(` ${pColor}Patience:${reset} ${p.remaining}/${p.allowed} remaining, ${p.failures} failures, timeout ${p.finalTimeoutMs}ms`);
544
+ }
545
+ return lines.join(`
546
+ `);
547
+ }
548
+ function formatSuiteHuman(result, tests) {
549
+ const lines = [];
550
+ const icon = result.summary.failed === 0 ? "\u2713" : "\u2717";
551
+ const color = result.summary.failed === 0 ? "\x1B[32m" : "\x1B[31m";
552
+ const reset = "\x1B[0m";
553
+ lines.push(`${color}${icon} Test Suite${reset} (${result.duration}ms)`);
554
+ lines.push(` ${result.summary.passed}/${result.summary.total} tests passed`);
555
+ lines.push("");
556
+ result.results.forEach((testResult, i) => {
557
+ lines.push(formatTestHuman(testResult, tests[i]));
558
+ lines.push("");
559
+ });
560
+ return lines.join(`
561
+ `);
562
+ }
563
+ function escapeAnnotation(str) {
564
+ return str.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A").replace(/:/g, "%3A").replace(/,/g, "%2C");
565
+ }
566
+ function inferSuggestion(step, pageContext) {
567
+ const desc = step.description?.toLocaleLowerCase() || "";
568
+ const buttons = pageContext.buttonsOnPage || [];
569
+ const englishWords = ["submit", "cancel", "ok", "save", "delete", "confirm", "next", "back", "continue"];
570
+ const hasEnglishInDesc = englishWords.some((w) => desc.includes(w));
571
+ const hasNonEnglishButtons = buttons.some((b) => {
572
+ const lower = b.toLocaleLowerCase();
573
+ return !englishWords.includes(lower) && /[\u00E0\u00E1\u00E2\u00E3\u00E4\u00E5\u00E6\u00E7\u00E8\u00E9\u00EA\u00EB\u00EC\u00ED\u00EE\u00EF\u00F0\u00F1\u00F2\u00F3\u00F4\u00F5\u00F6\u00F8\u00F9\u00FA\u00FB\u00FC\u00FD\u00FE\u00FF]/.test(lower);
574
+ });
575
+ if (hasEnglishInDesc && hasNonEnglishButtons) {
576
+ return "Page may be in a different locale than expected";
577
+ }
578
+ if (buttons.length === 0) {
579
+ return "No interactive elements found - page may not have loaded or element is conditionally rendered";
580
+ }
581
+ const selectorText = step.description?.match(/["']([^"']+)["']/)?.[1]?.toLocaleLowerCase();
582
+ if (selectorText && buttons.length > 0) {
583
+ const similar = buttons.find((b) => {
584
+ const lower = b.toLocaleLowerCase();
585
+ return lower.includes(selectorText.slice(0, 3)) || selectorText.includes(lower.slice(0, 3));
586
+ });
587
+ if (similar) {
588
+ return `Button may have been renamed - found "${similar}" which looks similar`;
589
+ }
590
+ }
591
+ return;
592
+ }
593
+
594
+ // src/test.ts
595
+ class HaltijaTestError extends Error {
596
+ results;
597
+ summary;
598
+ constructor(message, results, summary) {
599
+ super(message);
600
+ this.name = "HaltijaTestError";
601
+ this.results = results;
602
+ this.summary = summary;
603
+ }
604
+ }
605
+ function substituteVars(text, vars = {}) {
606
+ return text.replace(/\$\{([^}]+)\}/g, (match, varName) => {
607
+ const trimmed = varName.trim();
608
+ if (trimmed.startsWith("GEN."))
609
+ return match;
610
+ if (trimmed in vars)
611
+ return vars[trimmed];
612
+ if (trimmed in process.env)
613
+ return process.env[trimmed];
614
+ return match;
615
+ });
616
+ }
617
+ function loadTestFile(filePath, vars) {
618
+ const content = readFileSync(filePath, "utf-8");
619
+ const processed = vars ? substituteVars(content, vars) : content;
620
+ const parsed = JSON.parse(processed);
621
+ if (Array.isArray(parsed.tests)) {
622
+ return parsed.tests;
623
+ }
624
+ return [parsed];
625
+ }
626
+ function expandTestDir(dir) {
627
+ const absDir = resolve(dir);
628
+ const stat = statSync(absDir);
629
+ if (!stat.isDirectory()) {
630
+ return [absDir];
631
+ }
632
+ return readdirSync(absDir).filter((f) => f.endsWith(".json")).sort().map((f) => join(absDir, f));
633
+ }
634
+
635
+ class HaltijaTestClient {
636
+ client;
637
+ baseUrl;
638
+ constructor(serverUrl = "http://localhost:8700") {
639
+ this.baseUrl = serverUrl;
640
+ this.client = new DevChannelClient(serverUrl);
641
+ }
642
+ async waitForServer(timeoutMs = 15000) {
643
+ const start = Date.now();
644
+ const pollInterval = 500;
645
+ while (Date.now() - start < timeoutMs) {
646
+ try {
647
+ const res = await fetch(`${this.baseUrl}/status`);
648
+ if (res.ok)
649
+ return;
650
+ } catch {}
651
+ await new Promise((r) => setTimeout(r, pollInterval));
652
+ }
653
+ throw new Error(`Haltija server not reachable at ${this.baseUrl} after ${timeoutMs}ms`);
654
+ }
655
+ async status() {
656
+ return this.client.status();
657
+ }
658
+ async windows() {
659
+ const res = await fetch(`${this.baseUrl}/windows`);
660
+ return res.json();
661
+ }
662
+ async query(selector) {
663
+ return this.client.query(selector);
664
+ }
665
+ async queryAll(selector) {
666
+ return this.client.queryAll(selector);
667
+ }
668
+ async click(selector, options) {
669
+ return this.client.click(selector, options);
670
+ }
671
+ async type(selector, text) {
672
+ return this.client.type(selector, text);
673
+ }
674
+ async press(key, modifiers) {
675
+ return this.client.press(key, modifiers);
676
+ }
677
+ async focus(selector) {
678
+ return this.client.focus(selector);
679
+ }
680
+ async blur(selector) {
681
+ return this.client.blur(selector);
682
+ }
683
+ async navigate(url) {
684
+ return this.client.navigate(url);
685
+ }
686
+ async refresh(hard = false) {
687
+ return this.client.refresh(hard);
688
+ }
689
+ async getLocation() {
690
+ return this.client.getLocation();
691
+ }
692
+ async eval(code) {
693
+ return this.client.eval(code);
694
+ }
695
+ async getConsole(since = 0) {
696
+ return this.client.getConsole(since);
697
+ }
698
+ async screenshot(options = {}) {
699
+ const res = await fetch(`${this.baseUrl}/screenshot`, {
700
+ method: "POST",
701
+ headers: { "Content-Type": "application/json" },
702
+ body: JSON.stringify({ file: true, ...options })
703
+ });
704
+ const response = await res.json();
705
+ if (!response.success) {
706
+ throw new Error(response.error || "Screenshot failed");
707
+ }
708
+ return response.data;
709
+ }
710
+ async tree(options = {}) {
711
+ const res = await fetch(`${this.baseUrl}/tree`, {
712
+ method: "POST",
713
+ headers: { "Content-Type": "application/json" },
714
+ body: JSON.stringify(options)
715
+ });
716
+ const response = await res.json();
717
+ if (!response.success) {
718
+ throw new Error(response.error || "Tree failed");
719
+ }
720
+ return response.data;
721
+ }
722
+ async runFile(filePath, options = {}) {
723
+ const tests = loadTestFile(resolve(filePath), options.vars);
724
+ const { vars, ...serverOptions } = options;
725
+ if (tests.length > 1) {
726
+ const res2 = await fetch(`${this.baseUrl}/test/suite`, {
727
+ method: "POST",
728
+ headers: { "Content-Type": "application/json" },
729
+ body: JSON.stringify({ tests, ...serverOptions })
730
+ });
731
+ const text2 = await res2.text();
732
+ let suiteResult;
733
+ try {
734
+ suiteResult = JSON.parse(text2);
735
+ } catch {
736
+ throw new Error(`Suite endpoint returned invalid JSON (status ${res2.status}): ${text2.slice(0, 200)}`);
737
+ }
738
+ const combined = {
739
+ test: tests[0]?.name || filePath,
740
+ passed: suiteResult.summary.failed === 0,
741
+ duration: suiteResult.duration,
742
+ steps: suiteResult.results.flatMap((r) => r.steps || []),
743
+ summary: {
744
+ total: suiteResult.summary.total,
745
+ executed: suiteResult.summary.executed ?? suiteResult.summary.total,
746
+ passed: suiteResult.summary.passed,
747
+ failed: suiteResult.summary.failed
748
+ }
749
+ };
750
+ if (!combined.passed) {
751
+ const formatted = formatSuiteHuman(suiteResult, tests);
752
+ throw new HaltijaTestError(`Suite file "${filePath}" failed: ${suiteResult.summary.failed}/${suiteResult.summary.total} tests failed
753
+
754
+ ${formatted}`, suiteResult, suiteResult.summary);
755
+ }
756
+ return combined;
757
+ }
758
+ const test = tests[0];
759
+ const res = await fetch(`${this.baseUrl}/test/run`, {
760
+ method: "POST",
761
+ headers: { "Content-Type": "application/json" },
762
+ body: JSON.stringify({ test, ...serverOptions })
763
+ });
764
+ const text = await res.text();
765
+ let result;
766
+ try {
767
+ result = JSON.parse(text);
768
+ } catch {
769
+ throw new Error(`Test run endpoint returned invalid JSON (status ${res.status}): ${text.slice(0, 200)}`);
770
+ }
771
+ if (!result.passed) {
772
+ const formatted = formatTestHuman(result, test);
773
+ throw new HaltijaTestError(`Test "${test.name}" failed: ${result.summary.failed}/${result.summary.total} steps failed
774
+
775
+ ${formatted}`, result, result.summary);
776
+ }
777
+ return result;
778
+ }
779
+ async suite(dir, options = {}) {
780
+ const files = expandTestDir(resolve(dir));
781
+ if (files.length === 0) {
782
+ throw new Error(`No .json test files found in ${dir}`);
783
+ }
784
+ const tests = files.flatMap((f) => loadTestFile(f, options.vars));
785
+ const { vars, ...serverOptions } = options;
786
+ const res = await fetch(`${this.baseUrl}/test/suite`, {
787
+ method: "POST",
788
+ headers: { "Content-Type": "application/json" },
789
+ body: JSON.stringify({ tests, ...serverOptions })
790
+ });
791
+ const text = await res.text();
792
+ let result;
793
+ try {
794
+ result = JSON.parse(text);
795
+ } catch {
796
+ throw new Error(`Suite endpoint returned invalid JSON (status ${res.status}): ${text.slice(0, 200)}`);
797
+ }
798
+ if (result.summary.failed > 0) {
799
+ const formatted = formatSuiteHuman(result, tests);
800
+ throw new HaltijaTestError(`Suite failed: ${result.summary.failed}/${result.summary.total} tests failed
801
+
802
+ ${formatted}`, result, result.summary);
803
+ }
804
+ return result;
805
+ }
806
+ async watchMutations(options) {
807
+ return this.client.watchMutations(options);
808
+ }
809
+ async unwatchMutations() {
810
+ return this.client.unwatchMutations();
811
+ }
812
+ async watchEvents(options = {}) {
813
+ const res = await fetch(`${this.baseUrl}/events/watch`, {
814
+ method: "POST",
815
+ headers: { "Content-Type": "application/json" },
816
+ body: JSON.stringify(options)
817
+ });
818
+ const response = await res.json();
819
+ if (!response.success) {
820
+ throw new Error(response.error || "Watch events failed");
821
+ }
822
+ }
823
+ async unwatchEvents() {
824
+ const res = await fetch(`${this.baseUrl}/events/unwatch`, {
825
+ method: "POST"
826
+ });
827
+ const response = await res.json();
828
+ if (!response.success) {
829
+ throw new Error(response.error || "Unwatch events failed");
830
+ }
831
+ }
832
+ async getEvents() {
833
+ const res = await fetch(`${this.baseUrl}/events`);
834
+ const response = await res.json();
835
+ return response.success ? response.data : [];
836
+ }
837
+ }
838
+ var hj = new HaltijaTestClient;
839
+ function createTestClient(serverUrl) {
840
+ return new HaltijaTestClient(serverUrl);
841
+ }
842
+ export {
843
+ hj,
844
+ createTestClient,
845
+ HaltijaTestError,
846
+ HaltijaTestClient
847
+ };