@smoothbricks/cli 0.10.6 → 0.10.8
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/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +24 -1
- package/dist/github-ci/index.d.ts +44 -3
- package/dist/github-ci/index.d.ts.map +1 -1
- package/dist/github-ci/index.js +242 -36
- package/dist/lib/json.d.ts +1 -1
- package/dist/lib/json.d.ts.map +1 -1
- package/dist/lib/json.js +3 -18
- package/dist/lib/workspace.d.ts.map +1 -1
- package/dist/lib/workspace.js +1 -0
- package/dist/monorepo/ci-workflow.js +16 -6
- package/dist/monorepo/managed-files.d.ts +6 -22
- package/dist/monorepo/managed-files.d.ts.map +1 -1
- package/dist/monorepo/managed-files.js +31 -48
- package/dist/monorepo/package-policy.js +2 -2
- package/dist/monorepo/packs/index.d.ts.map +1 -1
- package/dist/monorepo/packs/index.js +2 -0
- package/dist/monorepo/pr-preview-cleanup-workflow.d.ts +5 -0
- package/dist/monorepo/pr-preview-cleanup-workflow.d.ts.map +1 -0
- package/dist/monorepo/pr-preview-cleanup-workflow.js +38 -0
- package/dist/monorepo/publish-workflow.js +3 -3
- package/dist/monorepo/runtime.d.ts +2 -2
- package/dist/monorepo/runtime.d.ts.map +1 -1
- package/dist/monorepo/runtime.js +11 -16
- package/dist/monorepo/tool-validation.d.ts.map +1 -1
- package/dist/monorepo/tool-validation.js +105 -28
- package/dist/nx/index.d.ts +7 -4
- package/dist/nx/index.d.ts.map +1 -1
- package/dist/nx/index.js +117 -56
- package/dist/playwright/index.d.ts +22 -0
- package/dist/playwright/index.d.ts.map +1 -0
- package/dist/playwright/index.js +44 -0
- package/dist/release/bootstrap-npm-packages.d.ts +3 -0
- package/dist/release/bootstrap-npm-packages.d.ts.map +1 -1
- package/dist/release/bootstrap-npm-packages.js +21 -0
- package/dist/release/index.d.ts.map +1 -1
- package/dist/release/index.js +17 -0
- package/dist/wrangler/cloudflare.d.ts +85 -0
- package/dist/wrangler/cloudflare.d.ts.map +1 -0
- package/dist/wrangler/cloudflare.js +235 -0
- package/dist/wrangler/deploy-environment.d.ts +48 -0
- package/dist/wrangler/deploy-environment.d.ts.map +1 -0
- package/dist/wrangler/deploy-environment.js +383 -0
- package/dist/wrangler/environment.d.ts +58 -0
- package/dist/wrangler/environment.d.ts.map +1 -0
- package/dist/wrangler/environment.js +297 -0
- package/managed/raw/tooling/devenv +71 -0
- package/managed/raw/tooling/direnv/github-actions-bootstrap.sh +3 -4
- package/package.json +11 -4
- package/src/cli.ts +33 -3
- package/src/github-ci/index.test.ts +221 -1
- package/src/github-ci/index.ts +303 -32
- package/src/lib/json.ts +1 -1
- package/src/lib/workspace.ts +1 -0
- package/src/monorepo/__tests__/ci-workflow.test.ts +10 -4
- package/src/monorepo/__tests__/pr-preview-cleanup-workflow.test.ts +23 -0
- package/src/monorepo/__tests__/publish-workflow.test.ts +12 -4
- package/src/monorepo/ci-workflow.ts +16 -6
- package/src/monorepo/managed-files.test.ts +87 -23
- package/src/monorepo/managed-files.ts +35 -69
- package/src/monorepo/package-policy.test.ts +31 -0
- package/src/monorepo/package-policy.ts +2 -2
- package/src/monorepo/packs/index.ts +2 -0
- package/src/monorepo/pr-preview-cleanup-workflow.ts +44 -0
- package/src/monorepo/publish-workflow.ts +7 -2
- package/src/monorepo/runtime.test.ts +23 -17
- package/src/monorepo/runtime.ts +12 -17
- package/src/monorepo/tool-validation.test.ts +100 -8
- package/src/monorepo/tool-validation.ts +115 -31
- package/src/nx/index.test.ts +45 -14
- package/src/nx/index.ts +133 -64
- package/src/playwright/index.test.ts +90 -0
- package/src/playwright/index.ts +73 -0
- package/src/release/__tests__/bootstrap-npm-packages.test.ts +63 -2
- package/src/release/bootstrap-npm-packages.ts +34 -0
- package/src/release/index.ts +18 -0
- package/src/wrangler/cloudflare.ts +289 -0
- package/src/wrangler/deploy-environment.test.ts +354 -0
- package/src/wrangler/deploy-environment.ts +445 -0
- package/src/wrangler/environment.test.ts +173 -0
- package/src/wrangler/environment.ts +366 -0
- package/managed/raw/patches/ttsc@0.19.3.patch +0 -67
package/src/nx/index.test.ts
CHANGED
|
@@ -4,12 +4,12 @@ import {
|
|
|
4
4
|
formatProjectTargetLines,
|
|
5
5
|
nxCacheDirectories,
|
|
6
6
|
nxResetCommand,
|
|
7
|
-
nxShowProjectCommand,
|
|
8
|
-
projectNamesFromNxShowProjectsOutput,
|
|
9
7
|
projectNamesWithTarget,
|
|
10
8
|
projectRootFromNxProjectJson,
|
|
9
|
+
projectTargetsFromNxProjects,
|
|
11
10
|
targetDependenciesFromNxProjectJson,
|
|
12
11
|
targetNamesFromNxProjectJson,
|
|
12
|
+
targetNamesFromProjects,
|
|
13
13
|
targetOutputsFromNxProjectJson,
|
|
14
14
|
} from './index.js';
|
|
15
15
|
|
|
@@ -18,10 +18,6 @@ describe('Nx helper command construction', () => {
|
|
|
18
18
|
expect(nxResetCommand()).toEqual({ command: 'nx', args: ['reset'] });
|
|
19
19
|
});
|
|
20
20
|
|
|
21
|
-
it('builds explicit nx project metadata invocation', () => {
|
|
22
|
-
expect(nxShowProjectCommand('cli')).toEqual({ command: 'nx', args: ['show', 'project', 'cli', '--json'] });
|
|
23
|
-
});
|
|
24
|
-
|
|
25
21
|
it('selects only local Nx cache directories', () => {
|
|
26
22
|
expect(nxCacheDirectories('/repo')).toEqual([
|
|
27
23
|
join('/repo', '.nx/cache'),
|
|
@@ -54,14 +50,6 @@ describe('Nx helper output formatting', () => {
|
|
|
54
50
|
).toEqual(['cli', 'web']);
|
|
55
51
|
});
|
|
56
52
|
|
|
57
|
-
it('parses JSON project lists from nx show projects', () => {
|
|
58
|
-
expect(projectNamesFromNxShowProjectsOutput('["web","cli"]\n')).toEqual(['cli', 'web']);
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
it('parses legacy newline project lists from nx show projects', () => {
|
|
62
|
-
expect(projectNamesFromNxShowProjectsOutput('web\ncli\n')).toEqual(['cli', 'web']);
|
|
63
|
-
});
|
|
64
|
-
|
|
65
53
|
it('extracts sorted target names from Nx project JSON', () => {
|
|
66
54
|
expect(targetNamesFromNxProjectJson({ targets: { test: {}, build: {}, lint: {} } })).toEqual([
|
|
67
55
|
'build',
|
|
@@ -113,4 +101,47 @@ describe('Nx helper output formatting', () => {
|
|
|
113
101
|
it('treats missing target metadata as an empty project', () => {
|
|
114
102
|
expect(targetNamesFromNxProjectJson({ name: 'cli' })).toEqual([]);
|
|
115
103
|
});
|
|
104
|
+
|
|
105
|
+
it('projects every resolved graph node without per-project CLI parsing', () => {
|
|
106
|
+
const projects = {
|
|
107
|
+
web: {
|
|
108
|
+
root: 'packages/web',
|
|
109
|
+
targets: {
|
|
110
|
+
test: {
|
|
111
|
+
executor: '@smoothbricks/nx-plugin:bounded-exec',
|
|
112
|
+
dependsOn: ['build'],
|
|
113
|
+
options: { command: 'bun test', timeoutMs: 120_000 },
|
|
114
|
+
},
|
|
115
|
+
build: { outputs: ['{projectRoot}/dist'] },
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
cli: { root: 'packages/cli', targets: { lint: {} } },
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
expect(targetNamesFromProjects(projects).sort()).toEqual(['build', 'lint', 'test']);
|
|
122
|
+
expect(projectTargetsFromNxProjects(projects)).toEqual([
|
|
123
|
+
{
|
|
124
|
+
project: 'cli',
|
|
125
|
+
root: 'packages/cli',
|
|
126
|
+
targets: ['lint'],
|
|
127
|
+
buildDependsOn: undefined,
|
|
128
|
+
targetDependencies: new Map(),
|
|
129
|
+
targetExecutors: new Map(),
|
|
130
|
+
targetOptions: new Map(),
|
|
131
|
+
targetOutputs: new Map(),
|
|
132
|
+
targetScripts: new Map(),
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
project: 'web',
|
|
136
|
+
root: 'packages/web',
|
|
137
|
+
targets: ['build', 'test'],
|
|
138
|
+
buildDependsOn: undefined,
|
|
139
|
+
targetDependencies: new Map([['test', ['build']]]),
|
|
140
|
+
targetExecutors: new Map([['test', '@smoothbricks/nx-plugin:bounded-exec']]),
|
|
141
|
+
targetOptions: new Map([['test', { command: 'bun test', timeoutMs: 120_000 }]]),
|
|
142
|
+
targetOutputs: new Map([['build', ['{projectRoot}/dist']]]),
|
|
143
|
+
targetScripts: new Map(),
|
|
144
|
+
},
|
|
145
|
+
]);
|
|
146
|
+
});
|
|
116
147
|
});
|
package/src/nx/index.ts
CHANGED
|
@@ -1,15 +1,11 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
2
|
import { lstat, rm } from 'node:fs/promises';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
-
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
parseNxProjectJsonText,
|
|
10
|
-
parseStringArrayText,
|
|
11
|
-
} from '../lib/json.js';
|
|
12
|
-
import { decode, run } from '../lib/run.js';
|
|
4
|
+
// Nx package exports are CLI-oriented; this is the module the CLI uses for graph + daemon IPC.
|
|
5
|
+
import { createProjectGraphAsync } from 'nx/src/project-graph/project-graph.js';
|
|
6
|
+
import { workspaceRoot as primaryWorkspaceRoot } from 'nx/src/utils/workspace-root.js';
|
|
7
|
+
import type { NxDependsOn, NxProjectJson, NxTargetConfig, NxTargetOptions } from '../lib/json.js';
|
|
8
|
+
import { run, runResult } from '../lib/run.js';
|
|
13
9
|
|
|
14
10
|
export interface ProjectTargets {
|
|
15
11
|
project: string;
|
|
@@ -18,6 +14,7 @@ export interface ProjectTargets {
|
|
|
18
14
|
buildDependsOn?: string[];
|
|
19
15
|
targetDependencies?: Map<string, string[]>;
|
|
20
16
|
targetExecutors?: Map<string, string>;
|
|
17
|
+
targetOptions?: Map<string, NxTargetOptions>;
|
|
21
18
|
targetOutputs?: Map<string, string[]>;
|
|
22
19
|
targetScripts?: Map<string, string>;
|
|
23
20
|
}
|
|
@@ -31,10 +28,6 @@ export function nxResetCommand(): CommandInvocation {
|
|
|
31
28
|
return { command: 'nx', args: ['reset'] };
|
|
32
29
|
}
|
|
33
30
|
|
|
34
|
-
export function nxShowProjectCommand(project: string): CommandInvocation {
|
|
35
|
-
return { command: 'nx', args: ['show', 'project', project, '--json'] };
|
|
36
|
-
}
|
|
37
|
-
|
|
38
31
|
export function nxCacheDirectories(root: string): string[] {
|
|
39
32
|
return [join(root, '.nx/cache'), join(root, '.nx/workspace-data'), join(root, 'node_modules/.cache/nx')];
|
|
40
33
|
}
|
|
@@ -48,10 +41,6 @@ export function projectRootFromNxProjectJson(value: NxProjectJson | null | undef
|
|
|
48
41
|
return typeof value?.root === 'string' ? value.root : undefined;
|
|
49
42
|
}
|
|
50
43
|
|
|
51
|
-
export function buildDependsOnFromNxProjectJson(value: NxProjectJson | null | undefined): string[] | undefined {
|
|
52
|
-
return targetDependenciesFromNxProjectJson(value).get('build');
|
|
53
|
-
}
|
|
54
|
-
|
|
55
44
|
export function targetDependenciesFromNxProjectJson(value: NxProjectJson | null | undefined): Map<string, string[]> {
|
|
56
45
|
const targets = value?.targets;
|
|
57
46
|
const dependencies = new Map<string, string[]>();
|
|
@@ -112,6 +101,20 @@ export function targetExecutorsFromNxProjectJson(value: NxProjectJson | null | u
|
|
|
112
101
|
return executors;
|
|
113
102
|
}
|
|
114
103
|
|
|
104
|
+
export function targetOptionsFromNxProjectJson(value: NxProjectJson | null | undefined): Map<string, NxTargetOptions> {
|
|
105
|
+
const targets = value?.targets;
|
|
106
|
+
const options = new Map<string, NxTargetOptions>();
|
|
107
|
+
if (!targets) {
|
|
108
|
+
return options;
|
|
109
|
+
}
|
|
110
|
+
for (const [targetName, target] of Object.entries(targets)) {
|
|
111
|
+
if (target.options) {
|
|
112
|
+
options.set(targetName, target.options);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return options;
|
|
116
|
+
}
|
|
117
|
+
|
|
115
118
|
export function targetOutputsFromNxProjectJson(value: NxProjectJson | null | undefined): Map<string, string[]> {
|
|
116
119
|
return targetStringArraysFromNxProjectJson(value, 'outputs');
|
|
117
120
|
}
|
|
@@ -168,30 +171,6 @@ export function projectNamesWithTarget(projects: ProjectTargets[], target: strin
|
|
|
168
171
|
.sort((a, b) => a.localeCompare(b));
|
|
169
172
|
}
|
|
170
173
|
|
|
171
|
-
export function projectNamesFromNxShowProjectsOutput(output: string): string[] {
|
|
172
|
-
const trimmed = output.trim();
|
|
173
|
-
if (!trimmed) {
|
|
174
|
-
return [];
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
if (trimmed.startsWith('[')) {
|
|
178
|
-
try {
|
|
179
|
-
const parsed = parseStringArrayText(trimmed);
|
|
180
|
-
if (parsed) {
|
|
181
|
-
return parsed.sort((a, b) => a.localeCompare(b));
|
|
182
|
-
}
|
|
183
|
-
} catch {
|
|
184
|
-
// Fall through to legacy newline parsing for older Nx output shapes.
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
return trimmed
|
|
189
|
-
.split('\n')
|
|
190
|
-
.map((line) => line.trim())
|
|
191
|
-
.filter(Boolean)
|
|
192
|
-
.sort((a, b) => a.localeCompare(b));
|
|
193
|
-
}
|
|
194
|
-
|
|
195
174
|
export async function listTargets(root: string): Promise<void> {
|
|
196
175
|
const output = formatProjectTargetLines(await readProjectTargets(root));
|
|
197
176
|
if (output) {
|
|
@@ -229,34 +208,124 @@ export async function cleanCache(root: string): Promise<void> {
|
|
|
229
208
|
}
|
|
230
209
|
}
|
|
231
210
|
|
|
232
|
-
export
|
|
233
|
-
|
|
234
|
-
|
|
211
|
+
export type NxProjects = Readonly<Record<string, NxProjectJson>>;
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Load the resolved project graph through Nx's API. The current workspace uses the
|
|
215
|
+
* daemon in-process; foreign roots use an isolated process because Nx snapshots its
|
|
216
|
+
* workspace cache paths when the module loads.
|
|
217
|
+
*/
|
|
218
|
+
let graphLoadTail: Promise<void> = Promise.resolve();
|
|
219
|
+
|
|
220
|
+
export function loadNxProjects(root: string): Promise<NxProjects> {
|
|
221
|
+
const result = graphLoadTail.then(() => loadNxProjectsNow(root));
|
|
222
|
+
graphLoadTail = result.then(
|
|
223
|
+
() => undefined,
|
|
224
|
+
() => undefined,
|
|
225
|
+
);
|
|
226
|
+
return result;
|
|
235
227
|
}
|
|
236
228
|
|
|
237
|
-
async function
|
|
238
|
-
|
|
239
|
-
|
|
229
|
+
async function loadNxProjectsNow(root: string): Promise<NxProjects> {
|
|
230
|
+
if (root !== primaryWorkspaceRoot) {
|
|
231
|
+
return loadForeignNxProjects(root);
|
|
232
|
+
}
|
|
233
|
+
const graph = await createProjectGraphAsync({ exitOnError: false });
|
|
234
|
+
return nxProjectsFromNodes(graph.nodes);
|
|
240
235
|
}
|
|
241
236
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
237
|
+
const FOREIGN_GRAPH_SCRIPT = `
|
|
238
|
+
import('nx/src/project-graph/project-graph.js')
|
|
239
|
+
.then(async ({ createProjectGraphAsync }) => {
|
|
240
|
+
const graph = await createProjectGraphAsync({ exitOnError: false });
|
|
241
|
+
const projects = Object.fromEntries(
|
|
242
|
+
Object.entries(graph.nodes).map(([name, node]) => [
|
|
243
|
+
name,
|
|
244
|
+
{ ...node.data, name: node.data?.name ?? name, targets: node.data?.targets ?? {} },
|
|
245
|
+
]),
|
|
246
|
+
);
|
|
247
|
+
process.stdout.write(JSON.stringify(projects));
|
|
248
|
+
})
|
|
249
|
+
.catch((error) => {
|
|
250
|
+
console.error(error);
|
|
251
|
+
process.exitCode = 1;
|
|
252
|
+
});
|
|
253
|
+
`;
|
|
254
|
+
|
|
255
|
+
async function loadForeignNxProjects(root: string): Promise<NxProjects> {
|
|
256
|
+
const result = await runResult(process.execPath, ['--eval', FOREIGN_GRAPH_SCRIPT], root, {
|
|
257
|
+
NX_DAEMON: 'false',
|
|
258
|
+
NX_ISOLATE_PLUGINS: 'false',
|
|
259
|
+
NX_WORKSPACE_ROOT_PATH: root,
|
|
260
|
+
});
|
|
261
|
+
if (result.exitCode !== 0) {
|
|
262
|
+
throw new Error(`Failed to load Nx project graph for ${root}: ${result.stderr.trim()}`);
|
|
263
|
+
}
|
|
264
|
+
const parsed: unknown = JSON.parse(result.stdout);
|
|
265
|
+
if (!isNxProjects(parsed)) {
|
|
266
|
+
throw new Error(`Nx returned an invalid project graph for ${root}`);
|
|
248
267
|
}
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
268
|
+
return parsed;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function nxProjectsFromNodes(nodes: Readonly<Record<string, { data: NxProjectJson }>>): NxProjects {
|
|
272
|
+
const projects: Record<string, NxProjectJson> = {};
|
|
273
|
+
for (const [name, node] of Object.entries(nodes)) {
|
|
274
|
+
projects[name] = {
|
|
275
|
+
...node.data,
|
|
276
|
+
name: node.data.name ?? name,
|
|
277
|
+
targets: node.data.targets ?? {},
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
return projects;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function isNxProjects(value: unknown): value is NxProjects {
|
|
284
|
+
if (!isRecord(value)) return false;
|
|
285
|
+
return Object.values(value).every(
|
|
286
|
+
(project) =>
|
|
287
|
+
isRecord(project) &&
|
|
288
|
+
(project.name === undefined || typeof project.name === 'string') &&
|
|
289
|
+
(project.root === undefined || typeof project.root === 'string') &&
|
|
290
|
+
(project.targets === undefined || (isRecord(project.targets) && Object.values(project.targets).every(isRecord))),
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
295
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export function targetNamesFromProjects(projects: NxProjects): string[] {
|
|
299
|
+
const names = new Set<string>();
|
|
300
|
+
for (const project of Object.values(projects)) {
|
|
301
|
+
for (const targetName of targetNamesFromNxProjectJson(project)) {
|
|
302
|
+
names.add(targetName);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return [...names];
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export function projectTargetsFromNxProjects(projects: NxProjects): ProjectTargets[] {
|
|
309
|
+
return Object.entries(projects)
|
|
310
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
311
|
+
.map(([project, metadata]) => {
|
|
312
|
+
const targetDependencies = targetDependenciesFromNxProjectJson(metadata);
|
|
313
|
+
return {
|
|
314
|
+
project,
|
|
315
|
+
root: projectRootFromNxProjectJson(metadata),
|
|
316
|
+
targets: targetNamesFromNxProjectJson(metadata),
|
|
317
|
+
buildDependsOn: targetDependencies.get('build'),
|
|
318
|
+
targetDependencies,
|
|
319
|
+
targetExecutors: targetExecutorsFromNxProjectJson(metadata),
|
|
320
|
+
targetOptions: targetOptionsFromNxProjectJson(metadata),
|
|
321
|
+
targetOutputs: targetOutputsFromNxProjectJson(metadata),
|
|
322
|
+
targetScripts: targetScriptsFromNxProjectJson(metadata),
|
|
323
|
+
};
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export async function readProjectTargets(root: string): Promise<ProjectTargets[]> {
|
|
328
|
+
return projectTargetsFromNxProjects(await loadNxProjects(root));
|
|
260
329
|
}
|
|
261
330
|
|
|
262
331
|
export type { NxProjectJson, NxTargetConfig };
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { type ChromiumSetupDependencies, ensureChromium } from './index.js';
|
|
3
|
+
|
|
4
|
+
interface RunCall {
|
|
5
|
+
readonly command: string;
|
|
6
|
+
readonly args: string[];
|
|
7
|
+
readonly cwd: string;
|
|
8
|
+
readonly env?: Record<string, string>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function setupDependencies(
|
|
12
|
+
env: ChromiumSetupDependencies['env'],
|
|
13
|
+
existingPaths: readonly string[],
|
|
14
|
+
calls: RunCall[],
|
|
15
|
+
): ChromiumSetupDependencies {
|
|
16
|
+
const existing = new Set(existingPaths);
|
|
17
|
+
return {
|
|
18
|
+
env,
|
|
19
|
+
exists: (path) => existing.has(path),
|
|
20
|
+
run: async (command, args, cwd, runEnv) => {
|
|
21
|
+
calls.push({ command, args, cwd, ...(runEnv ? { env: runEnv } : {}) });
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('ensureChromium', () => {
|
|
27
|
+
it('uses preinstalled Chrome without invoking Playwright install on an ephemeral GitHub runner', async () => {
|
|
28
|
+
const calls: RunCall[] = [];
|
|
29
|
+
const executablePath = '/fixture/google-chrome';
|
|
30
|
+
|
|
31
|
+
const result = await ensureChromium(
|
|
32
|
+
'/workspace/package',
|
|
33
|
+
setupDependencies(
|
|
34
|
+
{ GITHUB_ACTIONS: 'true', PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH: executablePath },
|
|
35
|
+
[executablePath],
|
|
36
|
+
calls,
|
|
37
|
+
),
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
expect(result).toEqual({ mode: 'system', executablePath });
|
|
41
|
+
expect(calls).toEqual([]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('installs Chromium into the persistent host-runner cache', async () => {
|
|
45
|
+
const calls: RunCall[] = [];
|
|
46
|
+
|
|
47
|
+
const result = await ensureChromium(
|
|
48
|
+
'/workspace/package',
|
|
49
|
+
setupDependencies({ GITHUB_ACTIONS: 'true' }, ['/var/cache/ci'], calls),
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
expect(result).toEqual({ mode: 'persistent-cache', browserCachePath: '/var/cache/ci/ms-playwright' });
|
|
53
|
+
expect(calls).toEqual([
|
|
54
|
+
{
|
|
55
|
+
command: 'playwright',
|
|
56
|
+
args: ['install', 'chromium', '--only-shell'],
|
|
57
|
+
cwd: '/workspace/package',
|
|
58
|
+
env: { PLAYWRIGHT_BROWSERS_PATH: '/var/cache/ci/ms-playwright' },
|
|
59
|
+
},
|
|
60
|
+
]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('fails rather than downloading when an ephemeral GitHub runner has no system Chrome', async () => {
|
|
64
|
+
const calls: RunCall[] = [];
|
|
65
|
+
|
|
66
|
+
await expect(
|
|
67
|
+
ensureChromium('/workspace/package', setupDependencies({ GITHUB_ACTIONS: 'true' }, [], calls)),
|
|
68
|
+
).rejects.toThrow('Refusing to download');
|
|
69
|
+
expect(calls).toEqual([]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('honors an explicit developer cache outside GitHub Actions', async () => {
|
|
73
|
+
const calls: RunCall[] = [];
|
|
74
|
+
|
|
75
|
+
const result = await ensureChromium(
|
|
76
|
+
'/workspace/package',
|
|
77
|
+
setupDependencies({ PLAYWRIGHT_BROWSERS_PATH: '/tmp/browser-cache' }, [], calls),
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
expect(result).toEqual({ mode: 'developer-cache', browserCachePath: '/tmp/browser-cache' });
|
|
81
|
+
expect(calls).toEqual([
|
|
82
|
+
{
|
|
83
|
+
command: 'playwright',
|
|
84
|
+
args: ['install', 'chromium', '--only-shell'],
|
|
85
|
+
cwd: '/workspace/package',
|
|
86
|
+
env: { PLAYWRIGHT_BROWSERS_PATH: '/tmp/browser-cache' },
|
|
87
|
+
},
|
|
88
|
+
]);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { run } from '../lib/run.js';
|
|
3
|
+
|
|
4
|
+
const HOST_CACHE_ROOT = '/var/cache/ci';
|
|
5
|
+
const HOST_BROWSER_CACHE = `${HOST_CACHE_ROOT}/ms-playwright`;
|
|
6
|
+
const SYSTEM_CHROME_PATHS = [
|
|
7
|
+
'/usr/bin/google-chrome',
|
|
8
|
+
'/usr/bin/chromium-browser',
|
|
9
|
+
'/usr/bin/chromium',
|
|
10
|
+
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
11
|
+
] as const;
|
|
12
|
+
|
|
13
|
+
export interface ChromiumSetupDependencies {
|
|
14
|
+
readonly env: Readonly<Record<string, string | undefined>>;
|
|
15
|
+
readonly exists: (path: string) => boolean;
|
|
16
|
+
readonly run: (command: string, args: string[], cwd: string, env?: Record<string, string>) => Promise<void>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type ChromiumSetupResult =
|
|
20
|
+
| { readonly mode: 'persistent-cache'; readonly browserCachePath: string }
|
|
21
|
+
| { readonly mode: 'system'; readonly executablePath: string }
|
|
22
|
+
| { readonly mode: 'developer-cache'; readonly browserCachePath?: string };
|
|
23
|
+
|
|
24
|
+
const defaultDependencies: ChromiumSetupDependencies = {
|
|
25
|
+
env: process.env,
|
|
26
|
+
exists: existsSync,
|
|
27
|
+
run,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Ensure Chromium is available without polluting ephemeral GitHub runners.
|
|
32
|
+
* Persistent host runners and developer machines may install into their
|
|
33
|
+
* respective caches; ephemeral GitHub runners must use their image's browser.
|
|
34
|
+
*/
|
|
35
|
+
export async function ensureChromium(
|
|
36
|
+
cwd = process.cwd(),
|
|
37
|
+
dependencies: ChromiumSetupDependencies = defaultDependencies,
|
|
38
|
+
): Promise<ChromiumSetupResult> {
|
|
39
|
+
const { env, exists, run: runCommand } = dependencies;
|
|
40
|
+
const githubActions = env.GITHUB_ACTIONS === 'true';
|
|
41
|
+
|
|
42
|
+
if (githubActions && exists(HOST_CACHE_ROOT)) {
|
|
43
|
+
await runCommand('playwright', ['install', 'chromium', '--only-shell'], cwd, {
|
|
44
|
+
PLAYWRIGHT_BROWSERS_PATH: HOST_BROWSER_CACHE,
|
|
45
|
+
});
|
|
46
|
+
console.log(`Chromium ready in persistent cache: ${HOST_BROWSER_CACHE}`);
|
|
47
|
+
return { mode: 'persistent-cache', browserCachePath: HOST_BROWSER_CACHE };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (githubActions) {
|
|
51
|
+
const candidates = [env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH, ...SYSTEM_CHROME_PATHS].filter(
|
|
52
|
+
(path): path is string => typeof path === 'string' && path.length > 0,
|
|
53
|
+
);
|
|
54
|
+
const executablePath = candidates.find(exists);
|
|
55
|
+
if (!executablePath) {
|
|
56
|
+
throw new Error(
|
|
57
|
+
`GitHub-hosted runner has no preinstalled Chromium. Refusing to download; searched: ${candidates.join(', ')}`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
console.log(`Using preinstalled Chromium: ${executablePath}`);
|
|
61
|
+
return { mode: 'system', executablePath };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const browserCachePath = env.PLAYWRIGHT_BROWSERS_PATH;
|
|
65
|
+
await runCommand(
|
|
66
|
+
'playwright',
|
|
67
|
+
['install', 'chromium', '--only-shell'],
|
|
68
|
+
cwd,
|
|
69
|
+
browserCachePath ? { PLAYWRIGHT_BROWSERS_PATH: browserCachePath } : undefined,
|
|
70
|
+
);
|
|
71
|
+
console.log(browserCachePath ? `Chromium ready in configured cache: ${browserCachePath}` : 'Chromium ready.');
|
|
72
|
+
return browserCachePath ? { mode: 'developer-cache', browserCachePath } : { mode: 'developer-cache' };
|
|
73
|
+
}
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
bootstrapNpmPackages,
|
|
5
5
|
NPM_BOOTSTRAP_DIST_TAG,
|
|
6
6
|
NPM_BOOTSTRAP_VERSION,
|
|
7
|
+
NPM_BOOTSTRAP_VISIBILITY_TIMEOUT_MS,
|
|
7
8
|
} from '../bootstrap-npm-packages.js';
|
|
8
9
|
import type { ReleasePackageInfo } from '../core.js';
|
|
9
10
|
|
|
@@ -70,6 +71,45 @@ describe('bootstrap npm packages', () => {
|
|
|
70
71
|
expect(shell.prompts).toEqual([]);
|
|
71
72
|
});
|
|
72
73
|
|
|
74
|
+
it('uploads every placeholder before polling all pending packages with backoff', async () => {
|
|
75
|
+
const shell = new RecordingBootstrapShell({
|
|
76
|
+
packages: [stable, missing],
|
|
77
|
+
existing: [],
|
|
78
|
+
visibleAfterChecks: { [stable.name]: 2, [missing.name]: 3 },
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
await bootstrapNpmPackages(shell, { dryRun: false, skipLogin: true, packages: [], otp: '111222' });
|
|
82
|
+
|
|
83
|
+
expect(shell.events).toEqual([
|
|
84
|
+
`publish:${stable.name}`,
|
|
85
|
+
`publish:${missing.name}`,
|
|
86
|
+
`view:${stable.name}:${NPM_BOOTSTRAP_VERSION}`,
|
|
87
|
+
`view:${missing.name}:${NPM_BOOTSTRAP_VERSION}`,
|
|
88
|
+
'wait:1000',
|
|
89
|
+
`view:${stable.name}:${NPM_BOOTSTRAP_VERSION}`,
|
|
90
|
+
`view:${missing.name}:${NPM_BOOTSTRAP_VERSION}`,
|
|
91
|
+
'wait:2000',
|
|
92
|
+
`view:${missing.name}:${NPM_BOOTSTRAP_VERSION}`,
|
|
93
|
+
]);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('times out after bounded backoff without suggesting another bootstrap', async () => {
|
|
97
|
+
const shell = new RecordingBootstrapShell({
|
|
98
|
+
packages: [missing],
|
|
99
|
+
existing: [],
|
|
100
|
+
visibleAfterChecks: { [missing.name]: Number.POSITIVE_INFINITY },
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
await expect(
|
|
104
|
+
bootstrapNpmPackages(shell, { dryRun: false, skipLogin: true, packages: [], otp: '111222' }),
|
|
105
|
+
).rejects.toThrow('Retry smoo release trust-publisher later; do not bootstrap again.');
|
|
106
|
+
|
|
107
|
+
const waited = shell.events
|
|
108
|
+
.filter((event) => event.startsWith('wait:'))
|
|
109
|
+
.reduce((total, event) => total + Number(event.slice('wait:'.length)), 0);
|
|
110
|
+
expect(waited).toBe(NPM_BOOTSTRAP_VISIBILITY_TIMEOUT_MS);
|
|
111
|
+
});
|
|
112
|
+
|
|
73
113
|
it('rejects unknown package selections before npm login', async () => {
|
|
74
114
|
const shell = new RecordingBootstrapShell({ packages: [stable], existing: [] });
|
|
75
115
|
|
|
@@ -82,6 +122,7 @@ describe('bootstrap npm packages', () => {
|
|
|
82
122
|
});
|
|
83
123
|
|
|
84
124
|
class RecordingBootstrapShell implements BootstrapNpmPackagesShell<ReleasePackageInfo> {
|
|
125
|
+
readonly events: string[] = [];
|
|
85
126
|
readonly logs: string[] = [];
|
|
86
127
|
readonly published: Array<{ name: string; otp: string }> = [];
|
|
87
128
|
readonly prompts: string[] = [];
|
|
@@ -89,11 +130,19 @@ class RecordingBootstrapShell implements BootstrapNpmPackagesShell<ReleasePackag
|
|
|
89
130
|
private readonly packages: ReleasePackageInfo[];
|
|
90
131
|
private readonly existing: Set<string>;
|
|
91
132
|
private readonly otps: string[];
|
|
92
|
-
|
|
93
|
-
|
|
133
|
+
private readonly visibilityChecks = new Map<string, number>();
|
|
134
|
+
private readonly visibleAfterChecks: Readonly<Record<string, number>>;
|
|
135
|
+
|
|
136
|
+
constructor(options: {
|
|
137
|
+
packages: ReleasePackageInfo[];
|
|
138
|
+
existing: string[];
|
|
139
|
+
otps?: string[];
|
|
140
|
+
visibleAfterChecks?: Record<string, number>;
|
|
141
|
+
}) {
|
|
94
142
|
this.packages = options.packages;
|
|
95
143
|
this.existing = new Set(options.existing);
|
|
96
144
|
this.otps = [...(options.otps ?? [])];
|
|
145
|
+
this.visibleAfterChecks = options.visibleAfterChecks ?? {};
|
|
97
146
|
}
|
|
98
147
|
|
|
99
148
|
listReleasePackages(): ReleasePackageInfo[] {
|
|
@@ -104,11 +153,19 @@ class RecordingBootstrapShell implements BootstrapNpmPackagesShell<ReleasePackag
|
|
|
104
153
|
return this.existing.has(name);
|
|
105
154
|
}
|
|
106
155
|
|
|
156
|
+
async packageVersionExists(name: string, version: string): Promise<boolean> {
|
|
157
|
+
this.events.push(`view:${name}:${version}`);
|
|
158
|
+
const checks = (this.visibilityChecks.get(name) ?? 0) + 1;
|
|
159
|
+
this.visibilityChecks.set(name, checks);
|
|
160
|
+
return checks >= (this.visibleAfterChecks[name] ?? 1);
|
|
161
|
+
}
|
|
162
|
+
|
|
107
163
|
async login(): Promise<void> {
|
|
108
164
|
this.logins += 1;
|
|
109
165
|
}
|
|
110
166
|
|
|
111
167
|
async publishPlaceholder(pkg: ReleasePackageInfo, env?: Record<string, string>): Promise<void> {
|
|
168
|
+
this.events.push(`publish:${pkg.name}`);
|
|
112
169
|
this.published.push({ name: pkg.name, otp: env?.NPM_CONFIG_OTP ?? '' });
|
|
113
170
|
}
|
|
114
171
|
|
|
@@ -121,6 +178,10 @@ class RecordingBootstrapShell implements BootstrapNpmPackagesShell<ReleasePackag
|
|
|
121
178
|
return otp;
|
|
122
179
|
}
|
|
123
180
|
|
|
181
|
+
async wait(milliseconds: number): Promise<void> {
|
|
182
|
+
this.events.push(`wait:${milliseconds}`);
|
|
183
|
+
}
|
|
184
|
+
|
|
124
185
|
log(message: string): void {
|
|
125
186
|
this.logs.push(message);
|
|
126
187
|
}
|
|
@@ -2,6 +2,9 @@ import type { ReleasePackageInfo } from './core.js';
|
|
|
2
2
|
|
|
3
3
|
export const NPM_BOOTSTRAP_VERSION = '0.0.0-bootstrap.0';
|
|
4
4
|
export const NPM_BOOTSTRAP_DIST_TAG = 'bootstrap';
|
|
5
|
+
export const NPM_BOOTSTRAP_VISIBILITY_TIMEOUT_MS = 120_000;
|
|
6
|
+
|
|
7
|
+
const NPM_BOOTSTRAP_POLL_DELAYS_MS = [0, 1_000, 2_000, 4_000, 8_000, 15_000, 30_000, 30_000, 30_000] as const;
|
|
5
8
|
|
|
6
9
|
export interface BootstrapNpmPackagesOptions {
|
|
7
10
|
dryRun: boolean;
|
|
@@ -13,9 +16,11 @@ export interface BootstrapNpmPackagesOptions {
|
|
|
13
16
|
export interface BootstrapNpmPackagesShell<Package extends ReleasePackageInfo = ReleasePackageInfo> {
|
|
14
17
|
listReleasePackages(): Package[];
|
|
15
18
|
packageExists(name: string): Promise<boolean>;
|
|
19
|
+
packageVersionExists(name: string, version: string): Promise<boolean>;
|
|
16
20
|
login(): Promise<void>;
|
|
17
21
|
publishPlaceholder(pkg: Package, env?: Record<string, string>): Promise<void>;
|
|
18
22
|
promptOtp(packageName: string): Promise<string>;
|
|
23
|
+
wait(milliseconds: number): Promise<void>;
|
|
19
24
|
log(message: string): void;
|
|
20
25
|
}
|
|
21
26
|
|
|
@@ -54,15 +59,44 @@ export async function bootstrapNpmPackages<Package extends ReleasePackageInfo>(
|
|
|
54
59
|
if (!options.skipLogin) {
|
|
55
60
|
await shell.login();
|
|
56
61
|
}
|
|
62
|
+
// Upload every placeholder before waiting for npm registry propagation. Keeping the
|
|
63
|
+
// phases separate prevents one slow package from blocking the remaining uploads.
|
|
57
64
|
for (const pkg of missing) {
|
|
58
65
|
shell.log(`${pkg.name}: publishing npm placeholder.`);
|
|
59
66
|
const otp = options.otp ?? (await shell.promptOtp(pkg.name));
|
|
60
67
|
await shell.publishPlaceholder(pkg, { NPM_CONFIG_OTP: otp });
|
|
61
68
|
}
|
|
69
|
+
|
|
70
|
+
await waitForPublishedPackages(shell, missing);
|
|
62
71
|
shell.log('Bootstrap complete. Run smoo release trust-publisher before the first CI publish.');
|
|
63
72
|
return missing;
|
|
64
73
|
}
|
|
65
74
|
|
|
75
|
+
async function waitForPublishedPackages<Package extends ReleasePackageInfo>(
|
|
76
|
+
shell: BootstrapNpmPackagesShell<Package>,
|
|
77
|
+
packages: readonly Package[],
|
|
78
|
+
): Promise<void> {
|
|
79
|
+
let pending = [...packages];
|
|
80
|
+
for (const delayMs of NPM_BOOTSTRAP_POLL_DELAYS_MS) {
|
|
81
|
+
if (delayMs > 0) {
|
|
82
|
+
await shell.wait(delayMs);
|
|
83
|
+
}
|
|
84
|
+
const visible = await Promise.all(
|
|
85
|
+
pending.map((pkg) => shell.packageVersionExists(pkg.name, NPM_BOOTSTRAP_VERSION)),
|
|
86
|
+
);
|
|
87
|
+
pending = pending.filter((_, index) => !visible[index]);
|
|
88
|
+
if (pending.length === 0) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
throw new Error(
|
|
94
|
+
`Bootstrap uploads succeeded, but npm did not expose these packages after ${NPM_BOOTSTRAP_VISIBILITY_TIMEOUT_MS / 1_000} seconds: ${pending
|
|
95
|
+
.map((pkg) => pkg.name)
|
|
96
|
+
.join(', ')}. Retry smoo release trust-publisher later; do not bootstrap again.`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
66
100
|
function selectedReleasePackages<Package extends ReleasePackageInfo>(
|
|
67
101
|
packages: Package[],
|
|
68
102
|
selections: string[],
|