@leera.io/qa-runner 1.0.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.
Files changed (110) hide show
  1. package/LICENSE +64 -0
  2. package/README.md +333 -0
  3. package/dist/src/appium/drivers.js +60 -0
  4. package/dist/src/appium/home.js +60 -0
  5. package/dist/src/appium/server.js +237 -0
  6. package/dist/src/builds/cache.js +133 -0
  7. package/dist/src/builds/cleanup.js +208 -0
  8. package/dist/src/builds/download.js +64 -0
  9. package/dist/src/builds/install.js +75 -0
  10. package/dist/src/builds/ios-app.js +99 -0
  11. package/dist/src/builds/unpack.js +347 -0
  12. package/dist/src/capabilities.js +93 -0
  13. package/dist/src/ci/builds.js +68 -0
  14. package/dist/src/ci/client.js +90 -0
  15. package/dist/src/ci/env.js +118 -0
  16. package/dist/src/ci/junit.js +79 -0
  17. package/dist/src/ci/run.js +320 -0
  18. package/dist/src/ci/summary.js +126 -0
  19. package/dist/src/cli/commands/builds.js +104 -0
  20. package/dist/src/cli/commands/ci.js +167 -0
  21. package/dist/src/cli/commands/config.js +83 -0
  22. package/dist/src/cli/commands/connect.js +70 -0
  23. package/dist/src/cli/commands/doctor.js +57 -0
  24. package/dist/src/cli/commands/service.js +165 -0
  25. package/dist/src/cli/commands/setup.js +193 -0
  26. package/dist/src/cli/commands/start.js +94 -0
  27. package/dist/src/cli/commands/update.js +189 -0
  28. package/dist/src/cli/commands/version.js +28 -0
  29. package/dist/src/cli/main.js +59 -0
  30. package/dist/src/cli/output.js +67 -0
  31. package/dist/src/cli.js +40 -0
  32. package/dist/src/client.js +146 -0
  33. package/dist/src/config.js +260 -0
  34. package/dist/src/debug.js +137 -0
  35. package/dist/src/devices/android/adb.js +251 -0
  36. package/dist/src/devices/android/avd.js +95 -0
  37. package/dist/src/devices/android/emulator.js +153 -0
  38. package/dist/src/devices/android/index.js +133 -0
  39. package/dist/src/devices/android/logcat.js +205 -0
  40. package/dist/src/devices/android/prepare.js +40 -0
  41. package/dist/src/devices/android/setup.js +162 -0
  42. package/dist/src/devices/android/system-dialog.js +78 -0
  43. package/dist/src/devices/desktop.js +145 -0
  44. package/dist/src/devices/ios/devicectl.js +87 -0
  45. package/dist/src/devices/ios/index.js +130 -0
  46. package/dist/src/devices/ios/prepare.js +39 -0
  47. package/dist/src/devices/ios/record.js +122 -0
  48. package/dist/src/devices/ios/settings.js +55 -0
  49. package/dist/src/devices/ios/setup.js +186 -0
  50. package/dist/src/devices/ios/simctl.js +201 -0
  51. package/dist/src/devices/ios/syslog.js +185 -0
  52. package/dist/src/devices/ios/wda.js +108 -0
  53. package/dist/src/devices/macos.js +248 -0
  54. package/dist/src/devices/manager.js +206 -0
  55. package/dist/src/devices/screen-record.js +95 -0
  56. package/dist/src/devices/tauri.js +189 -0
  57. package/dist/src/devices/types.js +6 -0
  58. package/dist/src/devices/windows.js +362 -0
  59. package/dist/src/doctor.js +394 -0
  60. package/dist/src/download.js +39 -0
  61. package/dist/src/drivers/android-appium.js +525 -0
  62. package/dist/src/drivers/common.js +64 -0
  63. package/dist/src/drivers/desktop-appium.js +282 -0
  64. package/dist/src/drivers/driver.js +15 -0
  65. package/dist/src/drivers/electron-playwright.js +566 -0
  66. package/dist/src/drivers/ios-appium.js +535 -0
  67. package/dist/src/drivers/mac2-appium.js +313 -0
  68. package/dist/src/drivers/registry.js +47 -0
  69. package/dist/src/drivers/snapshot/aria.js +351 -0
  70. package/dist/src/drivers/snapshot/format.js +106 -0
  71. package/dist/src/drivers/tauri-webdriver.js +678 -0
  72. package/dist/src/drivers/web-playwright.js +403 -0
  73. package/dist/src/drivers/webdriver/actions.js +328 -0
  74. package/dist/src/drivers/webdriver/desktop-keys.js +141 -0
  75. package/dist/src/drivers/webdriver/dom-locate.js +113 -0
  76. package/dist/src/drivers/webdriver/dom-snapshot.js +376 -0
  77. package/dist/src/drivers/webdriver/dom-tree.js +118 -0
  78. package/dist/src/drivers/webdriver/dom.js +213 -0
  79. package/dist/src/drivers/webdriver/ios-actions.js +299 -0
  80. package/dist/src/drivers/webdriver/ios-locate.js +121 -0
  81. package/dist/src/drivers/webdriver/locate.js +136 -0
  82. package/dist/src/drivers/webdriver/native-actions.js +175 -0
  83. package/dist/src/drivers/webdriver/native-locate.js +273 -0
  84. package/dist/src/drivers/webdriver/session.js +105 -0
  85. package/dist/src/drivers/webdriver/xml-tree-desktop.js +425 -0
  86. package/dist/src/drivers/webdriver/xml-tree-ios.js +314 -0
  87. package/dist/src/drivers/webdriver/xml-tree.js +393 -0
  88. package/dist/src/drivers/windows-appium.js +319 -0
  89. package/dist/src/errors.js +21 -0
  90. package/dist/src/executor.js +189 -0
  91. package/dist/src/home.js +76 -0
  92. package/dist/src/index.js +22 -0
  93. package/dist/src/log.js +69 -0
  94. package/dist/src/runner.js +498 -0
  95. package/dist/src/service/index.js +64 -0
  96. package/dist/src/service/launchd.js +85 -0
  97. package/dist/src/service/names.js +2 -0
  98. package/dist/src/service/schtasks.js +128 -0
  99. package/dist/src/service/systemd.js +65 -0
  100. package/dist/src/session/commands.js +165 -0
  101. package/dist/src/session/execute.js +232 -0
  102. package/dist/src/session/loop.js +148 -0
  103. package/dist/src/template.js +62 -0
  104. package/dist/src/types.js +7 -0
  105. package/dist/src/update/apply.js +223 -0
  106. package/dist/src/update/check.js +59 -0
  107. package/dist/src/update/manifest.js +93 -0
  108. package/dist/src/update/verify.js +65 -0
  109. package/dist/src/version.js +42 -0
  110. package/package.json +44 -0
