@hautajoki/hexagon 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 +138 -0
- package/package.json +45 -0
- package/runtime/cloud_runner.luau +139 -0
- package/runtime/encode_json.luau +57 -0
- package/runtime/expect.luau +125 -0
- package/runtime/hexagon.luau +569 -0
- package/runtime/local_runner.luau +51 -0
- package/runtime/quote.luau +20 -0
- package/src/bundle.ts +128 -0
- package/src/cli.ts +441 -0
- package/src/discovery.ts +32 -0
- package/src/protocol.ts +189 -0
- package/src/reporter.ts +105 -0
- package/types.d.ts +51 -0
package/src/bundle.ts
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { mkdir, readdir, readFile, rm, rmdir, stat, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname, join, resolve } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
|
|
5
|
+
const BUNDLE_VERSION = 1;
|
|
6
|
+
const CLOUD_BUNDLE_FILES = ['cloud_runner.luau', 'expect.luau', 'hexagon.luau', 'quote.luau'] as const;
|
|
7
|
+
const LOCAL_BUNDLE_FILES = ['encode_json.luau', 'expect.luau', 'hexagon.luau', 'local_runner.luau', 'quote.luau'] as const;
|
|
8
|
+
const MARKER = '.hexagon-bundle.json';
|
|
9
|
+
|
|
10
|
+
interface Marker {
|
|
11
|
+
readonly format: 1;
|
|
12
|
+
readonly package: string;
|
|
13
|
+
readonly version: string;
|
|
14
|
+
readonly files: readonly string[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface PackageIdentity {
|
|
18
|
+
readonly name: string;
|
|
19
|
+
readonly version: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface RuntimeBundle {
|
|
23
|
+
readonly directory: string;
|
|
24
|
+
readonly cloudRunner: string;
|
|
25
|
+
readonly localRunner: string;
|
|
26
|
+
readonly cleanup: () => Promise<void>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function exists(path: string): Promise<boolean> {
|
|
30
|
+
try {
|
|
31
|
+
await stat(path);
|
|
32
|
+
return true;
|
|
33
|
+
} catch (error) {
|
|
34
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false;
|
|
35
|
+
throw error;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function packageIdentity(): Promise<PackageIdentity> {
|
|
40
|
+
const path = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
|
|
41
|
+
const value = JSON.parse(await readFile(path, 'utf8')) as Partial<PackageIdentity>;
|
|
42
|
+
if (typeof value.name !== 'string' || typeof value.version !== 'string') {
|
|
43
|
+
throw new Error(`Hexagon package metadata is invalid at ${path}`);
|
|
44
|
+
}
|
|
45
|
+
return { name: value.name, version: value.version };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function readMarker(directory: string, identity: PackageIdentity): Promise<Marker | undefined> {
|
|
49
|
+
const path = join(directory, MARKER);
|
|
50
|
+
if (!(await exists(path))) return undefined;
|
|
51
|
+
const value = JSON.parse(await readFile(path, 'utf8')) as Partial<Marker>;
|
|
52
|
+
if (
|
|
53
|
+
value.format !== BUNDLE_VERSION ||
|
|
54
|
+
value.package !== identity.name ||
|
|
55
|
+
value.version !== identity.version ||
|
|
56
|
+
!Array.isArray(value.files)
|
|
57
|
+
) {
|
|
58
|
+
throw new Error(`refusing to replace an incompatible Hexagon bundle at ${directory}`);
|
|
59
|
+
}
|
|
60
|
+
return value as Marker;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function assertOwned(directory: string, marker: Marker): Promise<void> {
|
|
64
|
+
const owned = new Set<string>([MARKER, ...marker.files]);
|
|
65
|
+
const unexpected = (await readdir(directory)).filter((entry) => !owned.has(entry));
|
|
66
|
+
if (unexpected.length > 0) {
|
|
67
|
+
throw new Error(`refusing to alter unexpected files in ${directory}: ${unexpected.join(', ')}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function materializeRuntime(sourceRoot: string, target: 'cloud' | 'local'): Promise<RuntimeBundle> {
|
|
72
|
+
const directory = resolve(sourceRoot, 'test', 'hexagon');
|
|
73
|
+
const parent = dirname(directory);
|
|
74
|
+
const parentExisted = await exists(parent);
|
|
75
|
+
const identity = await packageIdentity();
|
|
76
|
+
const marker = await readMarker(directory, identity);
|
|
77
|
+
if ((await exists(directory)) && marker === undefined) {
|
|
78
|
+
const entries = await readdir(directory);
|
|
79
|
+
if (entries.length > 0) {
|
|
80
|
+
throw new Error(`refusing to overwrite authored source at ${directory}; move it or pass a different --root`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (marker !== undefined) {
|
|
84
|
+
await assertOwned(directory, marker);
|
|
85
|
+
for (const file of marker.files) {
|
|
86
|
+
await rm(join(directory, file), { force: true });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const runtime = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'runtime');
|
|
91
|
+
const files = target === 'cloud' ? CLOUD_BUNDLE_FILES : LOCAL_BUNDLE_FILES;
|
|
92
|
+
await mkdir(directory, { recursive: true });
|
|
93
|
+
for (const file of files) {
|
|
94
|
+
const contents = await readFile(join(runtime, file));
|
|
95
|
+
await writeFile(join(directory, file), contents);
|
|
96
|
+
}
|
|
97
|
+
const next: Marker = {
|
|
98
|
+
format: BUNDLE_VERSION,
|
|
99
|
+
package: identity.name,
|
|
100
|
+
version: identity.version,
|
|
101
|
+
files,
|
|
102
|
+
};
|
|
103
|
+
await writeFile(join(directory, MARKER), `${JSON.stringify(next, undefined, '\t')}\n`);
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
directory,
|
|
107
|
+
cloudRunner: 'ReplicatedStorage.shared.test.hexagon.cloud_runner',
|
|
108
|
+
localRunner: join(directory, 'local_runner.luau'),
|
|
109
|
+
cleanup: async () => {
|
|
110
|
+
const current = await readMarker(directory, identity);
|
|
111
|
+
if (current === undefined) return;
|
|
112
|
+
await assertOwned(directory, current);
|
|
113
|
+
for (const file of current.files) {
|
|
114
|
+
await rm(join(directory, file), { force: true });
|
|
115
|
+
}
|
|
116
|
+
await rm(join(directory, MARKER), { force: true });
|
|
117
|
+
await rmdir(directory);
|
|
118
|
+
if (!parentExisted) {
|
|
119
|
+
try {
|
|
120
|
+
await rmdir(parent);
|
|
121
|
+
} catch (error) {
|
|
122
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
123
|
+
if (code !== 'ENOENT' && code !== 'ENOTEMPTY') throw error;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
import { dirname, relative, resolve, sep } from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { materializeRuntime } from './bundle.ts';
|
|
6
|
+
import { discoverLocalSuites, type Mode } from './discovery.ts';
|
|
7
|
+
import { aggregateReports, parseRunReport, parseTesseraRun, type RunReport, returnedString, type TesseraRunResult } from './protocol.ts';
|
|
8
|
+
import { printReport } from './reporter.ts';
|
|
9
|
+
|
|
10
|
+
type Isolation = 'file' | 'run';
|
|
11
|
+
type CloudTarget = { readonly kind: 'fresh' } | { readonly kind: 'published' } | { readonly kind: 'version'; readonly version: number };
|
|
12
|
+
|
|
13
|
+
interface Arguments {
|
|
14
|
+
readonly mode: Mode;
|
|
15
|
+
readonly filter?: string;
|
|
16
|
+
readonly cloud: boolean;
|
|
17
|
+
readonly root: string;
|
|
18
|
+
readonly place: string;
|
|
19
|
+
readonly environment?: string;
|
|
20
|
+
readonly config?: string;
|
|
21
|
+
readonly timeout: string;
|
|
22
|
+
readonly target: CloudTarget;
|
|
23
|
+
readonly codegen: boolean;
|
|
24
|
+
readonly isolation: Isolation;
|
|
25
|
+
readonly allowFocused: boolean;
|
|
26
|
+
readonly json: boolean;
|
|
27
|
+
readonly color: boolean;
|
|
28
|
+
readonly runner?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface CommandResult {
|
|
32
|
+
readonly stdout: string;
|
|
33
|
+
readonly stderr: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface HexagonResult {
|
|
37
|
+
readonly schema_version: 1;
|
|
38
|
+
readonly target: 'local' | 'cloud';
|
|
39
|
+
readonly isolation: Isolation;
|
|
40
|
+
readonly report: RunReport;
|
|
41
|
+
readonly executions: readonly TesseraRunResult[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
class HelpRequested extends Error {}
|
|
45
|
+
|
|
46
|
+
function usage(): string {
|
|
47
|
+
return `Hexagon tests and benchmarks
|
|
48
|
+
|
|
49
|
+
Usage:
|
|
50
|
+
hexagon <test|bench> [filter] [options]
|
|
51
|
+
|
|
52
|
+
Options:
|
|
53
|
+
--cloud Run suites from an exact Roblox artifact
|
|
54
|
+
--filter <text> Match a case path by case-insensitive substring
|
|
55
|
+
--root <directory> Source root (default src)
|
|
56
|
+
--isolation <file|run> Fresh VM/DataModel per suite file or one shared run
|
|
57
|
+
--place <name> Tessera logical place (default main)
|
|
58
|
+
--environment <name> Tessera environment
|
|
59
|
+
--config <path> Tessera config path
|
|
60
|
+
--timeout <duration> Tessera execution timeout (default 5m)
|
|
61
|
+
--version <number> Run an existing exact place version
|
|
62
|
+
--published Run mutable published head (cloud, run isolation only)
|
|
63
|
+
--runner <DataModel> Override the bundled cloud runner path
|
|
64
|
+
--interpreted Disable native code generation locally
|
|
65
|
+
--allow-focused Permit global .only cases (requires run isolation)
|
|
66
|
+
--json Emit one machine-readable result
|
|
67
|
+
--no-color Disable ANSI output
|
|
68
|
+
--help Show this help`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function required(args: readonly string[], index: number, option: string): string {
|
|
72
|
+
const value = args[index];
|
|
73
|
+
if (value === undefined || value.startsWith('--')) {
|
|
74
|
+
throw new TypeError(`${option} requires a value`);
|
|
75
|
+
}
|
|
76
|
+
return value;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function parseArguments(args: readonly string[], environment: NodeJS.ProcessEnv = process.env): Arguments {
|
|
80
|
+
const mode = args[0];
|
|
81
|
+
if (mode === undefined || mode === '--help' || mode === '-h') {
|
|
82
|
+
throw new HelpRequested();
|
|
83
|
+
}
|
|
84
|
+
if (mode !== 'test' && mode !== 'bench') {
|
|
85
|
+
throw new TypeError(`expected test or bench, got ${mode}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
let filter: string | undefined;
|
|
89
|
+
let cloud = false;
|
|
90
|
+
let root = 'src';
|
|
91
|
+
let place = 'main';
|
|
92
|
+
let selectedEnvironment: string | undefined;
|
|
93
|
+
let config: string | undefined;
|
|
94
|
+
let timeout = '5m';
|
|
95
|
+
let target: CloudTarget = { kind: 'fresh' };
|
|
96
|
+
let targetSet = false;
|
|
97
|
+
let codegen = true;
|
|
98
|
+
let isolation: Isolation = 'file';
|
|
99
|
+
let allowFocused = false;
|
|
100
|
+
let json = false;
|
|
101
|
+
let color = environment.NO_COLOR === undefined;
|
|
102
|
+
let runner: string | undefined;
|
|
103
|
+
|
|
104
|
+
for (let index = 1; index < args.length; index += 1) {
|
|
105
|
+
const argument = args[index]!;
|
|
106
|
+
switch (argument) {
|
|
107
|
+
case '--help':
|
|
108
|
+
case '-h':
|
|
109
|
+
throw new HelpRequested();
|
|
110
|
+
case '--cloud':
|
|
111
|
+
cloud = true;
|
|
112
|
+
break;
|
|
113
|
+
case '--filter':
|
|
114
|
+
filter = required(args, ++index, '--filter');
|
|
115
|
+
break;
|
|
116
|
+
case '--root':
|
|
117
|
+
root = required(args, ++index, '--root');
|
|
118
|
+
break;
|
|
119
|
+
case '--place':
|
|
120
|
+
place = required(args, ++index, '--place');
|
|
121
|
+
break;
|
|
122
|
+
case '--environment':
|
|
123
|
+
selectedEnvironment = required(args, ++index, '--environment');
|
|
124
|
+
break;
|
|
125
|
+
case '--config':
|
|
126
|
+
config = required(args, ++index, '--config');
|
|
127
|
+
break;
|
|
128
|
+
case '--timeout':
|
|
129
|
+
timeout = required(args, ++index, '--timeout');
|
|
130
|
+
break;
|
|
131
|
+
case '--version': {
|
|
132
|
+
if (targetSet) throw new TypeError('select only one cloud artifact mode');
|
|
133
|
+
const raw = required(args, ++index, '--version');
|
|
134
|
+
const version = Number(raw);
|
|
135
|
+
if (!Number.isSafeInteger(version) || version <= 0) {
|
|
136
|
+
throw new TypeError('--version requires a positive integer');
|
|
137
|
+
}
|
|
138
|
+
target = { kind: 'version', version };
|
|
139
|
+
targetSet = true;
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
case '--published':
|
|
143
|
+
if (targetSet) throw new TypeError('select only one cloud artifact mode');
|
|
144
|
+
target = { kind: 'published' };
|
|
145
|
+
targetSet = true;
|
|
146
|
+
break;
|
|
147
|
+
case '--runner':
|
|
148
|
+
runner = required(args, ++index, '--runner');
|
|
149
|
+
break;
|
|
150
|
+
case '--interpreted':
|
|
151
|
+
codegen = false;
|
|
152
|
+
break;
|
|
153
|
+
case '--isolation': {
|
|
154
|
+
const value = required(args, ++index, '--isolation');
|
|
155
|
+
if (value !== 'file' && value !== 'run') {
|
|
156
|
+
throw new TypeError('--isolation must be file or run');
|
|
157
|
+
}
|
|
158
|
+
isolation = value;
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
case '--allow-focused':
|
|
162
|
+
allowFocused = true;
|
|
163
|
+
break;
|
|
164
|
+
case '--json':
|
|
165
|
+
json = true;
|
|
166
|
+
color = false;
|
|
167
|
+
break;
|
|
168
|
+
case '--no-color':
|
|
169
|
+
color = false;
|
|
170
|
+
break;
|
|
171
|
+
default:
|
|
172
|
+
if (argument.startsWith('--')) {
|
|
173
|
+
throw new TypeError(`unknown option: ${argument}`);
|
|
174
|
+
}
|
|
175
|
+
if (filter !== undefined) throw new TypeError(`unknown argument: ${argument}`);
|
|
176
|
+
filter = argument;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (!cloud && targetSet) throw new TypeError('cloud artifact options require --cloud');
|
|
181
|
+
if (cloud && !codegen) throw new TypeError('--interpreted only applies to local runs');
|
|
182
|
+
if (!cloud && (selectedEnvironment !== undefined || config !== undefined || runner !== undefined)) {
|
|
183
|
+
throw new TypeError('Tessera options require --cloud');
|
|
184
|
+
}
|
|
185
|
+
if (cloud && target.kind === 'published' && isolation === 'file') {
|
|
186
|
+
throw new TypeError('--published cannot guarantee one version across isolated files; use --isolation run');
|
|
187
|
+
}
|
|
188
|
+
if (allowFocused && isolation === 'file') {
|
|
189
|
+
throw new TypeError('--allow-focused requires --isolation run so focus applies globally');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
mode,
|
|
194
|
+
...(filter === undefined ? {} : { filter }),
|
|
195
|
+
cloud,
|
|
196
|
+
root,
|
|
197
|
+
place,
|
|
198
|
+
...(selectedEnvironment === undefined ? {} : { environment: selectedEnvironment }),
|
|
199
|
+
...(config === undefined ? {} : { config }),
|
|
200
|
+
timeout,
|
|
201
|
+
target,
|
|
202
|
+
codegen,
|
|
203
|
+
isolation,
|
|
204
|
+
allowFocused,
|
|
205
|
+
json,
|
|
206
|
+
color,
|
|
207
|
+
...(runner === undefined ? {} : { runner }),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function consume(stream: ReadableStream<Uint8Array>, forward: boolean): Promise<string> {
|
|
212
|
+
const reader = stream.getReader();
|
|
213
|
+
const decoder = new TextDecoder();
|
|
214
|
+
let output = '';
|
|
215
|
+
while (true) {
|
|
216
|
+
const { done, value } = await reader.read();
|
|
217
|
+
if (done) break;
|
|
218
|
+
const text = decoder.decode(value, { stream: true });
|
|
219
|
+
output += text;
|
|
220
|
+
if (forward) process.stderr.write(text);
|
|
221
|
+
}
|
|
222
|
+
output += decoder.decode();
|
|
223
|
+
return output;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function execute(command: readonly string[], cwd: string, streamStderr: boolean): Promise<CommandResult> {
|
|
227
|
+
const child = Bun.spawn([...command], {
|
|
228
|
+
cwd,
|
|
229
|
+
stdout: 'pipe',
|
|
230
|
+
stderr: 'pipe',
|
|
231
|
+
});
|
|
232
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
233
|
+
new Response(child.stdout).text(),
|
|
234
|
+
consume(child.stderr as ReadableStream<Uint8Array>, streamStderr),
|
|
235
|
+
child.exited,
|
|
236
|
+
]);
|
|
237
|
+
if (exitCode !== 0) {
|
|
238
|
+
if (!streamStderr && stderr !== '') process.stderr.write(stderr);
|
|
239
|
+
throw new Error(`${command[0]} exited with code ${exitCode}`);
|
|
240
|
+
}
|
|
241
|
+
return { stdout, stderr };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function moduleArgument(runner: string, suite: string): string {
|
|
245
|
+
let path = relative(dirname(runner), suite).split(sep).join('/');
|
|
246
|
+
if (!path.startsWith('.')) path = `./${path}`;
|
|
247
|
+
return path.replace(/\.luau$/, '');
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function runLocalGroup(arguments_: Arguments, projectRoot: string, runner: string, suites: readonly string[]): Promise<RunReport> {
|
|
251
|
+
const command = ['luau'];
|
|
252
|
+
if (arguments_.codegen) command.push('--codegen');
|
|
253
|
+
command.push('-O2', runner, '-a', arguments_.mode, arguments_.filter ?? '-');
|
|
254
|
+
const sourceRoot = resolve(projectRoot, arguments_.root);
|
|
255
|
+
for (const suite of suites) {
|
|
256
|
+
command.push(
|
|
257
|
+
moduleArgument(runner, suite),
|
|
258
|
+
relative(sourceRoot, suite)
|
|
259
|
+
.split(sep)
|
|
260
|
+
.join('/')
|
|
261
|
+
.replace(/\.luau$/, ''),
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
const result = await execute(command, projectRoot, false);
|
|
265
|
+
const match = result.stdout.match(/(?:^|\n)HEXAGON_JSON:(.+)\r?\n?$/s);
|
|
266
|
+
if (match === null) {
|
|
267
|
+
if (result.stdout !== '') process.stderr.write(result.stdout);
|
|
268
|
+
throw new Error('native runner did not return a Hexagon report');
|
|
269
|
+
}
|
|
270
|
+
const diagnostics = result.stdout.slice(0, match.index).trim();
|
|
271
|
+
if (diagnostics !== '') process.stderr.write(`${diagnostics}\n`);
|
|
272
|
+
return parseRunReport(match[1]!);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async function runLocal(arguments_: Arguments, projectRoot: string): Promise<HexagonResult> {
|
|
276
|
+
const sourceRoot = resolve(projectRoot, arguments_.root);
|
|
277
|
+
const bundle = await materializeRuntime(sourceRoot, 'local');
|
|
278
|
+
const reports: RunReport[] = [];
|
|
279
|
+
try {
|
|
280
|
+
const suites = await discoverLocalSuites(sourceRoot, arguments_.mode);
|
|
281
|
+
if (arguments_.isolation === 'run') {
|
|
282
|
+
if (suites.length > 0) {
|
|
283
|
+
reports.push(await runLocalGroup(arguments_, projectRoot, bundle.localRunner, suites));
|
|
284
|
+
}
|
|
285
|
+
} else {
|
|
286
|
+
for (const suite of suites) {
|
|
287
|
+
reports.push(await runLocalGroup(arguments_, projectRoot, bundle.localRunner, [suite]));
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
} finally {
|
|
291
|
+
await bundle.cleanup();
|
|
292
|
+
}
|
|
293
|
+
return {
|
|
294
|
+
schema_version: 1,
|
|
295
|
+
target: 'local',
|
|
296
|
+
isolation: arguments_.isolation,
|
|
297
|
+
report: aggregateReports(arguments_.mode, arguments_.filter, reports),
|
|
298
|
+
executions: [],
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function tesseraCommand(
|
|
303
|
+
arguments_: Arguments,
|
|
304
|
+
runner: string,
|
|
305
|
+
method: 'list' | 'run',
|
|
306
|
+
request: Record<string, unknown>,
|
|
307
|
+
target: CloudTarget,
|
|
308
|
+
): string[] {
|
|
309
|
+
const command = [
|
|
310
|
+
'tessera',
|
|
311
|
+
'run',
|
|
312
|
+
'--json',
|
|
313
|
+
'--logs',
|
|
314
|
+
'--module',
|
|
315
|
+
runner,
|
|
316
|
+
'--method',
|
|
317
|
+
method,
|
|
318
|
+
'--args',
|
|
319
|
+
JSON.stringify([request]),
|
|
320
|
+
'--timeout',
|
|
321
|
+
arguments_.timeout,
|
|
322
|
+
];
|
|
323
|
+
if (arguments_.environment !== undefined) {
|
|
324
|
+
command.push('--environment', arguments_.environment);
|
|
325
|
+
}
|
|
326
|
+
if (arguments_.config !== undefined) command.push('--config', arguments_.config);
|
|
327
|
+
if (target.kind === 'fresh') command.push('--fresh');
|
|
328
|
+
else if (target.kind === 'version') command.push('--version', String(target.version));
|
|
329
|
+
command.push(arguments_.place);
|
|
330
|
+
return command;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async function runTessera(
|
|
334
|
+
arguments_: Arguments,
|
|
335
|
+
projectRoot: string,
|
|
336
|
+
runner: string,
|
|
337
|
+
method: 'list' | 'run',
|
|
338
|
+
request: Record<string, unknown>,
|
|
339
|
+
target: CloudTarget,
|
|
340
|
+
): Promise<TesseraRunResult> {
|
|
341
|
+
const result = await execute(tesseraCommand(arguments_, runner, method, request, target), projectRoot, !arguments_.json);
|
|
342
|
+
return parseTesseraRun(result.stdout);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function printExecutionLogs(result: TesseraRunResult, json: boolean): void {
|
|
346
|
+
if (json) return;
|
|
347
|
+
for (const line of result.logs ?? []) process.stderr.write(`${line}\n`);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
async function runCloud(arguments_: Arguments, projectRoot: string): Promise<HexagonResult> {
|
|
351
|
+
const bundle = await materializeRuntime(resolve(projectRoot, arguments_.root), 'cloud');
|
|
352
|
+
const runner = arguments_.runner ?? bundle.cloudRunner;
|
|
353
|
+
const reports: RunReport[] = [];
|
|
354
|
+
const executions: TesseraRunResult[] = [];
|
|
355
|
+
try {
|
|
356
|
+
const baseRequest: Record<string, unknown> = { mode: arguments_.mode };
|
|
357
|
+
if (arguments_.filter !== undefined) baseRequest.filter = arguments_.filter;
|
|
358
|
+
if (arguments_.isolation === 'run') {
|
|
359
|
+
const execution = await runTessera(arguments_, projectRoot, runner, 'run', baseRequest, arguments_.target);
|
|
360
|
+
executions.push(execution);
|
|
361
|
+
printExecutionLogs(execution, arguments_.json);
|
|
362
|
+
reports.push(parseRunReport(returnedString(execution, 'cloud runner')));
|
|
363
|
+
} else {
|
|
364
|
+
const discovery = await runTessera(arguments_, projectRoot, runner, 'list', { mode: arguments_.mode }, arguments_.target);
|
|
365
|
+
executions.push(discovery);
|
|
366
|
+
printExecutionLogs(discovery, arguments_.json);
|
|
367
|
+
const manifest = JSON.parse(returnedString(discovery, 'cloud discovery')) as { schema_version?: unknown; suites?: unknown };
|
|
368
|
+
if (manifest.schema_version !== 1 || !Array.isArray(manifest.suites)) {
|
|
369
|
+
throw new Error('cloud discovery returned an unsupported suite manifest');
|
|
370
|
+
}
|
|
371
|
+
const suites = manifest.suites.map((suite) => {
|
|
372
|
+
if (typeof suite !== 'string') {
|
|
373
|
+
throw new TypeError('cloud suite manifest contains a non-string path');
|
|
374
|
+
}
|
|
375
|
+
return suite;
|
|
376
|
+
});
|
|
377
|
+
const version = discovery.version;
|
|
378
|
+
if (version === undefined || version <= 0) {
|
|
379
|
+
throw new Error('isolated cloud runs require an exact saved place version');
|
|
380
|
+
}
|
|
381
|
+
for (const suite of suites) {
|
|
382
|
+
const execution = await runTessera(arguments_, projectRoot, runner, 'run', { ...baseRequest, suite }, { kind: 'version', version });
|
|
383
|
+
executions.push(execution);
|
|
384
|
+
printExecutionLogs(execution, arguments_.json);
|
|
385
|
+
reports.push(parseRunReport(returnedString(execution, `suite ${suite}`)));
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
} finally {
|
|
389
|
+
await bundle.cleanup();
|
|
390
|
+
}
|
|
391
|
+
return {
|
|
392
|
+
schema_version: 1,
|
|
393
|
+
target: 'cloud',
|
|
394
|
+
isolation: arguments_.isolation,
|
|
395
|
+
report: aggregateReports(arguments_.mode, arguments_.filter, reports),
|
|
396
|
+
executions,
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
export function policyFailure(result: HexagonResult, arguments_: Arguments): string | undefined {
|
|
401
|
+
if (result.report.selected === 0) return 'no cases were selected';
|
|
402
|
+
if (result.report.focused && !arguments_.allowFocused) {
|
|
403
|
+
return 'focused cases are not allowed; remove `.only` or pass --allow-focused for an intentional run';
|
|
404
|
+
}
|
|
405
|
+
if (!result.report.ok) return 'one or more cases failed';
|
|
406
|
+
return undefined;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
export async function runCLI(rawArguments: readonly string[], cwd = process.cwd()): Promise<number> {
|
|
410
|
+
let arguments_: Arguments;
|
|
411
|
+
try {
|
|
412
|
+
arguments_ = parseArguments(rawArguments);
|
|
413
|
+
} catch (error) {
|
|
414
|
+
if (error instanceof HelpRequested) {
|
|
415
|
+
process.stdout.write(`${usage()}\n`);
|
|
416
|
+
return 0;
|
|
417
|
+
}
|
|
418
|
+
throw error;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const result = arguments_.cloud ? await runCloud(arguments_, cwd) : await runLocal(arguments_, cwd);
|
|
422
|
+
if (arguments_.json) process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
423
|
+
else printReport(result.report, { color: arguments_.color });
|
|
424
|
+
|
|
425
|
+
const failure = policyFailure(result, arguments_);
|
|
426
|
+
if (failure !== undefined) {
|
|
427
|
+
if (!arguments_.json) process.stderr.write(`hexagon: ${failure}\n`);
|
|
428
|
+
return 1;
|
|
429
|
+
}
|
|
430
|
+
return 0;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
if (import.meta.main) {
|
|
434
|
+
try {
|
|
435
|
+
process.exitCode = await runCLI(Bun.argv.slice(2));
|
|
436
|
+
} catch (error) {
|
|
437
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
438
|
+
process.stderr.write(`hexagon: ${message}\n`);
|
|
439
|
+
process.exitCode = 1;
|
|
440
|
+
}
|
|
441
|
+
}
|
package/src/discovery.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { readdir } from 'node:fs/promises';
|
|
2
|
+
import { relative, resolve } from 'node:path';
|
|
3
|
+
|
|
4
|
+
export type Mode = 'test' | 'bench';
|
|
5
|
+
|
|
6
|
+
function suffix(mode: Mode): string {
|
|
7
|
+
return mode === 'test' ? '.local.spec.luau' : '.local.bench.luau';
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function discoverLocalSuites(root: string, mode: Mode): Promise<string[]> {
|
|
11
|
+
const sourceRoot = resolve(root);
|
|
12
|
+
const expected = suffix(mode);
|
|
13
|
+
const suites: string[] = [];
|
|
14
|
+
|
|
15
|
+
async function walk(directory: string): Promise<void> {
|
|
16
|
+
const entries = await readdir(directory, { withFileTypes: true });
|
|
17
|
+
entries.sort((left, right) => left.name.localeCompare(right.name));
|
|
18
|
+
for (const entry of entries) {
|
|
19
|
+
const path = resolve(directory, entry.name);
|
|
20
|
+
if (entry.isDirectory()) await walk(path);
|
|
21
|
+
else if (entry.isFile() && entry.name.endsWith(expected)) suites.push(path);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
await walk(sourceRoot);
|
|
26
|
+
suites.sort((left, right) => relative(sourceRoot, left).localeCompare(relative(sourceRoot, right)));
|
|
27
|
+
return suites;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function matchesCloudSuite(name: string, mode: Mode): boolean {
|
|
31
|
+
return mode === 'test' ? name.endsWith('.spec') : name.endsWith('.bench');
|
|
32
|
+
}
|