ravensight-playtest 0.1.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 (72) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +380 -0
  3. package/addons/ravensight_driver/driver.gd +836 -0
  4. package/addons/ravensight_driver/export_plugin.gd +51 -0
  5. package/addons/ravensight_driver/plugin.cfg +7 -0
  6. package/addons/ravensight_driver/plugin.gd +36 -0
  7. package/bin/ravensight-playtest.js +31 -0
  8. package/package.json +45 -0
  9. package/src/api/README.md +500 -0
  10. package/src/api/client.js +340 -0
  11. package/src/api/errors.js +115 -0
  12. package/src/api/http.js +194 -0
  13. package/src/api/index.js +107 -0
  14. package/src/auth/deviceCode.js +79 -0
  15. package/src/auth/keychain.js +159 -0
  16. package/src/auth/session.js +128 -0
  17. package/src/cli.js +335 -0
  18. package/src/commands/brief.js +303 -0
  19. package/src/commands/check.js +318 -0
  20. package/src/commands/fakeCore.js +379 -0
  21. package/src/commands/init.js +120 -0
  22. package/src/commands/login.js +90 -0
  23. package/src/commands/logout.js +70 -0
  24. package/src/commands/open.js +125 -0
  25. package/src/commands/profile.js +262 -0
  26. package/src/commands/resume.js +156 -0
  27. package/src/commands/run.js +1015 -0
  28. package/src/commands/upload.js +137 -0
  29. package/src/config.js +100 -0
  30. package/src/dashboard.js +97 -0
  31. package/src/detect.js +77 -0
  32. package/src/errors.js +44 -0
  33. package/src/fsutil.js +77 -0
  34. package/src/godot.js +85 -0
  35. package/src/packs/index.js +191 -0
  36. package/src/paths.js +129 -0
  37. package/src/run/aggregate.js +658 -0
  38. package/src/run/args.js +111 -0
  39. package/src/run/context.js +181 -0
  40. package/src/run/deps.js +184 -0
  41. package/src/run/drivers/driver.js +183 -0
  42. package/src/run/drivers/godot-observation.js +138 -0
  43. package/src/run/drivers/godot-project.js +475 -0
  44. package/src/run/drivers/godot-rpc.js +225 -0
  45. package/src/run/drivers/godot.js +587 -0
  46. package/src/run/drivers/index.js +52 -0
  47. package/src/run/drivers/web.js +385 -0
  48. package/src/run/exit.js +21 -0
  49. package/src/run/heartbeat.js +131 -0
  50. package/src/run/index.js +31 -0
  51. package/src/run/json.js +56 -0
  52. package/src/run/model.js +384 -0
  53. package/src/run/paths.js +88 -0
  54. package/src/run/personaLoop.js +871 -0
  55. package/src/run/profile.js +214 -0
  56. package/src/run/regenerate.js +149 -0
  57. package/src/run/repoTools.js +286 -0
  58. package/src/run/report.js +222 -0
  59. package/src/run/resume.js +272 -0
  60. package/src/run/secretScan.js +171 -0
  61. package/src/run/state.js +198 -0
  62. package/src/run/synthetic.js +206 -0
  63. package/src/run/tools.js +344 -0
  64. package/src/run/transcript.js +93 -0
  65. package/src/run/usage.js +115 -0
  66. package/src/state/index.js +105 -0
  67. package/src/states.js +104 -0
  68. package/src/ui/index.js +195 -0
  69. package/src/upload/allowlist.js +116 -0
  70. package/src/upload/index.js +467 -0
  71. package/src/upload/queue.js +114 -0
  72. package/src/version.js +63 -0