@@ -0,0 +1,85 @@
1
+ import { join } from 'node:path';
2
+ import { SERVICE_LABEL } from './names.js';
3
+ function escapeXml(value) {
4
+ return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
5
+ }
6
+ /**
7
+ * A LaunchAgent, not a daemon: simulators, app windows, permissions and the signing
8
+ * keychain live in the user's GUI session.
9
+ */
10
+ export function launchdPlist(spec) {
11
+ const args = spec.argv.map((arg) => ` <string>${escapeXml(arg)}</string>`).join('\n');
12
+ const env = Object.entries(spec.env)
13
+ .map(([key, value]) => ` <key>${escapeXml(key)}</key>\n <string>${escapeXml(value)}</string>`)
14
+ .join('\n');
15
+ return `<?xml version="1.0" encoding="UTF-8"?>
16
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
17
+ <plist version="1.0">
18
+ <dict>
19
+ <key>Label</key>
20
+ <string>${SERVICE_LABEL}</string>
21
+ <key>ProgramArguments</key>
22
+ <array>
23
+ ${args}
24
+ </array>
25
+ <key>EnvironmentVariables</key>
26
+ <dict>
27
+ ${env}
28
+ </dict>
29
+ <key>WorkingDirectory</key>
30
+ <string>${escapeXml(spec.home)}</string>
31
+ <key>RunAtLoad</key>
32
+ <true/>
33
+ <key>KeepAlive</key>
34
+ <true/>
35
+ <key>ProcessType</key>
36
+ <string>Interactive</string>
37
+ <key>ThrottleInterval</key>
38
+ <integer>10</integer>
39
+ <key>StandardOutPath</key>
40
+ <string>${escapeXml(join(spec.logDir, 'service.out.log'))}</string>
41
+ <key>StandardErrorPath</key>
42
+ <string>${escapeXml(join(spec.logDir, 'service.err.log'))}</string>
43
+ </dict>
44
+ </plist>
45
+ `;
46
+ }
47
+ export function launchdBackend(uid) {
48
+ const domain = `gui/${uid}`;
49
+ const target = `${domain}/${SERVICE_LABEL}`;
50
+ const definitionPath = (spec) => join(spec.userHome, 'Library', 'LaunchAgents', `${SERVICE_LABEL}.plist`);
51
+ return {
52
+ kind: 'launchd',
53
+ definitionPath,
54
+ plan(action, spec) {
55
+ const plist = definitionPath(spec);
56
+ switch (action) {
57
+ case 'install':
58
+ return {
59
+ writes: [{ path: plist, contents: launchdPlist(spec) }],
60
+ commands: [
61
+ { argv: ['launchctl', 'bootout', target], ignoreFailure: true },
62
+ { argv: ['launchctl', 'bootstrap', domain, plist] },
63
+ ],
64
+ };
65
+ case 'uninstall':
66
+ return { commands: [{ argv: ['launchctl', 'bootout', target], ignoreFailure: true }], removes: [plist] };
67
+ case 'start':
68
+ return {
69
+ commands: [
70
+ // Loading an already loaded agent fails harmlessly; kickstart then starts it.
71
+ { argv: ['launchctl', 'bootstrap', domain, plist], ignoreFailure: true },
72
+ { argv: ['launchctl', 'kickstart', target] },
73
+ ],
74
+ };
75
+ case 'stop':
76
+ // KeepAlive would restart a killed process, so stopping unloads the agent.
77
+ return { commands: [{ argv: ['launchctl', 'bootout', target] }] };
78
+ case 'restart':
79
+ return { commands: [{ argv: ['launchctl', 'kickstart', '-k', target] }] };
80
+ case 'status':
81
+ return { commands: [{ argv: ['launchctl', 'print', target] }] };
82
+ }
83
+ },
84
+ };
85
+ }
@@ -0,0 +1,2 @@
1
+ export const SERVICE_LABEL = 'io.leera.qa-runner';
2
+ export const SERVICE_NAME = 'leera-qa-runner';
@@ -0,0 +1,128 @@
1
+ import { join } from 'node:path';
2
+ import { SERVICE_NAME } from './names.js';
3
+ function escapeXml(value) {
4
+ return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
5
+ }
6
+ /** Quotes one argument the way CommandLineToArgvW reads it back. */
7
+ export function windowsQuote(arg) {
8
+ if (arg !== '' && !/[\s"]/.test(arg))
9
+ return arg;
10
+ let out = '"';
11
+ let backslashes = 0;
12
+ for (const char of arg) {
13
+ if (char === '\\') {
14
+ backslashes++;
15
+ }
16
+ else if (char === '"') {
17
+ out += `${'\\'.repeat(backslashes * 2 + 1)}"`;
18
+ backslashes = 0;
19
+ }
20
+ else {
21
+ out += '\\'.repeat(backslashes) + char;
22
+ backslashes = 0;
23
+ }
24
+ }
25
+ return `${out}${'\\'.repeat(backslashes * 2)}"`;
26
+ }
27
+ /**
28
+ * A task that starts at logon in the user's interactive session with limited rights
29
+ * (the XML form of `schtasks /SC ONLOGON /IT /RL LIMITED`), restarts on failure and
30
+ * has no time limit — settings the plain `schtasks` flags cannot express.
31
+ */
32
+ export function scheduledTaskXml(spec, user) {
33
+ const [command, ...args] = spec.argv;
34
+ return `<?xml version="1.0" encoding="UTF-16"?>
35
+ <Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
36
+ <RegistrationInfo>
37
+ <Description>QA test runner</Description>
38
+ </RegistrationInfo>
39
+ <Triggers>
40
+ <LogonTrigger>
41
+ <Enabled>true</Enabled>
42
+ <UserId>${escapeXml(user)}</UserId>
43
+ </LogonTrigger>
44
+ </Triggers>
45
+ <Principals>
46
+ <Principal id="Author">
47
+ <UserId>${escapeXml(user)}</UserId>
48
+ <LogonType>InteractiveToken</LogonType>
49
+ <RunLevel>LeastPrivilege</RunLevel>
50
+ </Principal>
51
+ </Principals>
52
+ <Settings>
53
+ <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
54
+ <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
55
+ <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
56
+ <AllowHardTerminate>true</AllowHardTerminate>
57
+ <StartWhenAvailable>true</StartWhenAvailable>
58
+ <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
59
+ <IdleSettings>
60
+ <StopOnIdleEnd>false</StopOnIdleEnd>
61
+ <RestartOnIdle>false</RestartOnIdle>
62
+ </IdleSettings>
63
+ <AllowStartOnDemand>true</AllowStartOnDemand>
64
+ <Enabled>true</Enabled>
65
+ <Hidden>false</Hidden>
66
+ <RunOnlyIfIdle>false</RunOnlyIfIdle>
67
+ <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
68
+ <Priority>7</Priority>
69
+ <RestartOnFailure>
70
+ <Interval>PT1M</Interval>
71
+ <Count>999</Count>
72
+ </RestartOnFailure>
73
+ </Settings>
74
+ <Actions Context="Author">
75
+ <Exec>
76
+ <Command>${escapeXml(command ?? '')}</Command>
77
+ <Arguments>${escapeXml(args.map(windowsQuote).join(' '))}</Arguments>
78
+ <WorkingDirectory>${escapeXml(spec.home)}</WorkingDirectory>
79
+ </Exec>
80
+ </Actions>
81
+ </Task>
82
+ `;
83
+ }
84
+ /** schtasks reads task XML as UTF-16 with a byte order mark. */
85
+ export function utf16le(text) {
86
+ return Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from(text.replace(/\r?\n/g, '\r\n'), 'utf16le')]);
87
+ }
88
+ export function schtasksBackend(user) {
89
+ const definitionPath = (spec) => join(spec.home, 'service', `${SERVICE_NAME}.xml`);
90
+ return {
91
+ kind: 'schtasks',
92
+ definitionPath,
93
+ plan(action, spec) {
94
+ const xml = definitionPath(spec);
95
+ switch (action) {
96
+ case 'install':
97
+ return {
98
+ writes: [{ path: xml, contents: utf16le(scheduledTaskXml(spec, user)) }],
99
+ commands: [
100
+ { argv: ['schtasks', '/Create', '/TN', SERVICE_NAME, '/XML', xml, '/F'] },
101
+ { argv: ['schtasks', '/Run', '/TN', SERVICE_NAME] },
102
+ ],
103
+ };
104
+ case 'uninstall':
105
+ return {
106
+ commands: [
107
+ { argv: ['schtasks', '/End', '/TN', SERVICE_NAME], ignoreFailure: true },
108
+ { argv: ['schtasks', '/Delete', '/TN', SERVICE_NAME, '/F'] },
109
+ ],
110
+ removes: [xml],
111
+ };
112
+ case 'start':
113
+ return { commands: [{ argv: ['schtasks', '/Run', '/TN', SERVICE_NAME] }] };
114
+ case 'stop':
115
+ return { commands: [{ argv: ['schtasks', '/End', '/TN', SERVICE_NAME] }] };
116
+ case 'restart':
117
+ return {
118
+ commands: [
119
+ { argv: ['schtasks', '/End', '/TN', SERVICE_NAME], ignoreFailure: true },
120
+ { argv: ['schtasks', '/Run', '/TN', SERVICE_NAME] },
121
+ ],
122
+ };
123
+ case 'status':
124
+ return { commands: [{ argv: ['schtasks', '/Query', '/TN', SERVICE_NAME, '/V', '/FO', 'LIST'] }] };
125
+ }
126
+ },
127
+ };
128
+ }
@@ -0,0 +1,65 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
3
+ import { SERVICE_NAME } from './names.js';
4
+ const UNIT = `${SERVICE_NAME}.service`;
5
+ /** Quotes one word for a unit file: double quotes, with `\`, `"`, `%` and `$` escaped. */
6
+ export function systemdQuote(value) {
7
+ return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/%/g, '%%').replace(/\$/g, '$$$$')}"`;
8
+ }
9
+ export function systemdUnit(spec) {
10
+ const env = Object.entries(spec.env)
11
+ .map(([key, value]) => `Environment=${systemdQuote(`${key}=${value}`)}`)
12
+ .join('\n');
13
+ return `[Unit]
14
+ Description=QA test runner
15
+ After=network-online.target
16
+ Wants=network-online.target
17
+
18
+ [Service]
19
+ Type=simple
20
+ ExecStart=${spec.argv.map(systemdQuote).join(' ')}
21
+ WorkingDirectory=${systemdQuote(spec.home)}
22
+ ${env}
23
+ Restart=always
24
+ RestartSec=10
25
+ KillSignal=SIGTERM
26
+ TimeoutStopSec=60
27
+
28
+ [Install]
29
+ WantedBy=default.target
30
+ `;
31
+ }
32
+ export function systemdBackend(configHome = process.env.XDG_CONFIG_HOME?.trim() || join(homedir(), '.config')) {
33
+ const definitionPath = (_spec) => join(configHome, 'systemd', 'user', UNIT);
34
+ const ctl = (...args) => ['systemctl', '--user', ...args];
35
+ return {
36
+ kind: 'systemd',
37
+ definitionPath,
38
+ plan(action, spec) {
39
+ switch (action) {
40
+ case 'install':
41
+ return {
42
+ writes: [{ path: definitionPath(spec), contents: systemdUnit(spec) }],
43
+ commands: [
44
+ { argv: ctl('daemon-reload') },
45
+ { argv: ctl('enable', '--now', UNIT) },
46
+ // Keeps the user's services running while they are logged out; may need an administrator.
47
+ { argv: ['loginctl', 'enable-linger'], ignoreFailure: true },
48
+ ],
49
+ notes: ['If the runner should keep running after you log out and `loginctl enable-linger` failed, ask an administrator to run `loginctl enable-linger <user>`.'],
50
+ };
51
+ case 'uninstall':
52
+ return {
53
+ commands: [{ argv: ctl('disable', '--now', UNIT), ignoreFailure: true }, { argv: ctl('daemon-reload'), ignoreFailure: true }],
54
+ removes: [definitionPath(spec)],
55
+ };
56
+ case 'start':
57
+ case 'stop':
58
+ case 'restart':
59
+ return { commands: [{ argv: ctl(action, UNIT) }] };
60
+ case 'status':
61
+ return { commands: [{ argv: ctl('status', '--no-pager', UNIT) }] };
62
+ }
63
+ },
64
+ };
65
+ }
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Local validation of device-session commands (plan §4, §7.5; contracts §H3). The server
3
+ * validates too; the runner checks again so a malformed command becomes an error result
4
+ * instead of reaching a driver.
5
+ */
6
+ /** Commands that only exist in sessions; every other type must be a DSL action of the platform. */
7
+ export const SESSION_TYPES = ['snapshot', 'find', 'source', 'back', 'home', 'end', 'tap', 'type', 'swipe'];
8
+ /** Never executed, whatever the platform says (no shell, scripts or file access in sessions). */
9
+ const FORBIDDEN_TYPES = new Set(['execute_script', 'execute', 'shell', 'eval', 'upload', 'pull_file', 'push_file']);
10
+ export const DEFAULT_COMMAND_TIMEOUT_MS = 30_000;
11
+ export const WAIT_FOR_MAX_MS = 60_000;
12
+ export const MIN_TIMEOUT_MS = 100;
13
+ export const MAX_TIMEOUT_MS = 120_000;
14
+ /** Time a driver gets beyond the action timeout to report its own timeout before the loop gives up. */
15
+ export const TIMEOUT_GRACE_MS = 5_000;
16
+ const DIRECTIONS = ['up', 'down', 'left', 'right'];
17
+ const REF = /^[a-z][a-z0-9]{0,15}$/i;
18
+ export class CommandError extends Error {
19
+ code;
20
+ constructor(code, message) {
21
+ super(message);
22
+ this.code = code;
23
+ }
24
+ }
25
+ export function isSessionType(type) {
26
+ return SESSION_TYPES.includes(type);
27
+ }
28
+ /** Checks a command's shape; `actions` are the DSL action types the session's driver performs. */
29
+ export function validateCommand(raw, actions) {
30
+ try {
31
+ return { ok: true, command: check(raw, actions) };
32
+ }
33
+ catch (error) {
34
+ if (error instanceof CommandError)
35
+ return { ok: false, code: error.code, message: error.message };
36
+ throw error;
37
+ }
38
+ }
39
+ function check(raw, actions) {
40
+ if (!isObject(raw))
41
+ throw invalid('the command must be an object');
42
+ const command = raw;
43
+ if (typeof command.type !== 'string' || command.type.length === 0)
44
+ throw invalid('command.type is required');
45
+ const type = command.type;
46
+ if (FORBIDDEN_TYPES.has(type))
47
+ throw new CommandError('unsupported', `'${type}' is not allowed in a device session`);
48
+ if (command.timeout_ms !== undefined && !inRange(command.timeout_ms, MIN_TIMEOUT_MS, MAX_TIMEOUT_MS)) {
49
+ throw invalid(`command.timeout_ms must be between ${MIN_TIMEOUT_MS} and ${MAX_TIMEOUT_MS}`);
50
+ }
51
+ if (command.screenshot !== undefined && typeof command.screenshot !== 'boolean') {
52
+ throw invalid('command.screenshot must be true or false');
53
+ }
54
+ if (command.ref !== undefined && (typeof command.ref !== 'string' || !REF.test(command.ref))) {
55
+ throw invalid('command.ref must be a ref from a snapshot, such as "e4"');
56
+ }
57
+ if (command.target !== undefined)
58
+ checkTarget(command.target, 'command.target');
59
+ switch (type) {
60
+ case 'snapshot':
61
+ if (command.depth !== undefined && !inRange(command.depth, 1, 1_000))
62
+ throw invalid('command.depth must be a positive number');
63
+ if (command.include_hidden !== undefined && typeof command.include_hidden !== 'boolean') {
64
+ throw invalid('command.include_hidden must be true or false');
65
+ }
66
+ checkMaxChars(command);
67
+ return command;
68
+ case 'source':
69
+ checkMaxChars(command);
70
+ return command;
71
+ case 'find':
72
+ if (command.target === undefined)
73
+ throw invalid("'find' needs a target");
74
+ return command;
75
+ case 'back':
76
+ case 'home':
77
+ case 'end':
78
+ return command;
79
+ case 'tap': {
80
+ const given = [command.ref !== undefined, command.at !== undefined, command.target !== undefined].filter(Boolean).length;
81
+ if (given !== 1)
82
+ throw invalid("'tap' needs exactly one of ref, at or target");
83
+ if (command.at !== undefined) {
84
+ const at = command.at;
85
+ if (!isObject(at) || !inRange(at.x, 0, 100_000) || !inRange(at.y, 0, 100_000)) {
86
+ throw invalid("'tap' at needs non-negative x and y");
87
+ }
88
+ }
89
+ return command;
90
+ }
91
+ case 'type':
92
+ if ((command.ref === undefined) === (command.target === undefined))
93
+ throw invalid("'type' needs exactly one of ref or target");
94
+ if (typeof command.value !== 'string')
95
+ throw invalid("'type' needs a string value");
96
+ if (command.clear !== undefined && typeof command.clear !== 'boolean')
97
+ throw invalid('command.clear must be true or false');
98
+ return command;
99
+ case 'swipe':
100
+ checkSwipe(command);
101
+ return command;
102
+ default:
103
+ if (!actions.includes(type))
104
+ throw new CommandError('unsupported', `'${type}' is not an action this session's device supports`);
105
+ if (command.ref !== undefined && command.target !== undefined)
106
+ throw invalid(`'${type}' takes a ref or a target, not both`);
107
+ if (type === 'swipe' || type === 'scroll_to')
108
+ checkSwipe(command, type === 'scroll_to');
109
+ return command;
110
+ }
111
+ }
112
+ function checkSwipe(command, optionalDirection = false) {
113
+ if (command.ref !== undefined && command.target !== undefined)
114
+ throw invalid(`'${command.type}' takes a ref or a target, not both`);
115
+ if (command.direction === undefined && optionalDirection)
116
+ return;
117
+ if (!DIRECTIONS.includes(command.direction)) {
118
+ throw invalid(`'${command.type}' needs a direction: ${DIRECTIONS.join(', ')}`);
119
+ }
120
+ if (command.distance !== undefined && !inRange(command.distance, 0.1, 1))
121
+ throw invalid('command.distance must be between 0.1 and 1');
122
+ }
123
+ function checkMaxChars(command) {
124
+ if (command.max_chars !== undefined && !inRange(command.max_chars, 1, 1_000_000)) {
125
+ throw invalid('command.max_chars must be a positive number');
126
+ }
127
+ }
128
+ function checkTarget(target, at) {
129
+ if (!isObject(target))
130
+ throw invalid(`${at} must be an object`);
131
+ if ('locate' in target)
132
+ throw invalid(`${at}.locate is not allowed`);
133
+ if (target.fallbacks !== undefined) {
134
+ if (!Array.isArray(target.fallbacks) || target.fallbacks.length > 5)
135
+ throw invalid(`${at}.fallbacks must be a list of at most 5 targets`);
136
+ target.fallbacks.forEach((fallback, i) => {
137
+ if (isObject(fallback) && fallback.fallbacks !== undefined)
138
+ throw invalid(`${at}.fallbacks[${i}] cannot have fallbacks`);
139
+ checkTarget(fallback, `${at}.fallbacks[${i}]`);
140
+ });
141
+ }
142
+ }
143
+ /**
144
+ * The time a command's action may take: `timeout_ms` or the session default (30 s), at most
145
+ * the default for ordinary actions and at most 60 s for `wait_for`.
146
+ */
147
+ export function actionTimeoutMs(command, defaultMs = DEFAULT_COMMAND_TIMEOUT_MS) {
148
+ const requested = command.timeout_ms ?? defaultMs;
149
+ const cap = command.type === 'wait_for' ? Math.max(defaultMs, WAIT_FOR_MAX_MS) : defaultMs;
150
+ return Math.max(MIN_TIMEOUT_MS, Math.min(requested, cap));
151
+ }
152
+ /** The loop's hard limit for one command: the action timeout plus a grace for the driver to give up by itself. */
153
+ export function hardTimeoutMs(command, defaultMs = DEFAULT_COMMAND_TIMEOUT_MS, graceMs = TIMEOUT_GRACE_MS) {
154
+ const extra = command.type === 'wait' && typeof command.ms === 'number' ? Math.min(command.ms, 30_000) : 0;
155
+ return actionTimeoutMs(command, defaultMs) + extra + graceMs;
156
+ }
157
+ function invalid(message) {
158
+ return new CommandError('failed', message);
159
+ }
160
+ function isObject(value) {
161
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
162
+ }
163
+ function inRange(value, min, max) {
164
+ return typeof value === 'number' && Number.isFinite(value) && value >= min && value <= max;
165
+ }
@@ -0,0 +1,232 @@
1
+ import { Blocked, NotFound, Unsupported } from '../drivers/driver.js';
2
+ import { clampMaxChars, emptyScreen, toSnapshotResult } from '../drivers/snapshot/format.js';
3
+ import { TemplateError } from '../template.js';
4
+ import { actionTimeoutMs, CommandError, DEFAULT_COMMAND_TIMEOUT_MS, hardTimeoutMs, validateCommand, } from './commands.js';
5
+ export const MAX_LOG_LINES = 50;
6
+ export const MAX_LOG_CHARS = 500;
7
+ /** Command fields that are not part of the action a driver performs. */
8
+ const COMMAND_ONLY_FIELDS = new Set(['id', 'seq', 'ref', 'at', 'screenshot', 'depth', 'include_hidden', 'max_chars']);
9
+ class TimedOut extends Error {
10
+ }
11
+ /**
12
+ * Executes session commands on one driver. Refs resolve only against the last snapshot
13
+ * this executor produced; anything else is `stale_ref` ("take a new snapshot").
14
+ */
15
+ export class SessionExecutor {
16
+ driver;
17
+ commandTimeoutMs;
18
+ redact;
19
+ graceMs;
20
+ last = null;
21
+ constructor(driver, options = {}) {
22
+ this.driver = driver;
23
+ this.commandTimeoutMs = options.commandTimeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS;
24
+ this.redact = options.redact ?? ((text) => text);
25
+ this.graceMs = options.timeoutGraceMs;
26
+ }
27
+ get lastSnapshot() {
28
+ return this.last;
29
+ }
30
+ /** Takes a snapshot outside any command (after launch) so the first refs exist. */
31
+ async prime() {
32
+ this.last = await this.driver.snapshot({});
33
+ }
34
+ async execute(raw) {
35
+ const started = Date.now();
36
+ const result = {
37
+ ok: true,
38
+ error: null,
39
+ duration_ms: 0,
40
+ screen: emptyScreen(),
41
+ snapshot: null,
42
+ find: null,
43
+ target_used: null,
44
+ suggested_target: null,
45
+ logs: [],
46
+ };
47
+ let end = false;
48
+ const validation = validateCommand(raw, this.driver.capabilities.actions);
49
+ if (!validation.ok) {
50
+ result.ok = false;
51
+ result.error = { code: validation.code, message: validation.message };
52
+ }
53
+ else {
54
+ const command = validation.command;
55
+ end = command.type === 'end';
56
+ try {
57
+ await withTimeout(this.perform(command, result), hardTimeoutMs(command, this.commandTimeoutMs, this.graceMs));
58
+ }
59
+ catch (error) {
60
+ result.ok = false;
61
+ result.error = { code: classify(error), message: this.redact(firstLine(error)) };
62
+ }
63
+ }
64
+ const wantsScreenshot = raw?.screenshot !== false;
65
+ result.screen = await this.screen();
66
+ result.logs = this.logs();
67
+ const screenshot = wantsScreenshot ? await this.driver.screenshot().catch(() => null) : null;
68
+ result.duration_ms = Date.now() - started;
69
+ return { result, screenshot, end };
70
+ }
71
+ async perform(command, result) {
72
+ const actions = this.driver.capabilities.actions;
73
+ const timeout = actionTimeoutMs(command, this.commandTimeoutMs);
74
+ switch (command.type) {
75
+ case 'snapshot': {
76
+ const snapshot = await this.driver.snapshot({
77
+ ...(command.depth !== undefined ? { depth: command.depth } : {}),
78
+ ...(command.include_hidden !== undefined ? { includeHidden: command.include_hidden } : {}),
79
+ ...(command.max_chars !== undefined ? { maxChars: command.max_chars } : {}),
80
+ });
81
+ this.last = snapshot;
82
+ result.snapshot = toSnapshotResult({ ...snapshot, text: this.redact(snapshot.text) });
83
+ return;
84
+ }
85
+ case 'source': {
86
+ const source = this.redact(await this.driver.source());
87
+ const max = clampMaxChars(command.max_chars);
88
+ result.snapshot = { text: source.slice(0, max), refs: {}, element_count: 0, truncated: source.length > max };
89
+ return;
90
+ }
91
+ case 'find': {
92
+ const target = command.target;
93
+ const found = await this.driver.find(target);
94
+ const known = this.last?.refs ?? {};
95
+ result.find = { count: found.count, refs: (found.refs ?? []).filter((ref) => ref in known) };
96
+ result.target_used = target;
97
+ return;
98
+ }
99
+ case 'end':
100
+ return;
101
+ case 'back':
102
+ case 'home': {
103
+ const hook = command.type === 'back' ? this.driver.back : this.driver.home;
104
+ if (hook)
105
+ await hook.call(this.driver);
106
+ else if (actions.includes('press'))
107
+ await this.driver.run({ type: 'press', key: command.type, timeout_ms: timeout });
108
+ else
109
+ throw new Unsupported(`'${command.type}' is not available on ${this.driver.platform}`);
110
+ return;
111
+ }
112
+ case 'tap': {
113
+ if (command.at) {
114
+ if (!this.driver.tapAt)
115
+ throw new Unsupported(`tapping at coordinates is not available on ${this.driver.platform}`);
116
+ await this.driver.tapAt(command.at.x, command.at.y);
117
+ return;
118
+ }
119
+ const type = pick(actions, ['tap', 'click'], 'tap', this.driver.platform);
120
+ await this.runAction({ type, timeout_ms: timeout }, command, result);
121
+ return;
122
+ }
123
+ case 'type': {
124
+ const type = pick(actions, ['type', 'fill'], 'type', this.driver.platform);
125
+ const action = { type, value: command.value ?? '', timeout_ms: timeout };
126
+ if (type === 'type' && command.clear !== undefined)
127
+ action.clear = command.clear;
128
+ await this.runAction(action, command, result);
129
+ return;
130
+ }
131
+ case 'swipe': {
132
+ const type = pick(actions, ['swipe'], 'swipe', this.driver.platform);
133
+ const action = { type, timeout_ms: timeout };
134
+ if (command.direction)
135
+ action.direction = command.direction;
136
+ if (command.distance !== undefined)
137
+ action.distance = command.distance;
138
+ await this.runAction(action, command, result, true);
139
+ return;
140
+ }
141
+ default: {
142
+ const action = { type: command.type };
143
+ for (const [key, value] of Object.entries(command)) {
144
+ if (!COMMAND_ONLY_FIELDS.has(key))
145
+ action[key] = value;
146
+ }
147
+ action.timeout_ms = timeout;
148
+ await this.runAction(action, command, result, true);
149
+ }
150
+ }
151
+ }
152
+ /** Resolves `ref` or `target` into the action and runs it. */
153
+ async runAction(action, command, result, targetOptional = false) {
154
+ if (command.ref !== undefined) {
155
+ const ref = this.last?.refs[command.ref];
156
+ if (!ref) {
157
+ throw new CommandError('stale_ref', `ref ${command.ref} is not in the last snapshot; take a new snapshot`);
158
+ }
159
+ const suggest = ref.suggest;
160
+ action.target = { ...suggest, locate: ref.locate };
161
+ result.target_used = suggest;
162
+ result.suggested_target = suggest;
163
+ }
164
+ else if (command.target !== undefined) {
165
+ action.target = command.target;
166
+ result.target_used = command.target;
167
+ }
168
+ else if (!targetOptional) {
169
+ throw new CommandError('failed', `'${command.type}' needs a ref or a target`);
170
+ }
171
+ await this.driver.run(action);
172
+ }
173
+ async screen() {
174
+ if (!this.driver.screen)
175
+ return emptyScreen();
176
+ try {
177
+ return await this.driver.screen();
178
+ }
179
+ catch {
180
+ return emptyScreen();
181
+ }
182
+ }
183
+ logs() {
184
+ let lines;
185
+ try {
186
+ lines = this.driver.drainLogs?.() ?? [];
187
+ }
188
+ catch {
189
+ return [];
190
+ }
191
+ return lines.slice(-MAX_LOG_LINES).map((line) => this.redact(line).slice(0, MAX_LOG_CHARS));
192
+ }
193
+ }
194
+ function pick(actions, candidates, name, platform) {
195
+ const found = candidates.find((candidate) => actions.includes(candidate));
196
+ if (!found)
197
+ throw new Unsupported(`'${name}' is not available on ${platform}`);
198
+ return found;
199
+ }
200
+ /** Maps what a driver threw to the result's error code. */
201
+ export function classify(error) {
202
+ if (error instanceof CommandError)
203
+ return error.code;
204
+ if (error instanceof TimedOut)
205
+ return 'timeout';
206
+ if (error instanceof Unsupported)
207
+ return 'unsupported';
208
+ if (error instanceof NotFound)
209
+ return 'not_found';
210
+ const message = error instanceof Error ? error.message : String(error);
211
+ if (/\bTimeout \d+ms exceeded\b|timed out/i.test(message))
212
+ return 'timeout';
213
+ if (error instanceof Blocked || error instanceof TemplateError)
214
+ return 'failed';
215
+ return 'failed';
216
+ }
217
+ async function withTimeout(promise, ms) {
218
+ let timer;
219
+ const timeout = new Promise((_resolve, reject) => {
220
+ timer = setTimeout(() => reject(new TimedOut(`the command timed out after ${Math.round(ms / 1000)} s`)), ms);
221
+ });
222
+ try {
223
+ return await Promise.race([promise, timeout]);
224
+ }
225
+ finally {
226
+ clearTimeout(timer);
227
+ }
228
+ }
229
+ function firstLine(error) {
230
+ const message = error instanceof Error ? error.message : String(error);
231
+ return (message.split('\n')[0] ?? '').slice(0, 500);
232
+ }