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.
- package/LICENSE +21 -0
- package/README.md +380 -0
- package/addons/ravensight_driver/driver.gd +836 -0
- package/addons/ravensight_driver/export_plugin.gd +51 -0
- package/addons/ravensight_driver/plugin.cfg +7 -0
- package/addons/ravensight_driver/plugin.gd +36 -0
- package/bin/ravensight-playtest.js +31 -0
- package/package.json +45 -0
- package/src/api/README.md +500 -0
- package/src/api/client.js +340 -0
- package/src/api/errors.js +115 -0
- package/src/api/http.js +194 -0
- package/src/api/index.js +107 -0
- package/src/auth/deviceCode.js +79 -0
- package/src/auth/keychain.js +159 -0
- package/src/auth/session.js +128 -0
- package/src/cli.js +335 -0
- package/src/commands/brief.js +303 -0
- package/src/commands/check.js +318 -0
- package/src/commands/fakeCore.js +379 -0
- package/src/commands/init.js +120 -0
- package/src/commands/login.js +90 -0
- package/src/commands/logout.js +70 -0
- package/src/commands/open.js +125 -0
- package/src/commands/profile.js +262 -0
- package/src/commands/resume.js +156 -0
- package/src/commands/run.js +1015 -0
- package/src/commands/upload.js +137 -0
- package/src/config.js +100 -0
- package/src/dashboard.js +97 -0
- package/src/detect.js +77 -0
- package/src/errors.js +44 -0
- package/src/fsutil.js +77 -0
- package/src/godot.js +85 -0
- package/src/packs/index.js +191 -0
- package/src/paths.js +129 -0
- package/src/run/aggregate.js +658 -0
- package/src/run/args.js +111 -0
- package/src/run/context.js +181 -0
- package/src/run/deps.js +184 -0
- package/src/run/drivers/driver.js +183 -0
- package/src/run/drivers/godot-observation.js +138 -0
- package/src/run/drivers/godot-project.js +475 -0
- package/src/run/drivers/godot-rpc.js +225 -0
- package/src/run/drivers/godot.js +587 -0
- package/src/run/drivers/index.js +52 -0
- package/src/run/drivers/web.js +385 -0
- package/src/run/exit.js +21 -0
- package/src/run/heartbeat.js +131 -0
- package/src/run/index.js +31 -0
- package/src/run/json.js +56 -0
- package/src/run/model.js +384 -0
- package/src/run/paths.js +88 -0
- package/src/run/personaLoop.js +871 -0
- package/src/run/profile.js +214 -0
- package/src/run/regenerate.js +149 -0
- package/src/run/repoTools.js +286 -0
- package/src/run/report.js +222 -0
- package/src/run/resume.js +272 -0
- package/src/run/secretScan.js +171 -0
- package/src/run/state.js +198 -0
- package/src/run/synthetic.js +206 -0
- package/src/run/tools.js +344 -0
- package/src/run/transcript.js +93 -0
- package/src/run/usage.js +115 -0
- package/src/state/index.js +105 -0
- package/src/states.js +104 -0
- package/src/ui/index.js +195 -0
- package/src/upload/allowlist.js +116 -0
- package/src/upload/index.js +467 -0
- package/src/upload/queue.js +114 -0
- 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);
|
package/src/ui/index.js
ADDED
|
@@ -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
|
+
}
|