package/src/states.js ADDED
@@ -0,0 +1,104 @@
1
+ /**
2
+ * The job and run state machines, the module list and the driver list, copied
3
+ * from the server's `src/models/PlaytestJob.js` and `src/models/PlaytestRun.js`.
4
+ *
5
+ * The server is the authority and refuses a state it does not know, so these
6
+ * lists exist so the CLI can refuse locally (and so the runner can tell a
7
+ * terminal run from a resumable one without a round trip). If the two ever
8
+ * disagree, the server wins and this file is the one to fix.
9
+ *
10
+ * The transition table is data on both sides: the server does not enforce it
11
+ * either, it conditionally writes the state the caller read. It is here so a
12
+ * runner can avoid asking for a transition it knows is nonsense.
13
+ */
14
+
15
+ export const MODULES = Object.freeze([
16
+ 'game_profile',
17
+ 'persona_playtest',
18
+ 'aggregate_report',
19
+ 'logic_twin',
20
+ 'balance_sim',
21
+ 'level_inspect'
22
+ ]);
23
+
24
+ export const DRIVERS = Object.freeze([
25
+ 'playwright_web',
26
+ 'godot_driver',
27
+ 'cli_stdio',
28
+ 'unity_driver',
29
+ 'unreal_driver'
30
+ ]);
31
+
32
+ export const JOB_STATES = Object.freeze([
33
+ 'registered',
34
+ 'running',
35
+ 'aggregating',
36
+ 'uploading',
37
+ 'succeeded',
38
+ 'failed',
39
+ 'canceled',
40
+ 'budget_exceeded',
41
+ 'interrupted',
42
+ 'stalled'
43
+ ]);
44
+
45
+ /**
46
+ * A job only ever reaches one of these through `/finish`, `/cancel` or
47
+ * `DELETE`: `PATCH /jobs/:jobId` refuses them with 409, because the terminal
48
+ * state and the refund are one decision and only the server's settlement writes
49
+ * either.
50
+ */
51
+ export const JOB_TERMINAL_STATES = Object.freeze([
52
+ 'succeeded',
53
+ 'failed',
54
+ 'canceled',
55
+ 'budget_exceeded',
56
+ 'stalled'
57
+ ]);
58
+
59
+ export const RUN_STATES = Object.freeze([
60
+ 'queued',
61
+ 'launching',
62
+ 'playing',
63
+ 'reporting',
64
+ 'uploading',
65
+ 'succeeded',
66
+ 'failed',
67
+ 'canceled',
68
+ 'budget_exceeded',
69
+ 'skipped',
70
+ 'interrupted'
71
+ ]);
72
+
73
+ export const RUN_TERMINAL_STATES = Object.freeze([
74
+ 'succeeded',
75
+ 'failed',
76
+ 'canceled',
77
+ 'budget_exceeded',
78
+ 'skipped'
79
+ ]);
80
+
81
+ /**
82
+ * `interrupted` is deliberately not terminal: it is what the server's
83
+ * housekeeping sweep marks a run whose heartbeat stopped, and the whole point
84
+ * of the state is that `resume` can take it back to `launching`.
85
+ */
86
+ export const RUN_TRANSITIONS = Object.freeze({
87
+ queued: ['launching', 'canceled', 'skipped', 'failed', 'interrupted'],
88
+ launching: ['playing', 'failed', 'canceled', 'interrupted'],
89
+ playing: ['reporting', 'failed', 'canceled', 'budget_exceeded', 'interrupted'],
90
+ reporting: ['uploading', 'failed', 'interrupted'],
91
+ uploading: ['succeeded', 'failed', 'interrupted'],
92
+ interrupted: ['launching', 'playing', 'reporting', 'uploading', 'failed', 'canceled'],
93
+ succeeded: [],
94
+ failed: [],
95
+ canceled: [],
96
+ budget_exceeded: [],
97
+ skipped: []
98
+ });
99
+
100
+ export const isRunTerminal = state => RUN_TERMINAL_STATES.includes(state);
101
+ export const isJobTerminal = state => JOB_TERMINAL_STATES.includes(state);
102
+
103
+ /** Whether the server's transition table allows this move. */
104
+ export const canTransitionRun = (from, to) => (RUN_TRANSITIONS[from] || []).includes(to);
@@ -0,0 +1,195 @@
1
+ import { createInterface } from 'node:readline/promises';
2
+ import { stableStringify } from '../fsutil.js';
3
+
4
+ /**
5
+ * Terminal output, in one place.
6
+ *
7
+ * Two rules the rest of the CLI relies on. Nothing here writes to stdout in
8
+ * quiet mode except `ui.json`, so `--json` output is parseable by a CI step
9
+ * that pipes it. And nothing here uses an em dash or an en dash, because this
10
+ * is every string a customer reads.
11
+ */
12
+
13
+ let quiet = false;
14
+ let stream = process.stdout;
15
+ let errStream = process.stderr;
16
+
17
+ export const SYMBOLS = Object.freeze({ ok: 'ok', warn: 'warn', fail: 'fail', info: '..' });
18
+
19
+ export function setQuiet(value) {
20
+ quiet = Boolean(value);
21
+ }
22
+
23
+ export function isQuiet() {
24
+ return quiet || process.env.RAVENSIGHT_JSON === '1';
25
+ }
26
+
27
+ /** Test seam: capture output without spawning a process. */
28
+ export function setStreams({ out, err } = {}) {
29
+ stream = out || process.stdout;
30
+ errStream = err || process.stderr;
31
+ }
32
+
33
+ function write(text) {
34
+ if (isQuiet()) return;
35
+ stream.write(`${text}\n`);
36
+ }
37
+
38
+ export function info(text) {
39
+ write(text);
40
+ }
41
+
42
+ export function ok(text) {
43
+ write(`${SYMBOLS.ok} ${text}`);
44
+ }
45
+
46
+ export function warn(text) {
47
+ write(`${SYMBOLS.warn} ${text}`);
48
+ }
49
+
50
+ /** Errors go to stderr and are printed even in quiet mode: a silent failure is a bug. */
51
+ export function error(text) {
52
+ errStream.write(`${SYMBOLS.fail} ${text}\n`);
53
+ }
54
+
55
+ export function blank() {
56
+ write('');
57
+ }
58
+
59
+ /** Machine readable output. The one thing quiet mode does not suppress. */
60
+ export function json(value) {
61
+ stream.write(`${JSON.stringify(value, null, 2)}\n`);
62
+ }
63
+
64
+ /** Stable JSON, for anything that gets hashed or compared between runs. */
65
+ export const stable = stableStringify;
66
+
67
+ /**
68
+ * An aligned table with no borders, which is what fits in a terminal that may
69
+ * be 80 columns wide.
70
+ * @param {Array<Object>} rows
71
+ * @param {Array<{key: string, label: string, align?: 'left'|'right'}>} columns
72
+ */
73
+ export function table(rows, columns) {
74
+ if (isQuiet()) return;
75
+ const cells = rows.map(row => columns.map(column => {
76
+ const value = row[column.key];
77
+ return value === undefined || value === null ? '' : String(value);
78
+ }));
79
+ const widths = columns.map((column, index) => Math.max(
80
+ column.label.length,
81
+ ...cells.map(row => row[index].length),
82
+ 0
83
+ ));
84
+ const pad = (text, width, align) => (align === 'right' ? text.padStart(width) : text.padEnd(width));
85
+
86
+ write(columns.map((column, i) => pad(column.label, widths[i], column.align)).join(' '));
87
+ write(widths.map(width => '-'.repeat(width)).join(' '));
88
+ for (const row of cells) {
89
+ write(row.map((text, i) => pad(text, widths[i], columns[i].align)).join(' '));
90
+ }
91
+ }
92
+
93
+ /**
94
+ * A spinner that degrades honestly. On a TTY it rewrites one line; anywhere
95
+ * else (a CI log, a pipe) it prints one line per update, because carriage
96
+ * returns in a log file are noise and a progress animation nobody sees is
97
+ * worse than a timestamped line someone can read later.
98
+ * @param {string} label
99
+ */
100
+ export function spinner(label) {
101
+ const frames = ['.', '..', '...'];
102
+ const interactive = Boolean(stream.isTTY) && !isQuiet();
103
+ let text = label;
104
+ let frame = 0;
105
+ let timer = null;
106
+
107
+ const paint = () => {
108
+ frame = (frame + 1) % frames.length;
109
+ stream.write(`\r${text} ${frames[frame]} `);
110
+ };
111
+
112
+ if (interactive) {
113
+ timer = setInterval(paint, 300);
114
+ if (typeof timer.unref === 'function') timer.unref();
115
+ paint();
116
+ } else {
117
+ write(text);
118
+ }
119
+
120
+ return {
121
+ update(next) {
122
+ text = next;
123
+ if (!interactive) write(next);
124
+ },
125
+ stop(final) {
126
+ if (timer) clearInterval(timer);
127
+ if (interactive) stream.write('\r'.padEnd(text.length + 8, ' '));
128
+ if (interactive) stream.write('\r');
129
+ if (final) write(final);
130
+ }
131
+ };
132
+ }
133
+
134
+ /**
135
+ * Ask a yes or no question.
136
+ *
137
+ * `--yes` skips it, and so does a non interactive stdin: a prompt nobody can
138
+ * answer would hang a CI job forever, so the absence of a TTY is treated as a
139
+ * refusal rather than as an assumed yes. That is the safe direction for a
140
+ * question that is usually "shall I spend your money".
141
+ *
142
+ * @param {string} question
143
+ * @param {{yes?: boolean, input?: object, output?: object}} [options]
144
+ * @returns {Promise<boolean>}
145
+ */
146
+ export async function confirm(question, options = {}) {
147
+ if (options.yes) return true;
148
+ const input = options.input || process.stdin;
149
+ if (!input.isTTY && !options.input) return false;
150
+
151
+ const rl = createInterface({ input, output: options.output || stream });
152
+ try {
153
+ const answer = await rl.question(`${question} [y/N] `);
154
+ return /^y(es)?$/i.test(answer.trim());
155
+ } finally {
156
+ rl.close();
157
+ }
158
+ }
159
+
160
+ /** Cents as dollars, the one place that formatting exists. */
161
+ export function money(cents) {
162
+ if (cents === null || cents === undefined) return 'unknown';
163
+ return `$${(cents / 100).toFixed(2)}`;
164
+ }
165
+
166
+ /** Bytes as a short human string. */
167
+ export function bytes(value) {
168
+ const units = ['B', 'KB', 'MB', 'GB'];
169
+ let n = Number(value) || 0;
170
+ let unit = 0;
171
+ while (n >= 1024 && unit < units.length - 1) {
172
+ n /= 1024;
173
+ unit += 1;
174
+ }
175
+ return `${unit === 0 ? n : n.toFixed(1)} ${units[unit]}`;
176
+ }
177
+
178
+ export const ui = {
179
+ SYMBOLS,
180
+ setQuiet,
181
+ isQuiet,
182
+ setStreams,
183
+ info,
184
+ ok,
185
+ warn,
186
+ error,
187
+ blank,
188
+ json,
189
+ stable,
190
+ table,
191
+ spinner,
192
+ confirm,
193
+ money,
194
+ bytes
195
+ };
@@ -0,0 +1,116 @@
1
+ /**
2
+ * The upload path allowlist, compiled into the CLI.
3
+ *
4
+ * This is a copy of the server's `src/playtest/limits.js` ALLOWED_PATHS,
5
+ * deliberately. The server is the authority and refuses anything it does not
6
+ * recognize, so this copy exists only so the CLI can say "that file is not
7
+ * uploadable" locally instead of sending a request that comes back 400. The
8
+ * two must agree; if they ever disagree, the server wins and this file is the
9
+ * one to fix.
10
+ *
11
+ * Source code is not on the list, at any path. A playtest uploads reports,
12
+ * screenshots and, opted in, a video and a transcript. Nothing else.
13
+ */
14
+
15
+ /**
16
+ * Exported so `allowlist.pinned.test.js` can compare this list entry by entry
17
+ * with the server's own, which is what makes the paragraph above a checked
18
+ * claim rather than an aspiration.
19
+ */
20
+ export const ALLOWED_PATHS = [
21
+ { pattern: /^report\.md$/, contentType: 'text/markdown' },
22
+ { pattern: /^report\.json$/, contentType: 'application/json' },
23
+ { pattern: /^aggregate-report\.md$/, contentType: 'text/markdown' },
24
+ { pattern: /^aggregate-report\.json$/, contentType: 'application/json' },
25
+ { pattern: /^capability-report\.json$/, contentType: 'application/json' },
26
+ { pattern: /^usage\.json$/, contentType: 'application/json' },
27
+ { pattern: /^job\.json$/, contentType: 'application/json' },
28
+ { pattern: /^screenshots\/.+\.png$/, contentType: 'image/png' },
29
+ { pattern: /^screenshots\/.+\.jpe?g$/, contentType: 'image/jpeg' },
30
+ { pattern: /^screenshots\/.+\.webp$/, contentType: 'image/webp' },
31
+ { pattern: /^changeset.*\.json$/, contentType: 'application/json' },
32
+ { pattern: /^job-review\.json$/, contentType: 'application/json' },
33
+ { pattern: /^expectations-brief\.json$/, contentType: 'application/json' },
34
+ { pattern: /^next-action-plan\.json$/, contentType: 'application/json' },
35
+ { pattern: /^session\.webm$/, contentType: 'video/webm', optIn: 'video' },
36
+ { pattern: /^session\.mp4$/, contentType: 'video/mp4', optIn: 'video' },
37
+ { pattern: /^transcript\.jsonl$/, contentType: 'application/x-ndjson', optIn: 'transcript' }
38
+ ];
39
+
40
+ export const TRANSCRIPT_PATH = 'transcript.jsonl';
41
+ export const VIDEO_PATHS = Object.freeze(['session.webm', 'session.mp4']);
42
+
43
+ /**
44
+ * The three paths whose objects carry the `retention=90d` S3 tag, from W8's
45
+ * `RETENTION_TAGGED_PATHS`. The CLI never computes the tag value itself: the
46
+ * presign response hands back the exact string that was signed. This list is
47
+ * only used to explain, in `--dry-upload` output, which files expire.
48
+ */
49
+ export const RETENTION_TAGGED_PATHS = Object.freeze([...VIDEO_PATHS, TRANSCRIPT_PATH]);
50
+
51
+ /**
52
+ * The only three artifacts that belong to a job rather than to one of its runs,
53
+ * and so get keys with no `runs/<runId>` segment.
54
+ *
55
+ * The server's allowlist is one list for both levels, so it would happily
56
+ * presign `report.json` at the job root. Nothing writes one there, but the job
57
+ * directory also holds `state.json` and `upload-queue.jsonl`, and a future
58
+ * artifact dropped beside them would start being uploaded under a job key by
59
+ * accident. Naming the three keeps the job level upload deliberate.
60
+ */
61
+ export const JOB_UPLOAD_PATHS = Object.freeze([
62
+ 'capability-report.json',
63
+ 'aggregate-report.md',
64
+ 'aggregate-report.json'
65
+ ]);
66
+
67
+ export const MAX_FILES_PER_RUN = 500;
68
+ export const MAX_RUN_BYTES = 500 * 1024 * 1024;
69
+ export const MAX_VIDEO_BYTES = 200 * 1024 * 1024;
70
+
71
+ /**
72
+ * The content type for a run relative path, or null when the path is not
73
+ * uploadable at all.
74
+ *
75
+ * `optIn` entries answer null unless the matching flag is set, which is what
76
+ * makes video and transcript opt in rather than "uploaded unless you noticed".
77
+ *
78
+ * @param {string} relPath - forward slashes, relative to the run directory
79
+ * @param {{video?: boolean, transcript?: boolean}} [opts]
80
+ * @returns {string|null}
81
+ */
82
+ export function contentTypeFor(relPath, opts = {}) {
83
+ for (const entry of ALLOWED_PATHS) {
84
+ if (!entry.pattern.test(relPath)) continue;
85
+ if (entry.optIn && !opts[entry.optIn]) return null;
86
+ return entry.contentType;
87
+ }
88
+ return null;
89
+ }
90
+
91
+ /**
92
+ * Whether a path is on the list at all, ignoring the opt in flags. Used to tell
93
+ * "you did not ask for the video" apart from "that file has no business here",
94
+ * which are different messages.
95
+ * @param {string} relPath
96
+ * @returns {boolean}
97
+ */
98
+ export function isAllowedPath(relPath) {
99
+ return ALLOWED_PATHS.some(entry => entry.pattern.test(relPath));
100
+ }
101
+
102
+ /**
103
+ * Why a path is being skipped, in words a developer can act on.
104
+ * @param {string} relPath
105
+ * @param {{video?: boolean, transcript?: boolean}} opts
106
+ * @returns {string}
107
+ */
108
+ export function skipReason(relPath, opts = {}) {
109
+ if (!isAllowedPath(relPath)) return 'not an uploadable path';
110
+ for (const entry of ALLOWED_PATHS) {
111
+ if (entry.pattern.test(relPath) && entry.optIn && !opts[entry.optIn]) {
112
+ return `${entry.optIn} upload not enabled`;
113
+ }
114
+ }
115
+ return 'skipped';
116
+ }