@astryxdesign/cli 0.6.5-canary.1bbbf63 → 0.6.5-canary.1d8fca6

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.
@@ -4,7 +4,9 @@
4
4
  /**
5
5
  * Collect the effective handler set in delivery order: project config first,
6
6
  * then integrations in config order. Valid handlers dedupe by handle identity;
7
- * malformed integration exports retain their position as failed deliveries.
7
+ * malformed integration exports and integrations that failed to load retain
8
+ * their position as failed deliveries. An unloadable integration may export a
9
+ * handler, so it never counts as an empty set that enables the fallback.
8
10
  *
9
11
  * @param {Project} project
10
12
  * @returns {HandlerEntry[]}
@@ -189,7 +189,9 @@ async function selectTarget(project, component, packageName) {
189
189
  /**
190
190
  * Collect the effective handler set in delivery order: project config first,
191
191
  * then integrations in config order. Valid handlers dedupe by handle identity;
192
- * malformed integration exports retain their position as failed deliveries.
192
+ * malformed integration exports and integrations that failed to load retain
193
+ * their position as failed deliveries. An unloadable integration may export a
194
+ * handler, so it never counts as an empty set that enables the fallback.
193
195
  *
194
196
  * @param {Project} project
195
197
  * @returns {HandlerEntry[]}
@@ -214,7 +216,15 @@ export function collectGapReportHandlers(project) {
214
216
  }
215
217
 
216
218
  for (const integration of project.loadedIntegrations) {
217
- if (integration.__loadError) continue;
219
+ if (integration.__loadError) {
220
+ entries.push({
221
+ kind: 'malformed',
222
+ handlerType: 'integration',
223
+ handlerName: integration.name,
224
+ error: `Integration failed to load, so it could not receive this report: ${integration.__loadError}. Fix the package or remove it from integrations in astryx.config, then rerun.`,
225
+ });
226
+ continue;
227
+ }
218
228
  if (integration.__gapReportError) {
219
229
  entries.push({
220
230
  kind: 'malformed',
@@ -105,6 +105,45 @@ function addIntegration({
105
105
  return {dir};
106
106
  }
107
107
 
108
+ /**
109
+ * Add an integration whose module throws on import, so it never loads.
110
+ * @param {string} name
111
+ */
112
+ function addBrokenIntegration(name) {
113
+ const dir = packageDir(name);
114
+ fs.mkdirSync(dir, {recursive: true});
115
+ fs.writeFileSync(
116
+ path.join(dir, 'package.json'),
117
+ JSON.stringify({name, version: '1.0.0', type: 'module'}),
118
+ );
119
+ fs.writeFileSync(
120
+ path.join(dir, 'astryx.integration.mjs'),
121
+ "throw new Error('intentional integration load failure');\n",
122
+ );
123
+ }
124
+
125
+ /**
126
+ * Put a fake `gh` first on PATH that records its arguments if it is called.
127
+ * @returns {string} the file the fake writes when invoked
128
+ */
129
+ function stubGh() {
130
+ const binDir = path.join(projectDir, 'bin');
131
+ const argsFile = path.join(projectDir, 'gh-args.json');
132
+ fs.mkdirSync(binDir, {recursive: true});
133
+ fs.writeFileSync(
134
+ path.join(binDir, 'gh'),
135
+ `#!/usr/bin/env node
136
+ import fs from 'node:fs';
137
+ fs.writeFileSync(${JSON.stringify(argsFile)}, JSON.stringify(process.argv.slice(2)));
138
+ console.log('https://github.com/acme/core/issues/42');
139
+ `,
140
+ {mode: 0o755},
141
+ );
142
+ fs.chmodSync(path.join(binDir, 'gh'), 0o755);
143
+ vi.stubEnv('PATH', `${binDir}${path.delimiter}${process.env.PATH ?? ''}`);
144
+ return argsFile;
145
+ }
146
+
108
147
  const reportOptions = {
109
148
  category: 'missing_variant',
110
149
  reason: 'Need a compact size',
@@ -681,6 +720,73 @@ describe('gapReport handler isolation', () => {
681
720
  });
682
721
  });
683
722
 
