ravensight-playtest 0.2.8 → 0.3.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.
@@ -0,0 +1,353 @@
1
+ // The free capability probe for the native drivers. It asks the developer's own
2
+ // local W3C automation server the three questions a persona run depends on, in
3
+ // the order a run would hit them, and spends nothing: no job, no reservation, no
4
+ // model call. `check` prints it as a row and `run` refuses to register a native
5
+ // job when it fails.
6
+ import fs from 'node:fs/promises';
7
+ import path from 'node:path';
8
+ import { readDriverConfig } from './webdriver.js';
9
+
10
+ /**
11
+ * Capability keys that name the app a session opens, in the order they are
12
+ * reported. A path or URL (`app`) is the build; a bundle id or package names an
13
+ * app already installed on the device or simulator.
14
+ */
15
+ export const APP_KEYS = Object.freeze([
16
+ 'appium:app',
17
+ 'app',
18
+ 'appium:bundleId',
19
+ 'bundleId',
20
+ 'appium:appPackage',
21
+ 'appPackage'
22
+ ]);
23
+
24
+ const STATUS_TIMEOUT_MS = 10_000;
25
+ // The first session on a fresh simulator builds and installs the automation
26
+ // agent, which takes minutes, not seconds.
27
+ const SESSION_TIMEOUT_MS = 180_000;
28
+ const CLOSE_TIMEOUT_MS = 30_000;
29
+
30
+ /**
31
+ * The app the capabilities name, or null.
32
+ * @param {Object} capabilities
33
+ * @returns {{key: string, value: string, kind: 'path'|'url'|'id'}|null}
34
+ */
35
+ export function appReference(capabilities = {}) {
36
+ for (const key of APP_KEYS) {
37
+ const value = capabilities[key];
38
+ if (typeof value !== 'string' || !value.trim()) continue;
39
+ if (key === 'appium:app' || key === 'app') {
40
+ return { key, value, kind: /^[a-z][a-z0-9+.-]*:\/\//i.test(value) ? 'url' : 'path' };
41
+ }
42
+ return { key, value, kind: 'id' };
43
+ }
44
+ return null;
45
+ }
46
+
47
+ function message(error) {
48
+ if (!error) return 'unknown error';
49
+ if (error.name === 'TimeoutError') return 'timed out';
50
+ const cause = error.cause && (error.cause.code || error.cause.message);
51
+ return String(cause || error.message || error).split('\n')[0];
52
+ }
53
+
54
+ /** The same response cap the native driver applies. */
55
+ export const MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
56
+
57
+ /** Read a response body, refusing anything past the cap. */
58
+ async function boundedText(response) {
59
+ if (!response.body) return '';
60
+ const reader = response.body.getReader();
61
+ const chunks = [];
62
+ let size = 0;
63
+ try {
64
+ for (;;) {
65
+ const part = await reader.read();
66
+ if (part.done) break;
67
+ size += part.value.length;
68
+ if (size > MAX_RESPONSE_BYTES) {
69
+ await reader.cancel();
70
+ throw new Error('automation server response exceeded 8 MB');
71
+ }
72
+ chunks.push(Buffer.from(part.value));
73
+ }
74
+ } finally {
75
+ reader.releaseLock();
76
+ }
77
+ return Buffer.concat(chunks).toString('utf8');
78
+ }
79
+
80
+ /**
81
+ * One W3C request with a timeout. Answers `{ ok, status, value }` and never
82
+ * throws for an HTTP error, so the caller can say which step failed.
83
+ */
84
+ async function call(fetchImpl, method, url, body, timeoutMs) {
85
+ const response = await fetchImpl(url, {
86
+ method,
87
+ redirect: 'error',
88
+ signal: AbortSignal.timeout(timeoutMs),
89
+ headers: { 'Content-Type': 'application/json' },
90
+ ...(body ? { body: JSON.stringify(body) } : {})
91
+ });
92
+ let data = null;
93
+ if (response.status !== 204) {
94
+ const text = await boundedText(response);
95
+ try {
96
+ data = text ? JSON.parse(text) : null;
97
+ } catch {
98
+ data = null;
99
+ }
100
+ }
101
+ const value = data && typeof data === 'object' ? data.value : undefined;
102
+ const error = value && typeof value === 'object' && value.error
103
+ ? `${value.error}${value.message ? `: ${String(value.message).split('\n')[0]}` : ''}`
104
+ : null;
105
+ return { ok: response.ok && !error, status: response.status, value, error };
106
+ }
107
+
108
+ /**
109
+ * The session ids the server lists, or null when it lists none. `GET /sessions`
110
+ * is not in the W3C standard; some servers answer it and many do not, so every
111
+ * failure here is null, never an error.
112
+ */
113
+ async function listSessions(fetchImpl, endpoint, timeoutMs) {
114
+ try {
115
+ const answer = await call(fetchImpl, 'GET', `${endpoint}/sessions`, null, timeoutMs);
116
+ if (!answer.ok || !Array.isArray(answer.value)) return null;
117
+ return answer.value.map(entry => entry && (entry.id || entry.sessionId)).filter(id => typeof id === 'string');
118
+ } catch {
119
+ return null;
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Which platformName each native driver accepts. Desktop, Unity and Unreal
125
+ * builds are desktop apps, so a mobile platform there is a config mistake.
126
+ */
127
+ export function platformMismatch(driverName, platformName) {
128
+ const platform = String(platformName || '').toLowerCase();
129
+ if (driverName === 'ios_driver' && platform !== 'ios') return 'iOS';
130
+ if (driverName === 'android_driver' && platform !== 'android') return 'Android';
131
+ if (['desktop_driver', 'unity_driver', 'unreal_driver'].includes(driverName) && ['ios', 'android'].includes(platform)) {
132
+ return 'a desktop platform (for example mac or windows)';
133
+ }
134
+ return null;
135
+ }
136
+
137
+ /** The wording each driver family needs in a fix. */
138
+ export function platformWords(driverName) {
139
+ if (driverName === 'ios_driver') {
140
+ return {
141
+ family: 'ios',
142
+ build: 'build the app for the simulator first, then point appium:app at the .app it produced',
143
+ noApp: 'add appium:app with the path to the built .app, or appium:bundleId for an installed one',
144
+ relative: 'use an absolute path to the built .app so check can confirm it exists',
145
+ installed: 'the automation server row confirms it; if that row was skipped, install the build on the simulator first',
146
+ session: 'boot the simulator and check the capabilities: platformName, deviceName, platformVersion and automationName must match a device the server can drive',
147
+ app: 'point appium:app at the built .app, or appium:bundleId at an app installed on the simulator'
148
+ };
149
+ }
150
+ if (driverName === 'android_driver') {
151
+ return {
152
+ family: 'android',
153
+ build: 'build the .apk first, then point appium:app at it',
154
+ noApp: 'add appium:app with the path to the built .apk, or appium:appPackage for an installed one',
155
+ relative: 'use an absolute path to the .apk so check can confirm it exists',
156
+ installed: 'the automation server row confirms it; if that row was skipped, install the .apk on the device or emulator first',
157
+ session: 'start the emulator or connect the device and check the capabilities: platformName, deviceName and automationName must match a device the server can drive',
158
+ app: 'point appium:app at the built .apk, or appium:appPackage at an app installed on the device or emulator'
159
+ };
160
+ }
161
+ return {
162
+ family: 'desktop',
163
+ build: 'build the game first, then point the app capability at what it produced',
164
+ noApp: 'add the capability your automation server uses to name the build (for example appium:app)',
165
+ relative: 'use an absolute path to the build so check can confirm it exists',
166
+ installed: 'the automation server row confirms it',
167
+ session: 'the automation server could not open the build; check the capabilities your server needs for this platform',
168
+ app: 'the automation server could not open the build; check the app capability points at it'
169
+ };
170
+ }
171
+
172
+ /**
173
+ * Probe the automation server a native run would use.
174
+ *
175
+ * Steps, each only after the previous one passed:
176
+ * 1. read the driver config (loopback endpoint, W3C capabilities);
177
+ * 2. `GET /status`: the server is reachable;
178
+ * 3. `POST /session` with the run's own capabilities: a session can be created;
179
+ * 4. `GET /session/:id/source`: the app the capabilities name opened and
180
+ * answers, which is what "the app resolves" means to a persona;
181
+ * 5. `DELETE /session/:id`: the session is closed again.
182
+ *
183
+ * @param {Object} options
184
+ * @param {string} options.driverName
185
+ * @param {string|null} options.target the driver config file
186
+ * @param {typeof fetch} [options.fetch] test seam
187
+ * @param {{status?: number, session?: number, close?: number}} [options.timeouts]
188
+ * @param {(line: string) => void} [options.onProgress] told before the slow part starts
189
+ * @returns {Promise<{
190
+ * endpoint: string|null, reachable: boolean|null, session: boolean|null,
191
+ * closed: boolean|null, app: boolean|null, appRef: Object|null,
192
+ * failed: 'config'|'reachable'|'session'|'session_timeout'|'app'|null, detail: string, leaked: boolean
193
+ * }>}
194
+ */
195
+ export async function probeAutomation({ driverName, target, fetch: fetchImpl = globalThis.fetch, timeouts = {}, onProgress = () => {} } = {}) {
196
+ const result = { endpoint: null, reachable: null, session: null, closed: null, app: null, appRef: null, failed: null, detail: '', leaked: false };
197
+ const fail = (step, detail) => Object.assign(result, { failed: step, detail });
198
+
199
+ if (!target) {
200
+ return fail('config', `no driver config, so there is no automation server to probe for ${driverName}`);
201
+ }
202
+ let config;
203
+ try {
204
+ config = await readDriverConfig(target);
205
+ } catch (error) {
206
+ return fail('config', `driver config ${target}: ${message(error)}`);
207
+ }
208
+ result.endpoint = config.endpoint;
209
+ result.appRef = appReference(config.capabilities);
210
+ const wanted = platformMismatch(driverName, config.capabilities.platformName);
211
+ if (wanted) {
212
+ return fail('config', `${driverName} needs platformName ${wanted}, and the driver config says ${config.capabilities.platformName}`);
213
+ }
214
+ onProgress(`probing ${config.endpoint}, this can take up to 3 minutes on a fresh simulator`);
215
+
216
+ try {
217
+ const status = await call(fetchImpl, 'GET', `${config.endpoint}/status`, null, timeouts.status ?? STATUS_TIMEOUT_MS);
218
+ if (!status.ok) return Object.assign(fail('reachable', `${config.endpoint}/status answered ${status.error || `HTTP ${status.status}`}`), { reachable: false });
219
+ if (status.value && status.value.ready === false) {
220
+ return Object.assign(fail('reachable', `${config.endpoint} answered but says it is not ready${status.value.message ? `: ${status.value.message}` : ''}`), { reachable: false });
221
+ }
222
+ result.reachable = true;
223
+ } catch (error) {
224
+ result.reachable = false;
225
+ return fail('reachable', `nothing answered at ${config.endpoint} (${message(error)})`);
226
+ }
227
+
228
+ let sessionId = null;
229
+ // Taken only so a timed out create can find the session it may have left
230
+ // behind. Servers without `GET /sessions` answer null and nothing is closed.
231
+ const before = await listSessions(fetchImpl, config.endpoint, timeouts.status ?? STATUS_TIMEOUT_MS);
232
+ try {
233
+ const created = await call(
234
+ fetchImpl,
235
+ 'POST',
236
+ `${config.endpoint}/session`,
237
+ { capabilities: { alwaysMatch: config.capabilities, firstMatch: [{}] } },
238
+ timeouts.session ?? SESSION_TIMEOUT_MS
239
+ );
240
+ sessionId = created.ok && created.value && typeof created.value.sessionId === 'string' ? created.value.sessionId : null;
241
+ if (!sessionId) {
242
+ result.session = false;
243
+ return fail('session', `the server did not create a session: ${created.error || `HTTP ${created.status}`}`);
244
+ }
245
+ result.session = true;
246
+ } catch (error) {
247
+ result.session = false;
248
+ if (error && error.name === 'TimeoutError') {
249
+ // The server may finish creating the session after the abort. Close any
250
+ // session that appeared since the list above, best effort.
251
+ const after = before ? await listSessions(fetchImpl, config.endpoint, timeouts.status ?? STATUS_TIMEOUT_MS) : null;
252
+ const strays = after ? after.filter(id => !before.includes(id)) : [];
253
+ for (const id of strays) {
254
+ try {
255
+ await call(fetchImpl, 'DELETE', `${config.endpoint}/session/${encodeURIComponent(id)}`, null, timeouts.close ?? CLOSE_TIMEOUT_MS);
256
+ } catch {
257
+ // Best effort: the detail already says a session may still be open.
258
+ }
259
+ }
260
+ result.leaked = true;
261
+ return fail('session_timeout', `creating a session timed out; a session may still be open on the server; restart it or wait before retrying${strays.length ? ` (asked the server to close ${strays.length} session${strays.length === 1 ? '' : 's'} that appeared meanwhile)` : ''}`);
262
+ }
263
+ return fail('session', `creating a session failed (${message(error)})`);
264
+ }
265
+
266
+ const sessionUrl = `${config.endpoint}/session/${encodeURIComponent(sessionId)}`;
267
+ try {
268
+ if (!result.appRef) {
269
+ result.app = false;
270
+ fail('app', `the capabilities name no app (one of ${APP_KEYS.join(', ')}), so a session opens nothing for a persona to play`);
271
+ } else {
272
+ const source = await call(fetchImpl, 'GET', `${sessionUrl}/source`, null, timeouts.status ?? STATUS_TIMEOUT_MS);
273
+ result.app = source.ok;
274
+ if (!source.ok) fail('app', `${result.appRef.value} did not answer in the session: ${source.error || `HTTP ${source.status}`}`);
275
+ }
276
+ } catch (error) {
277
+ result.app = false;
278
+ fail('app', `reading the app in the session failed (${message(error)})`);
279
+ } finally {
280
+ try {
281
+ const closed = await call(fetchImpl, 'DELETE', sessionUrl, null, timeouts.close ?? CLOSE_TIMEOUT_MS);
282
+ result.closed = closed.ok;
283
+ } catch {
284
+ result.closed = false;
285
+ }
286
+ }
287
+ if (result.failed) return result;
288
+
289
+ result.detail = `${config.endpoint} reachable, session created and ${result.closed ? 'closed' : 'NOT closed'}, app ${result.appRef.value} opened`;
290
+ return result;
291
+ }
292
+
293
+ /**
294
+ * What the native build is, from the driver config alone. Nothing is launched.
295
+ *
296
+ * @param {Object} options
297
+ * @param {string|null} options.target
298
+ * @param {string} [options.repoRoot]
299
+ * @returns {Promise<{status: 'pass'|'warn'|'fail', detail: string, fix: string|null, ref: string|null}>}
300
+ */
301
+ export async function inspectNativeBuild({ target, repoRoot = process.cwd(), driverName = 'ios_driver' } = {}) {
302
+ const words = platformWords(driverName);
303
+ if (!target) {
304
+ return {
305
+ status: 'fail',
306
+ detail: 'no driver config: a native run has nothing to open',
307
+ fix: 'write a driver config (see docs/NATIVE_RUNNER.md) and pass --driver-config <file>, or set build to { "kind": "driver_config", "ref": "<file>" } in .ravensight/config.json',
308
+ ref: null
309
+ };
310
+ }
311
+ let config;
312
+ try {
313
+ config = await readDriverConfig(path.resolve(repoRoot, target));
314
+ } catch (error) {
315
+ return { status: 'fail', detail: `driver config ${target}: ${message(error)}`, fix: 'fix the driver config; see docs/NATIVE_RUNNER.md', ref: null };
316
+ }
317
+ const app = appReference(config.capabilities);
318
+ if (!app) {
319
+ return {
320
+ status: 'fail',
321
+ detail: `${target} names no app (one of ${APP_KEYS.join(', ')})`,
322
+ fix: words.noApp,
323
+ ref: null
324
+ };
325
+ }
326
+ if (app.kind === 'id') {
327
+ return {
328
+ status: 'warn',
329
+ detail: `${app.key} ${app.value}: an installed app, which only the automation server can confirm`,
330
+ fix: words.installed,
331
+ ref: app.value
332
+ };
333
+ }
334
+ if (app.kind === 'url' || !path.isAbsolute(app.value)) {
335
+ return {
336
+ status: 'warn',
337
+ detail: `${app.key} ${app.value}: resolved by the automation server, not checked on disk`,
338
+ fix: words.relative,
339
+ ref: app.value
340
+ };
341
+ }
342
+ try {
343
+ await fs.stat(app.value);
344
+ } catch {
345
+ return {
346
+ status: 'fail',
347
+ detail: `${app.key} ${app.value} does not exist`,
348
+ fix: words.build,
349
+ ref: app.value
350
+ };
351
+ }
352
+ return { status: 'pass', detail: `${app.key} ${app.value} (from ${target})`, fix: null, ref: app.value };
353
+ }
@@ -108,8 +108,8 @@ const LIST_ITEM = /^\s*(?:[-*+]|\d+[.)])\s+/;
108
108
  * @param {{unidentified?: number}} [opts] a count the CLI could not attribute
109
109
  * @returns {string}
110
110
  */
111
- export function rewriteMarkdown(markdown, dropped, kept, { unidentified = 0 } = {}) {
112
- const lines = withoutDroppedSection(String(markdown || '').replace(/\s+$/, '')).split('\n');
111
+ export function rewriteMarkdown(markdown, dropped, kept, { unidentified = 0, heading = DROPPED_HEADING, explanation = null, emptyNote = null } = {}) {
112
+ const lines = withoutDroppedSection(String(markdown || '').replace(/\s+$/, ''), heading).split('\n');
113
113
  const droppedIds = dropped.map(finding => String(finding.id)).filter(Boolean);
114
114
  const keptIds = kept.map(finding => String(finding.id)).filter(Boolean);
115
115
  const names = (line, id) => new RegExp(`(^|[^A-Za-z0-9])${escapeRegExp(id)}(?![0-9])`).test(line);
@@ -126,7 +126,7 @@ export function rewriteMarkdown(markdown, dropped, kept, { unidentified = 0 } =
126
126
  out.pop();
127
127
  blanks += 1;
128
128
  }
129
- out.push('- Nothing here that the server kept. See the dropped findings below.');
129
+ out.push(emptyNote || '- Nothing here that the server kept. See the dropped findings below.');
130
130
  for (let i = 0; i < blanks; i += 1) out.push('');
131
131
  }
132
132
  };
@@ -148,10 +148,10 @@ export function rewriteMarkdown(markdown, dropped, kept, { unidentified = 0 } =
148
148
  }
149
149
  closeSection();
150
150
 
151
- const tail = ['', DROPPED_HEADING];
151
+ const tail = ['', heading];
152
152
  if (dropped.length > 0) {
153
153
  tail.push(
154
- 'The server keeps a finding only when at least one of its evidence references points at a file this run uploaded. These did not, so the dashboard does not show them and they are not counted. They are kept here so you can judge them yourself.',
154
+ explanation || 'The server keeps a finding only when at least one of its evidence references points at a file this run uploaded. These did not, so the dashboard does not show them and they are not counted. They are kept here so you can judge them yourself.',
155
155
  ''
156
156
  );
157
157
  for (const finding of dropped) {
@@ -176,9 +176,9 @@ export function rewriteMarkdown(markdown, dropped, kept, { unidentified = 0 } =
176
176
  * @param {string} markdown
177
177
  * @returns {string}
178
178
  */
179
- function withoutDroppedSection(markdown) {
179
+ function withoutDroppedSection(markdown, heading = DROPPED_HEADING) {
180
180
  const lines = markdown.split('\n');
181
- const start = lines.findIndex(line => line.trim() === DROPPED_HEADING);
181
+ const start = lines.findIndex(line => line.trim() === heading);
182
182
  if (start === -1) return markdown;
183
183
  let end = lines.findIndex((line, index) => index > start && SECTION.test(line));
184
184
  if (end === -1) end = lines.length;
@@ -58,6 +58,11 @@ export function createHeartbeat({
58
58
  let canceled = false;
59
59
  let failures = 0;
60
60
  let inFlight = false;
61
+ // Set by stop(). A beat already on the wire when the run ends must not
62
+ // write its progress after the run's final state: in 0.2.8 one landed
63
+ // after the outcome and put a finished run back to `playing` in the
64
+ // journal.
65
+ let stopped = false;
61
66
  // The progress the loop last reported. The interval beat carries it too,
62
67
  // rather than resetting the server's and the journal's idea of the
63
68
  // checkpoint to zero every five seconds.
@@ -78,7 +83,7 @@ export function createHeartbeat({
78
83
  try {
79
84
  const answer = await api.runs.heartbeat(gameId, jobId, runId, { heartbeat: true, ...progress });
80
85
  failures = 0;
81
- if (onBeat) await onBeat({ ...progress });
86
+ if (onBeat && !stopped) await onBeat({ ...progress });
82
87
  if (answer && answer.run && answer.run.usage && onUsage) onUsage(answer.run.usage);
83
88
  if (answer && answer.cancel_requested && !canceled) {
84
89
  canceled = true;
@@ -111,6 +116,7 @@ export function createHeartbeat({
111
116
  },
112
117
 
113
118
  start() {
119
+ stopped = false;
114
120
  if (timer) return this;
115
121
  timer = setIntervalImpl(() => {
116
122
  beat().catch(() => {});
@@ -121,6 +127,7 @@ export function createHeartbeat({
121
127
  },
122
128
 
123
129
  stop() {
130
+ stopped = true;
124
131
  if (timer) clearIntervalImpl(timer);
125
132
  timer = null;
126
133
  return this;