@astryxdesign/cli 0.4.2-canary.86a7f17 → 0.4.2-canary.8b07cb9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file Direct-API tests for `themeBuild`'s `iconsSpecifier` option (#4620).
|
|
5
|
+
*
|
|
6
|
+
* The CLI surface of `--icons-specifier` is pinned in
|
|
7
|
+
* clients/cli/commands/build-theme.icons-specifier.test.mjs; these tests pin
|
|
8
|
+
* the programmatic surface that watch mode, editor tooling, and build scripts
|
|
9
|
+
* call directly: the option reaches the emitted module, its absence leaves the
|
|
10
|
+
* scraped specifier byte-for-byte alone, and `check` mode compares the
|
|
11
|
+
* specifier-bearing text like any other generated byte — outputs built with a
|
|
12
|
+
* different specifier than the one being checked against are stale, not clean.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import {describe, it, expect, beforeEach, afterEach} from 'vitest';
|
|
16
|
+
import * as fs from 'node:fs';
|
|
17
|
+
import * as path from 'node:path';
|
|
18
|
+
import * as os from 'node:os';
|
|
19
|
+
import {themeBuild} from './build.mjs';
|
|
20
|
+
|
|
21
|
+
let tmpDir;
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
tmpDir = fs.mkdtempSync(
|
|
24
|
+
path.join(os.tmpdir(), 'astryx-api-icons-specifier-'),
|
|
25
|
+
);
|
|
26
|
+
});
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
fs.rmSync(tmpDir, {recursive: true, force: true});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Write a theme source (plus a loadable icons source beside it) under
|
|
33
|
+
* `<tmpDir>/src/`. The registry is a plain object — the emit path only
|
|
34
|
+
* re-exports it, so no React is needed.
|
|
35
|
+
*/
|
|
36
|
+
function writeIconTheme({withIcons = true, name = 'icotheme'} = {}) {
|
|
37
|
+
const srcDir = path.join(tmpDir, 'src');
|
|
38
|
+
fs.mkdirSync(srcDir, {recursive: true});
|
|
39
|
+
fs.writeFileSync(
|
|
40
|
+
path.join(srcDir, 'icons.ts'),
|
|
41
|
+
`export const myIcons = { close: 'x' };\n`,
|
|
42
|
+
);
|
|
43
|
+
const iconLines = withIcons ? [`import { myIcons } from './icons';`] : [];
|
|
44
|
+
fs.writeFileSync(
|
|
45
|
+
path.join(srcDir, `${name}.ts`),
|
|
46
|
+
[
|
|
47
|
+
...iconLines,
|
|
48
|
+
`export default { name: '${name}', tokens: { '--color-bg': '#fff' }${
|
|
49
|
+
withIcons ? ', icons: myIcons' : ''
|
|
50
|
+
} };`,
|
|
51
|
+
'',
|
|
52
|
+
].join('\n'),
|
|
53
|
+
);
|
|
54
|
+
return `src/${name}.ts`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function builtModule(name = 'icotheme') {
|
|
58
|
+
return fs.readFileSync(path.join(tmpDir, 'dist', `${name}.js`), 'utf8');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
describe('themeBuild({iconsSpecifier}) — direct API', () => {
|
|
62
|
+
it('emits the declared specifier into the generated module', async () => {
|
|
63
|
+
const file = writeIconTheme();
|
|
64
|
+
|
|
65
|
+
const result = await themeBuild(
|
|
66
|
+
file,
|
|
67
|
+
{out: 'dist/theme.css', iconsSpecifier: './icons.mjs'},
|
|
68
|
+
{cwd: tmpDir},
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
expect(result?.type).toBe('theme.build');
|
|
72
|
+
const js = builtModule();
|
|
73
|
+
expect(js).toContain(`import { myIcons } from "./icons.mjs";`);
|
|
74
|
+
// The registry re-export survives the override.
|
|
75
|
+
expect(js).toContain('export { myIcons }');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('emits the scraped specifier unchanged when the option is omitted', async () => {
|
|
79
|
+
const file = writeIconTheme();
|
|
80
|
+
|
|
81
|
+
const result = await themeBuild(
|
|
82
|
+
file,
|
|
83
|
+
{out: 'dist/theme.css'},
|
|
84
|
+
{cwd: tmpDir},
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
expect(result?.type).toBe('theme.build');
|
|
88
|
+
expect(builtModule()).toContain(`import { myIcons } from './icons';`);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('is inert for a theme with no icons field', async () => {
|
|
92
|
+
const file = writeIconTheme({withIcons: false});
|
|
93
|
+
|
|
94
|
+
const result = await themeBuild(
|
|
95
|
+
file,
|
|
96
|
+
{out: 'dist/theme.css', iconsSpecifier: './icons.mjs'},
|
|
97
|
+
{cwd: tmpDir},
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
expect(result?.type).toBe('theme.build');
|
|
101
|
+
// The header comment's usage example mentions imports; only a real
|
|
102
|
+
// statement (line-leading `import`) would be a leak.
|
|
103
|
+
const js = builtModule();
|
|
104
|
+
expect(js).not.toMatch(/^import /m);
|
|
105
|
+
expect(js).not.toContain('icons.mjs');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('check mode is clean against outputs built with the same specifier', async () => {
|
|
109
|
+
const file = writeIconTheme();
|
|
110
|
+
await themeBuild(
|
|
111
|
+
file,
|
|
112
|
+
{out: 'dist/theme.css', iconsSpecifier: './icons.mjs'},
|
|
113
|
+
{cwd: tmpDir},
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
const result = await themeBuild(
|
|
117
|
+
file,
|
|
118
|
+
{out: 'dist/theme.css', check: true, iconsSpecifier: './icons.mjs'},
|
|
119
|
+
{cwd: tmpDir},
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
expect(result?.type).toBe('theme.build.check');
|
|
123
|
+
expect(result?.data.upToDate).toBe(true);
|
|
124
|
+
expect(result?.data.stale).toEqual([]);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('check mode reports outputs stale when the specifier differs', async () => {
|
|
128
|
+
const file = writeIconTheme();
|
|
129
|
+
await themeBuild(
|
|
130
|
+
file,
|
|
131
|
+
{out: 'dist/theme.css', iconsSpecifier: './icons.mjs'},
|
|
132
|
+
{cwd: tmpDir},
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
// Checking without the option regenerates with the scraped './icons' —
|
|
136
|
+
// different bytes than the on-disk module, so the check must flag it.
|
|
137
|
+
const result = await themeBuild(
|
|
138
|
+
file,
|
|
139
|
+
{out: 'dist/theme.css', check: true},
|
|
140
|
+
{cwd: tmpDir},
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
expect(result?.type).toBe('theme.build.check');
|
|
144
|
+
expect(result?.data.upToDate).toBe(false);
|
|
145
|
+
expect(result?.data.stale.some(entry => entry.reason === 'outdated')).toBe(
|
|
146
|
+
true,
|
|
147
|
+
);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
@@ -15,15 +15,34 @@
|
|
|
15
15
|
* declared rather than inferred. Absent the flag, output is byte-for-byte what
|
|
16
16
|
* it was before, which keeps the default no-`--out` flow — where the neighbour
|
|
17
17
|
* is an uncompiled `icons.tsx` that only a bundler can resolve — working.
|
|
18
|
+
*
|
|
19
|
+
* The spawned-process block at the bottom pins what only real processes can
|
|
20
|
+
* prove: the emitted module actually loads under Node ESM, and the watch
|
|
21
|
+
* loop's child re-invocations carry the flag to every rebuild.
|
|
18
22
|
*/
|
|
19
23
|
|
|
20
24
|
import {describe, it, expect, beforeAll, beforeEach, afterEach} from 'vitest';
|
|
25
|
+
import {spawn, spawnSync} from 'node:child_process';
|
|
21
26
|
import * as fs from 'node:fs';
|
|
22
27
|
import * as path from 'node:path';
|
|
23
28
|
import * as os from 'node:os';
|
|
29
|
+
import {fileURLToPath, pathToFileURL} from 'node:url';
|
|
24
30
|
import {ensureCoreBuilt} from './ensure-core-built.mjs';
|
|
25
31
|
import {runCli} from '../../../test-utils/run-cli.mjs';
|
|
26
32
|
|
|
33
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
34
|
+
const CLI_BIN = path.resolve(__dirname, '../bin/astryx.mjs');
|
|
35
|
+
|
|
36
|
+
/** Poll until `predicate()` is true or the timeout elapses. */
|
|
37
|
+
async function waitFor(predicate, {timeout = 20000, interval = 100} = {}) {
|
|
38
|
+
const start = Date.now();
|
|
39
|
+
for (;;) {
|
|
40
|
+
if (predicate()) return true;
|
|
41
|
+
if (Date.now() - start > timeout) return false;
|
|
42
|
+
await new Promise(r => setTimeout(r, interval));
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
27
46
|
/**
|
|
28
47
|
* The emitted icon import, or null. Reads the statement rather than the whole
|
|
29
48
|
* file: the `@generated` header quotes the source filename and a usage example,
|
|
@@ -142,13 +161,7 @@ describe('theme build --icons-specifier', () => {
|
|
|
142
161
|
const relativeTheme = path.relative(project, themeFile);
|
|
143
162
|
|
|
144
163
|
const built = await runCli(
|
|
145
|
-
[
|
|
146
|
-
'theme',
|
|
147
|
-
'build',
|
|
148
|
-
relativeTheme,
|
|
149
|
-
'--icons-specifier',
|
|
150
|
-
'./icons.mjs',
|
|
151
|
-
],
|
|
164
|
+
['theme', 'build', relativeTheme, '--icons-specifier', './icons.mjs'],
|
|
152
165
|
project,
|
|
153
166
|
);
|
|
154
167
|
expect(built.code).toBe(0);
|
|
@@ -223,3 +236,114 @@ describe('theme build --icons-specifier', () => {
|
|
|
223
236
|
expect(generated).not.toContain('icons:');
|
|
224
237
|
});
|
|
225
238
|
});
|
|
239
|
+
|
|
240
|
+
describe('theme build --icons-specifier (spawned processes)', () => {
|
|
241
|
+
it('emits a module Node can actually load', async () => {
|
|
242
|
+
const project = path.join(tmpDir, 'project');
|
|
243
|
+
const themeFile = writeThemeWithIcons(project, 'loadable');
|
|
244
|
+
|
|
245
|
+
const result = await runCli(
|
|
246
|
+
[
|
|
247
|
+
'theme',
|
|
248
|
+
'build',
|
|
249
|
+
path.relative(project, themeFile),
|
|
250
|
+
'--icons-specifier',
|
|
251
|
+
'./icons.mjs',
|
|
252
|
+
],
|
|
253
|
+
project,
|
|
254
|
+
);
|
|
255
|
+
expect(result.code).toBe(0);
|
|
256
|
+
|
|
257
|
+
// The text assertions above prove the emitted line; only a real Node
|
|
258
|
+
// process proves the module resolves and evaluates. That distinction is
|
|
259
|
+
// the regression #4620 shipped: every byte existed, none of them loaded.
|
|
260
|
+
const builtUrl = pathToFileURL(path.join(project, 'loadable.js'));
|
|
261
|
+
const probe = spawnSync(
|
|
262
|
+
process.execPath,
|
|
263
|
+
[
|
|
264
|
+
'--input-type=module',
|
|
265
|
+
'-e',
|
|
266
|
+
`const m = await import(${JSON.stringify(builtUrl.href)});` +
|
|
267
|
+
`if (m.loadableTheme?.name !== 'loadable') throw new Error('bad theme export');` +
|
|
268
|
+
`if (typeof m.testIcons !== 'object') throw new Error('bad registry export');`,
|
|
269
|
+
],
|
|
270
|
+
{encoding: 'utf8'},
|
|
271
|
+
);
|
|
272
|
+
expect(probe.stderr).toBe('');
|
|
273
|
+
expect(probe.status).toBe(0);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it('watch mode forwards the flag to every rebuild', async () => {
|
|
277
|
+
const project = path.join(tmpDir, 'project');
|
|
278
|
+
const themeFile = writeThemeWithIcons(project, 'watched');
|
|
279
|
+
const cssFile = path.join(project, 'out', 'theme.css');
|
|
280
|
+
const builtFile = path.join(project, 'out', 'watched.js');
|
|
281
|
+
const declaredImport = 'import { testIcons } from "./icons.mjs";';
|
|
282
|
+
|
|
283
|
+
const child = spawn(
|
|
284
|
+
process.execPath,
|
|
285
|
+
[
|
|
286
|
+
CLI_BIN,
|
|
287
|
+
'theme',
|
|
288
|
+
'build',
|
|
289
|
+
path.relative(project, themeFile),
|
|
290
|
+
'--out',
|
|
291
|
+
'out/theme.css',
|
|
292
|
+
'--icons-specifier',
|
|
293
|
+
'./icons.mjs',
|
|
294
|
+
'--watch',
|
|
295
|
+
],
|
|
296
|
+
{cwd: project, env: {...process.env, FORCE_COLOR: '0'}},
|
|
297
|
+
);
|
|
298
|
+
let output = '';
|
|
299
|
+
child.stdout.on('data', d => (output += d.toString()));
|
|
300
|
+
child.stderr.on('data', d => (output += d.toString()));
|
|
301
|
+
|
|
302
|
+
try {
|
|
303
|
+
// Initial build: the declared specifier reaches the module.
|
|
304
|
+
expect(await waitFor(() => fs.existsSync(cssFile))).toBe(true);
|
|
305
|
+
expect(await waitFor(() => /Watching/i.test(output))).toBe(true);
|
|
306
|
+
expect(iconImportLine(fs.readFileSync(builtFile, 'utf8'))).toBe(
|
|
307
|
+
declaredImport,
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
// Rebuilds run through a child re-invocation of `theme build`, so the
|
|
311
|
+
// flag reaches them only if the watch loop forwards it. Change a token
|
|
312
|
+
// and wait for the rebuilt CSS. fs.watch delivery is best-effort under
|
|
313
|
+
// load, so re-touch until the rebuild shows up (idempotent write).
|
|
314
|
+
const touched =
|
|
315
|
+
`import {testIcons} from './icons';\n` +
|
|
316
|
+
`export default {\n` +
|
|
317
|
+
` name: "watched",\n` +
|
|
318
|
+
` icons: testIcons,\n` +
|
|
319
|
+
` tokens: {'--color-bg': '#0a0b0c'},\n` +
|
|
320
|
+
`};\n`;
|
|
321
|
+
fs.writeFileSync(themeFile, touched);
|
|
322
|
+
const rebuilt = await waitFor(() => {
|
|
323
|
+
try {
|
|
324
|
+
if (fs.readFileSync(cssFile, 'utf-8').includes('#0a0b0c'))
|
|
325
|
+
return true;
|
|
326
|
+
} catch {
|
|
327
|
+
// CSS mid-write; fall through to re-touch.
|
|
328
|
+
}
|
|
329
|
+
try {
|
|
330
|
+
fs.writeFileSync(themeFile, touched);
|
|
331
|
+
} catch {
|
|
332
|
+
// Retried on the next poll.
|
|
333
|
+
}
|
|
334
|
+
return false;
|
|
335
|
+
});
|
|
336
|
+
expect(rebuilt).toBe(true);
|
|
337
|
+
|
|
338
|
+
// The regenerated module still carries the declared specifier — the
|
|
339
|
+
// forwarding is what this test pins. A watch loop that dropped the flag
|
|
340
|
+
// would regenerate with the scraped './icons' here and ship the #4620
|
|
341
|
+
// bytes on every save.
|
|
342
|
+
expect(iconImportLine(fs.readFileSync(builtFile, 'utf8'))).toBe(
|
|
343
|
+
declaredImport,
|
|
344
|
+
);
|
|
345
|
+
} finally {
|
|
346
|
+
child.kill('SIGINT');
|
|
347
|
+
}
|
|
348
|
+
}, 60_000);
|
|
349
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@astryxdesign/cli",
|
|
3
|
-
"version": "0.4.2-canary.
|
|
3
|
+
"version": "0.4.2-canary.8b07cb9",
|
|
4
4
|
"displayName": "CLI",
|
|
5
5
|
"description": "Scaffold projects, browse templates, generate themes, and get agent-ready docs from the command line.",
|
|
6
6
|
"author": "Meta Open Source",
|
|
@@ -84,10 +84,10 @@
|
|
|
84
84
|
"zod": "^4.4.3"
|
|
85
85
|
},
|
|
86
86
|
"peerDependencies": {
|
|
87
|
-
"@astryxdesign/charts": "0.4.2-canary.
|
|
88
|
-
"@astryxdesign/core": "0.4.2-canary.
|
|
89
|
-
"@astryxdesign/lab": "0.4.2-canary.
|
|
90
|
-
"@astryxdesign/theme-neutral": "0.4.2-canary.
|
|
87
|
+
"@astryxdesign/charts": "0.4.2-canary.8b07cb9",
|
|
88
|
+
"@astryxdesign/core": "0.4.2-canary.8b07cb9",
|
|
89
|
+
"@astryxdesign/lab": "0.4.2-canary.8b07cb9",
|
|
90
|
+
"@astryxdesign/theme-neutral": "0.4.2-canary.8b07cb9",
|
|
91
91
|
"gpt-tokenizer": "^3.4.0"
|
|
92
92
|
},
|
|
93
93
|
"peerDependenciesMeta": {
|
|
@@ -105,10 +105,10 @@
|
|
|
105
105
|
}
|
|
106
106
|
},
|
|
107
107
|
"devDependencies": {
|
|
108
|
-
"@astryxdesign/charts": "0.4.2-canary.
|
|
109
|
-
"@astryxdesign/core": "0.4.2-canary.
|
|
110
|
-
"@astryxdesign/lab": "0.4.2-canary.
|
|
111
|
-
"@astryxdesign/theme-neutral": "0.4.2-canary.
|
|
108
|
+
"@astryxdesign/charts": "0.4.2-canary.8b07cb9",
|
|
109
|
+
"@astryxdesign/core": "0.4.2-canary.8b07cb9",
|
|
110
|
+
"@astryxdesign/lab": "0.4.2-canary.8b07cb9",
|
|
111
|
+
"@astryxdesign/theme-neutral": "0.4.2-canary.8b07cb9",
|
|
112
112
|
"gpt-tokenizer": "^3.4.0"
|
|
113
113
|
},
|
|
114
114
|
"scripts": {
|