723
+ it('an integration that fails to load is a failed delivery, not a missing handler', async () => {
724
+ addBrokenIntegration('@test/broken');
725
+ configure(['@test/broken'], {
726
+ issuesUrl: 'https://github.com/acme/core/issues/new',
727
+ });
728
+
729
+ const result = await gapReport('General', {
730
+ ...reportOptions,
731
+ cwd: projectDir,
732
+ });
733
+
734
+ expect(result.data.status).toBe('failed');
735
+ expect(result.data.deliveries).toEqual([
736
+ {
737
+ handlerType: 'integration',
738
+ handler: '@test/broken',
739
+ audience: null,
740
+ status: 'failed',
741
+ url: null,
742
+ message: expect.stringContaining(
743
+ 'intentional integration load failure',
744
+ ),
745
+ },
746
+ ]);
747
+ });
748
+
749
+ it('an integration that fails to load keeps the public fallback off, even with consent', async () => {
750
+ addBrokenIntegration('@test/broken');
751
+ configure(['@test/broken'], {
752
+ issuesUrl: 'https://github.com/acme/core/issues/new',
753
+ });
754
+ const argsFile = stubGh();
755
+
756
+ const result = await gapReport('General', {
757
+ ...reportOptions,
758
+ confirmPublic: true,
759
+ cwd: projectDir,
760
+ });
761
+
762
+ expect(result.data.status).toBe('failed');
763
+ expect(result.data.deliveries.map(d => d.handlerType)).toEqual([
764
+ 'integration',
765
+ ]);
766
+ expect(fs.existsSync(argsFile)).toBe(false);
767
+ });
768
+
769
+ it('an integration that fails to load keeps its place beside working handlers', async () => {
770
+ addBrokenIntegration('@test/broken');
771
+ addIntegration({
772
+ name: '@test/valid',
773
+ audience: 'internal',
774
+ handleBody: "return {status: 'filed', message: 'ok'};",
775
+ });
776
+ configure(['@test/broken', '@test/valid']);
777
+
778
+ const result = await gapReport('General', {
779
+ ...reportOptions,
780
+ cwd: projectDir,
781
+ });
782
+
783
+ expect(result.data.status).toBe('partial');
784
+ expect(result.data.deliveries.map(d => [d.handler, d.status])).toEqual([
785
+ ['@test/broken', 'failed'],
786
+ ['@test/valid', 'filed'],
787
+ ]);
788
+ });
789
+
684
790
  it('invalid handler receipt is recorded as failed delivery', async () => {
685
791
  addIntegration({
686
792
  name: '@test/bad-receipt',
@@ -174,7 +174,7 @@ message: Rerun with --confirm-public to file this report.`,
174
174
  },
175
175
  {
176
176
  type: 'prose',
177
- text: 'One handler anywhere in the app, from the app or from any package, turns the fallback off for every report. See {@link command:gap-report}.',
177
+ text: 'One handler anywhere in the app, from the app or from any package, turns the fallback off for every report. So does a listed package that fails to load: the CLI cannot tell whether it has a handler, so the report fails for that package instead of going to another tracker. See {@link command:gap-report}.',
178
178
  },
179
179
  ],
180
180
  },
@@ -55,6 +55,18 @@ export function registerGapReport(program) {
55
55
  result.type === 'gap-report.file' &&
56
56
  (result.data.status === 'failed' || result.data.status === 'partial');
57
57
 
58
+ // stderr in both modes, so a caller that reads only the exit code or the
59
+ // JSON envelope still sees which delivery failed and why.
60
+ if (isFailure && result.type === 'gap-report.file') {
61
+ for (const delivery of result.data.deliveries) {
62
+ if (delivery.status === 'failed') {
63
+ console.error(
64
+ `gap-report: delivery to ${delivery.handler} failed: ${delivery.message ?? 'no message'}`,
65
+ );
66
+ }
67
+ }
68
+ }
69
+
58
70
  if (program.opts().json) {
59
71
  jsonOut(result);
60
72
  if (isFailure) process.exitCode = 1;
@@ -172,6 +172,69 @@ export default {};\n`,
172
172
  }
173
173
  });
174
174
 
175
+ it('exits nonzero and names the integration on stderr when one fails to load', async () => {
176
+ const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'astryx-gap-broken-'));
177
+ try {
178
+ fs.writeFileSync(
179
+ path.join(cwd, 'package.json'),
180
+ JSON.stringify({name: 'fixture-app'}),
181
+ );
182
+ fs.writeFileSync(
183
+ path.join(cwd, 'astryx.config.mjs'),
184
+ "export default {integrations: ['@test/broken']};\n",
185
+ );
186
+ const integrationDir = path.join(cwd, 'node_modules', '@test', 'broken');
187
+ fs.mkdirSync(integrationDir, {recursive: true});
188
+ fs.writeFileSync(
189
+ path.join(integrationDir, 'package.json'),
190
+ JSON.stringify({
191
+ name: '@test/broken',
192
+ version: '1.0.0',
193
+ type: 'module',
194
+ }),
195
+ );
196
+ fs.writeFileSync(
197
+ path.join(integrationDir, 'astryx.integration.mjs'),
198
+ "throw new Error('intentional integration load failure');\n",
199
+ );
200
+
201
+ const cli = await runCli(
202
+ [
203
+ 'gap-report',
204
+ 'General',
205
+ '--category',
206
+ 'docs_gap',
207
+ '--reason',
208
+ 'Need a keyboard example',
209
+ '--json',
210
+ ],
211
+ {cwd},
212
+ );
213
+
214
+ expect(cli.status).toBe(1);
215
+ expect(JSON.parse(cli.stdout)).toMatchObject({
216
+ type: 'gap-report.file',
217
+ data: {
218
+ status: 'failed',
219
+ filedCount: 0,
220
+ deliveries: [
221
+ {
222
+ handlerType: 'integration',
223
+ handler: '@test/broken',
224
+ status: 'failed',
225
+ },
226
+ ],
227
+ },
228
+ });
229
+ expect(cli.stderr).toContain(
230
+ 'gap-report: delivery to @test/broken failed:',
231
+ );
232
+ expect(cli.stderr).toContain('intentional integration load failure');
233
+ } finally {
234
+ fs.rmSync(cwd, {recursive: true, force: true});
235
+ }
236
+ });
237
+
175
238
  it('reports missing required fields through the JSON error contract', async () => {
176
239
  const cli = await runCli(['gap-report', 'Button', '--json']);
177
240
  expect(cli.status).toBe(1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astryxdesign/cli",
3
- "version": "0.6.5-canary.1bbbf63",
3
+ "version": "0.6.5-canary.1d8fca6",
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",
@@ -101,10 +101,10 @@
101
101
  "zod": "^4.4.3"
102
102
  },
103
103
  "peerDependencies": {
104
- "@astryxdesign/charts": "0.6.5-canary.1bbbf63",
105
- "@astryxdesign/core": "0.6.5-canary.1bbbf63",
106
- "@astryxdesign/lab": "0.6.5-canary.1bbbf63",
107
- "@astryxdesign/theme-neutral": "0.6.5-canary.1bbbf63"
104
+ "@astryxdesign/charts": "0.6.5-canary.1d8fca6",
105
+ "@astryxdesign/core": "0.6.5-canary.1d8fca6",
106
+ "@astryxdesign/lab": "0.6.5-canary.1d8fca6",
107
+ "@astryxdesign/theme-neutral": "0.6.5-canary.1d8fca6"
108
108
  },
109
109
  "peerDependenciesMeta": {
110
110
  "@astryxdesign/charts": {
@@ -121,10 +121,10 @@
121
121
  }
122
122
  },
123
123
  "devDependencies": {
124
- "@astryxdesign/charts": "0.6.5-canary.1bbbf63",
125
- "@astryxdesign/core": "0.6.5-canary.1bbbf63",
126
- "@astryxdesign/lab": "0.6.5-canary.1bbbf63",
127
- "@astryxdesign/theme-neutral": "0.6.5-canary.1bbbf63",
124
+ "@astryxdesign/charts": "0.6.5-canary.1d8fca6",
125
+ "@astryxdesign/core": "0.6.5-canary.1d8fca6",
126
+ "@astryxdesign/lab": "0.6.5-canary.1d8fca6",
127
+ "@astryxdesign/theme-neutral": "0.6.5-canary.1d8fca6",
128
128
  "@heroicons/react": "^2.2.0",
129
129
  "@stylexjs/stylex": "^0.19.0",
130
130
  "@types/babel__core": "^7.20.5",