@astryxdesign/cli 0.7.2-canary.e2909cf → 0.7.2-canary.ebcc404

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.
@@ -26,6 +26,8 @@
26
26
  * @property {string|null} sourcePath
27
27
  * @property {string|undefined} issuesUrl
28
28
  * @property {import('../../foundation/integrations/integrations.mjs').LoadedIntegration|null} integration
29
+ * @property {import('../../foundation/discovery/component-discovery.mjs').NameHold} hold
30
+ * how the owner holds the name, for the owner rule
29
31
  */
30
32
  /**
31
33
  * What ownership shaping needs from an owner. The core and legacy-external
@@ -138,6 +140,25 @@ export function assertUnambiguousOwners(owners: ComponentOwner[], dirName: strin
138
140
  * @returns {string | null}
139
141
  */
140
142
  export function resolveLegacyExternalDoc(ext: ExternalPackageRef, dirName: string): string | null;
143
+ /**
144
+ * Whether a legacy external package documents `dirName`.
145
+ * @param {string} cwd
146
+ * @param {string} dirName
147
+ * @returns {boolean}
148
+ */
149
+ export function legacyExternalDocuments(cwd: string, dirName: string): boolean;
150
+ /**
151
+ * Whether a legacy docs package keeps `dirName` over these owners, each of which
152
+ * holds it only as an export a doc records.
153
+ * @param {string} cwd
154
+ * @param {string} dirName
155
+ * @param {Array<{hold?: string, docPath?: string | null}>} owners
156
+ * @returns {boolean}
157
+ */
158
+ export function legacyExternalKeeps(cwd: string, dirName: string, owners: Array<{
159
+ hold?: string;
160
+ docPath?: string | null;
161
+ }>): boolean;
141
162
  /**
142
163
  * The core source file for `dirName` (the no-scope `--source` path reads core
143
164
  * directly — it does NOT fall back to externals or fuzzy search).
@@ -210,7 +231,6 @@ export function scopeSubComponent(docs: LoadedComponentDoc, dirName: string, cor
210
231
  };
211
232
  matchingComponent: any;
212
233
  } | null;
213
- export { CORE_PACKAGE };
214
234
  /**
215
235
  * Internal ambiguity marker. Single-component callers still receive the same
216
236
  * AstryxError code, message, and suggestions; batch callers can additionally
@@ -261,6 +281,10 @@ export type ComponentOwner = {
261
281
  sourcePath: string | null;
262
282
  issuesUrl: string | undefined;
263
283
  integration: import("../../foundation/integrations/integrations.mjs").LoadedIntegration | null;
284
+ /**
285
+ * how the owner holds the name, for the owner rule
286
+ */
287
+ hold: import("../../foundation/discovery/component-discovery.mjs").NameHold;
264
288
  };
265
289
  /**
266
290
  * What ownership shaping needs from an owner. The core and legacy-external
@@ -303,4 +327,6 @@ export type ResolvedUnscopedDoc = {
303
327
  resolvedSourcePath: string | null;
304
328
  };
305
329
  import { CORE_PACKAGE } from '../../foundation/discovery/component-discovery.mjs';
330
+ import { unscopedComponentOwners } from '../../foundation/discovery/component-discovery.mjs';
306
331
  import { AstryxError } from '../error.mjs';
332
+ export { CORE_PACKAGE, unscopedComponentOwners };
@@ -28,13 +28,14 @@ import {
28
28
  import {
29
29
  CORE_PACKAGE,
30
30
  discoverComponents,
31
+ docStem,
31
32
  findComponentReadme,
32
33
  findComponentSource,
33
34
  findExternalComponentDoc,
34
- findIntegrationComponentDoc,
35
- findIntegrationComponentSource,
35
+ findIntegrationComponent,
36
36
  resolveImportPath,
37
37
  resolveIntegrationImportPath as resolveIntegrationImport,
38
+ unscopedComponentOwners,
38
39
  } from '../../foundation/discovery/component-discovery.mjs';
39
40
  import {Project} from '../../foundation/config/project.mjs';
40
41
  import {loadComponentDoc as loadValidatedComponentDoc} from '../../foundation/discovery/component-loader.mjs';
@@ -42,7 +43,7 @@ import {resolveComponentReplacements} from '../../foundation/discovery/component
42
43
  import {searchComponents} from '../../foundation/text/string-utils.mjs';
43
44
  import {AstryxError} from '../error.mjs';
44
45
 
45
- export {CORE_PACKAGE};
46
+ export {CORE_PACKAGE, unscopedComponentOwners};
46
47
 
47
48
  /**
48
49
  * A loaded component doc. The shared validated loader accepts stamped and legacy
@@ -71,6 +72,8 @@ export {CORE_PACKAGE};
71
72
  * @property {string|null} sourcePath
72
73
  * @property {string|undefined} issuesUrl
73
74
  * @property {import('../../foundation/integrations/integrations.mjs').LoadedIntegration|null} integration
75
+ * @property {import('../../foundation/discovery/component-discovery.mjs').NameHold} hold
76
+ * how the owner holds the name, for the owner rule
74
77
  */
75
78
 
76
79
  /**
@@ -240,17 +243,19 @@ export function resolveOwners(coreDir, dirName, loadedIntegrations) {
240
243
  sourcePath: findComponentSource(coreDir, dirName),
241
244
  issuesUrl: undefined,
242
245
  integration: null,
246
+ hold: 'core',
243
247
  });
244
248
  }
245
249
  for (const integration of loadedIntegrations) {
246
- const docPath = findIntegrationComponentDoc(integration, dirName);
247
- if (!docPath) continue;
250
+ const found = findIntegrationComponent(integration, dirName);
251
+ if (!found) continue;
248
252
  owners.push({
249
253
  package: integration.name,
250
- docPath,
251
- sourcePath: findIntegrationComponentSource(integration, dirName),
254
+ docPath: found.docPath,
255
+ sourcePath: found.sourcePath,
252
256
  issuesUrl: integration.issuesUrl,
253
257
  integration,
258
+ hold: found.hold,
254
259
  });
255
260
  }
256
261
  return owners;
@@ -361,6 +366,39 @@ export function resolveLegacyExternalDoc(ext, dirName) {
361
366
  return extDocPath && extDocPath.endsWith('.doc.mjs') ? extDocPath : null;
362
367
  }
363
368
 
369
+ /**
370
+ * Whether a legacy external package documents `dirName`.
371
+ * @param {string} cwd
372
+ * @param {string} dirName
373
+ * @returns {boolean}
374
+ */
375
+ export function legacyExternalDocuments(cwd, dirName) {
376
+ return discoverExternalPackages(cwd).some(
377
+ ext => resolveLegacyExternalDoc(ext, dirName) != null,
378
+ );
379
+ }
380
+
381
+ /**
382
+ * Whether a legacy docs package keeps `dirName` over these owners, each of which
383
+ * holds it only as an export a doc records.
384
+ * @param {string} cwd
385
+ * @param {string} dirName
386
+ * @param {Array<{hold?: string, docPath?: string | null}>} owners
387
+ * @returns {boolean}
388
+ */
389
+ export function legacyExternalKeeps(cwd, dirName, owners) {
390
+ return (
391
+ owners.length > 0 &&
392
+ owners.every(
393
+ owner =>
394
+ owner.hold === 'doc' &&
395
+ owner.docPath != null &&
396
+ docStem(owner.docPath).toLowerCase() !== dirName.toLowerCase(),
397
+ ) &&
398
+ legacyExternalDocuments(cwd, dirName)
399
+ );
400
+ }
401
+
364
402
  /**
365
403
  * The core source file for `dirName` (the no-scope `--source` path reads core
366
404
  * directly — it does NOT fall back to externals or fuzzy search).
@@ -22,6 +22,7 @@ import {
22
22
  classifyScope,
23
23
  assertUnambiguousOwners,
24
24
  resolveLegacyExternalDoc,
25
+ legacyExternalKeeps,
25
26
  resolveCoreSourcePath,
26
27
  resolveUnscopedDoc,
27
28
  loadComponentDoc,
@@ -29,6 +30,7 @@ import {
29
30
  ComponentAmbiguityError,
30
31
  installedComponentPackageVersion,
31
32
  resolveComponentReplacement,
33
+ unscopedComponentOwners,
32
34
  } from './_adapter.mjs';
33
35
  import {componentList} from './list/list.mjs';
34
36
  import {componentDetail} from './detail/detail.mjs';
@@ -481,12 +483,14 @@ export async function component(name, options = {}) {
481
483
  });
482
484
  }
483
485
 
486
+ // The owner rule runs first, over valid and invalid owners alike.
487
+ const ruled = unscopedComponentOwners(owners);
484
488
  // Invalid integration metadata does not create ambiguity against a valid
485
- // owner. Keep raw owners only when no doc owner is valid, so an integration-
489
+ // owner. Keep ruled owners only when no doc owner is valid, so an integration-
486
490
  // only component still exposes its source and returns ERR_INVALID_DOC for
487
491
  // detail instead of degrading to an unrelated unknown-component error.
488
492
  const validOwners = [];
489
- for (const owner of owners) {
493
+ for (const owner of ruled) {
490
494
  if (owner.package === CORE_PACKAGE) {
491
495
  validOwners.push(owner);
492
496
  continue;
@@ -498,7 +502,12 @@ export async function component(name, options = {}) {
498
502
  // Project/Doctor report invalid metadata; it is not an effective owner.
499
503
  }
500
504
  }
501
- const effectiveOwners = validOwners.length > 0 ? validOwners : owners;
505
+ const loadable = validOwners.length > 0 ? validOwners : ruled;
506
+ // A legacy docs package's exact doc keeps its name over an export name a doc
507
+ // records, as Core and file-named owners do.
508
+ const effectiveOwners = legacyExternalKeeps(cwd, dirName, loadable)
509
+ ? []
510
+ : loadable;
502
511
 
503
512
  // ── Ambiguity: owned by MORE THAN ONE valid package, no --package ──
504
513
  assertUnambiguousOwners(effectiveOwners, dirName);
@@ -0,0 +1,419 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Every surface knows an integration component by the name its doc
5
+ * documents. Fixtures live under a repo-local temp dir: Vite refuses to import
6
+ * a module from outside the project root.
7
+ */
8
+
9
+ import {afterEach, beforeEach, describe, expect, it} from 'vitest';
10
+ import * as fs from 'node:fs';
11
+ import * as path from 'node:path';
12
+ import {component} from './component.mjs';
13
+ import {search} from '../search/search.mjs';
14
+ import {validateIntegration} from '../integration/validate-integration.mjs';
15
+ import {integrationComponentConflicts} from '../integration/authoring-checks.mjs';
16
+ import {swizzleCopy} from '../swizzle/copy/copy.mjs';
17
+ import {gapReport} from '../gap-report/gap-report.mjs';
18
+ import {COMPONENT_REPLACES_CLI} from '../../foundation/integrations/cli-requirement.mjs';
19
+
20
+ const SLOW = 30_000;
21
+
22
+ /** @type {string} */
23
+ let tmpDir;
24
+ /** @type {string} */
25
+ let pkgDir;
26
+
27
+ /**
28
+ * @param {string} file path under the package's src/, without extension
29
+ * @param {string} name
30
+ * @param {string} source
31
+ * @param {string} description
32
+ * @param {object} [extra] more doc fields
33
+ */
34
+ function writeComponent(file, name, source, description, extra = {}) {
35
+ const target = path.join(pkgDir, 'src', file);
36
+ fs.mkdirSync(path.dirname(target), {recursive: true});
37
+ fs.writeFileSync(`${target}.tsx`, source);
38
+ fs.writeFileSync(
39
+ `${target}.doc.mjs`,
40
+ `export default ${JSON.stringify(
41
+ {
42
+ type: 'component',
43
+ name,
44
+ displayName: name,
45
+ import: `@acme/widgets/${file}`,
46
+ usage: {description},
47
+ props: [],
48
+ ...extra,
49
+ },
50
+ null,
51
+ 2,
52
+ )};\n`,
53
+ );
54
+ }
55
+
56
+ beforeEach(() => {
57
+ tmpDir = fs.mkdtempSync(
58
+ path.join(process.cwd(), '.astryx-export-name-test-'),
59
+ );
60
+ fs.writeFileSync(
61
+ path.join(tmpDir, 'package.json'),
62
+ JSON.stringify({name: 'consumer', version: '1.0.0'}),
63
+ );
64
+ fs.writeFileSync(
65
+ path.join(tmpDir, 'astryx.config.mjs'),
66
+ "export default {integrations: ['@acme/widgets']};\n",
67
+ );
68
+ pkgDir = path.join(tmpDir, 'node_modules', '@acme', 'widgets');
69
+ fs.mkdirSync(pkgDir, {recursive: true});
70
+ fs.writeFileSync(
71
+ path.join(pkgDir, 'package.json'),
72
+ JSON.stringify({
73
+ name: '@acme/widgets',
74
+ version: '1.0.0',
75
+ exports: {
76
+ './gallery/Slides': './src/gallery/Slides.tsx',
77
+ './gallery/Overlays': './src/gallery/Overlays.tsx',
78
+ './AcmeBadge': './src/AcmeBadge.tsx',
79
+ },
80
+ }),
81
+ );
82
+ fs.writeFileSync(
83
+ path.join(pkgDir, 'astryx.integration.mjs'),
84
+ "export default {components: './src', issuesUrl: 'https://example.com/acme/issues'};\n",
85
+ );
86
+ writeComponent(
87
+ 'gallery/Slides',
88
+ 'AcmeCarousel',
89
+ 'export function AcmeCarousel() { return null; }\nexport function useSlides() { return []; }\n',
90
+ 'Cycles through a row of slides.',
91
+ );
92
+ writeComponent(
93
+ 'AcmeBadge',
94
+ 'AcmeBadge',
95
+ 'export function AcmeBadge() { return null; }\n',
96
+ 'A small status label.',
97
+ );
98
+ });
99
+
100
+ afterEach(() => {
101
+ fs.rmSync(tmpDir, {recursive: true, force: true});
102
+ });
103
+
104
+ /** @param {any} list @returns {string[]} the integration's listed names */
105
+ function listedNames(list) {
106
+ return Object.values(list.data.components)
107
+ .flat()
108
+ .filter(entry => entry.package === '@acme/widgets')
109
+ .map(entry => entry.name)
110
+ .sort();
111
+ }
112
+
113
+ describe('a component doc that names another export of its file', () => {
114
+ it(
115
+ "leaves a name a legacy docs package documents to that package's doc",
116
+ async () => {
117
+ const legacy = path.join(tmpDir, 'node_modules', '@legacy', 'docs');
118
+ fs.mkdirSync(path.join(legacy, 'docs'), {recursive: true});
119
+ fs.writeFileSync(
120
+ path.join(legacy, 'package.json'),
121
+ JSON.stringify({name: '@legacy/docs', astryx: {docs: './docs'}}),
122
+ );
123
+ for (const name of ['AcmeCarousel', 'Slides']) {
124
+ fs.writeFileSync(
125
+ path.join(legacy, 'docs', `${name}.doc.mjs`),
126
+ `export default {type: 'component', name: '${name}', usage: {description: 'Legacy.'}, props: []};\n`,
127
+ );
128
+ }
129
+ const cwd = tmpDir;
130
+ expect((await component('AcmeCarousel', {cwd})).data.package).toBe(
131
+ '@legacy/docs',
132
+ );
133
+ const scoped = {cwd, package: '@acme/widgets'};
134
+ expect((await component('AcmeCarousel', scoped)).data.package).toBe(
135
+ '@acme/widgets',
136
+ );
137
+ // Slides.doc.mjs's file name still answers first, as on main.
138
+ expect((await component('Slides', {cwd})).data.package).toBe(
139
+ '@acme/widgets',
140
+ );
141
+ const report = {cwd, category: 'other', reason: 'Needs a size.'};
142
+ for (const name of ['AcmeCarousel', 'acmecarousel']) {
143
+ expect((await gapReport(name, report)).data.package).toBe(
144
+ '@astryxdesign/core',
145
+ );
146
+ }
147
+ const hits = (await search('AcmeCarousel', {cwd, type: 'component'})).data
148
+ .results;
149
+ expect(hits.find(hit => hit.package === '@acme/widgets')?.command).toBe(
150
+ 'astryx component AcmeCarousel --package @acme/widgets',
151
+ );
152
+ },
153
+ SLOW,
154
+ );
155
+
156
+ it(
157
+ 'is listed, opened and found by the export name',
158
+ async () => {
159
+ expect(
160
+ listedNames(await component(undefined, {cwd: tmpDir, list: true})),
161
+ ).toEqual(['AcmeBadge', 'AcmeCarousel']);
162
+
163
+ const detail = await component('AcmeCarousel', {cwd: tmpDir});
164
+ expect(detail.type).toBe('component.detail');
165
+ expect(detail.data).toMatchObject({
166
+ name: 'AcmeCarousel',
167
+ package: '@acme/widgets',
168
+ import: '@acme/widgets/gallery/Slides',
169
+ sourceAvailable: true,
170
+ });
171
+
172
+ const hits = (
173
+ await search('AcmeCarousel', {cwd: tmpDir, type: 'component'})
174
+ ).data.results;
175
+ expect(hits[0]).toMatchObject({
176
+ name: 'AcmeCarousel',
177
+ package: '@acme/widgets',
178
+ import: '@acme/widgets/gallery/Slides',
179
+ });
180
+
181
+ // The file name opens the same doc, as it did before, routes a gap
182
+ // report to the package, and search finds the component by it, a word
183
+ // of it included.
184
+ expect((await component('Slides', {cwd: tmpDir})).data.name).toBe(
185
+ 'AcmeCarousel',
186
+ );
187
+ const report = {cwd: tmpDir, category: 'other', reason: 'Needs a size.'};
188
+ expect((await gapReport('Slides', report)).data.package).toBe(
189
+ '@acme/widgets',
190
+ );
191
+ for (const query of ['Slides', 'slide']) {
192
+ const byFileName = (
193
+ await search(query, {cwd: tmpDir, type: 'component'})
194
+ ).data.results;
195
+ expect(byFileName[0]?.name).toBe('AcmeCarousel');
196
+ }
197
+ },
198
+ SLOW,
199
+ );
200
+
201
+ it(
202
+ "leaves a Core name to Core unless --package names it, and passes Doctor's package checks",
203
+ async () => {
204
+ // Overlays.tsx exports Dialog, a Core name, and nothing named Overlays.
205
+ writeComponent(
206
+ 'gallery/Overlays',
207
+ 'Dialog',
208
+ 'export function AcmeOverlays() { return null; }\nexport function Dialog() { return null; }\n',
209
+ 'Overlays.',
210
+ );
211
+
212
+ const validation = await validateIntegration(undefined, {cwd: pkgDir});
213
+ expect(validation.data.issues).toEqual([]);
214
+
215
+ const conflicts = await integrationComponentConflicts(undefined, {
216
+ cwd: pkgDir,
217
+ });
218
+ expect(conflicts.data.conflicts.map(conflict => conflict.name)).toEqual([
219
+ 'Dialog',
220
+ ]);
221
+
222
+ // Lookups by the bare name still answer with Core's Dialog, as before.
223
+ const cwd = tmpDir;
224
+ const core = '@astryxdesign/core';
225
+ expect((await component('Dialog', {cwd})).data.package).toBe(core);
226
+ expect((await component('Dialog', {cwd, props: true})).package).toBe(
227
+ core,
228
+ );
229
+ expect((await swizzleCopy('Dialog', {cwd})).package).toBe(core);
230
+ const report = {cwd, category: 'other', reason: 'Needs a size.'};
231
+ expect((await gapReport('Dialog', report)).data.package).toBe(core);
232
+ // The list and --package reach the integration's Dialog.
233
+ const scoped = {cwd, package: '@acme/widgets'};
234
+ expect((await component('Dialog', scoped)).data.import).toBe(
235
+ '@acme/widgets/gallery/Overlays',
236
+ );
237
+ expect(
238
+ listedNames(await component(undefined, {cwd, list: true})),
239
+ ).toContain('Dialog');
240
+ // Search shows both; the package's hit names its package and follows
241
+ // Core's.
242
+ const hits = (await search('Dialog', {cwd, type: 'component'})).data
243
+ .results;
244
+ const ours = hits.findIndex(hit => hit.package === '@acme/widgets');
245
+ expect(hits[0]).toMatchObject({name: 'Dialog', package: core});
246
+ expect(hits[ours]).toMatchObject({
247
+ name: 'Dialog',
248
+ command: 'astryx component Dialog --package @acme/widgets',
249
+ });
250
+ },
251
+ SLOW,
252
+ );
253
+
254
+ it(
255
+ 'leaves a Core name with no folder of its own unknown to swizzle, as before',
256
+ async () => {
257
+ // Core documents CheckboxIndicator inside Indicator's doc, with no folder.
258
+ // BaseTable, named after its file, keeps routing reports to its package.
259
+ writeComponent(
260
+ 'BaseTable',
261
+ 'BaseTable',
262
+ 'export function BaseTable() { return null; }\n',
263
+ 'A file-named table.',
264
+ );
265
+ writeComponent(
266
+ 'gallery/Indicator',
267
+ 'CheckboxIndicator',
268
+ 'export function CheckboxIndicator() { return null; }\n',
269
+ 'A header.',
270
+ );
271
+ const cwd = tmpDir;
272
+ expect((await component('CheckboxIndicator', {cwd})).data.package).toBe(
273
+ '@astryxdesign/core',
274
+ );
275
+ await expect(
276
+ swizzleCopy('CheckboxIndicator', {cwd}),
277
+ ).rejects.toMatchObject({
278
+ code: 'ERR_UNKNOWN_COMPONENT',
279
+ });
280
+ const scoped = {cwd, package: '@acme/widgets'};
281
+ expect((await swizzleCopy('CheckboxIndicator', scoped)).package).toBe(
282
+ '@acme/widgets',
283
+ );
284
+ const report = {cwd, category: 'other', reason: 'Needs a size.'};
285
+ expect((await gapReport('CheckboxIndicator', report)).data.package).toBe(
286
+ '@astryxdesign/core',
287
+ );
288
+ const hit = (
289
+ await search('CheckboxIndicator', {cwd, type: 'component'})
290
+ ).data.results.find(result => result.package === '@acme/widgets');
291
+ expect(hit?.command).toBe(
292
+ 'astryx component CheckboxIndicator --package @acme/widgets',
293
+ );
294
+ expect((await gapReport('BaseTable', report)).data.package).toBe(
295
+ '@acme/widgets',
296
+ );
297
+ },
298
+ SLOW,
299
+ );
300
+
301
+ it.each([
302
+ // props must be an array: the loader rejects this doc.
303
+ [
304
+ 'invalid',
305
+ "export default {type: 'component', name: 'AcmeTab', props: 'x'};",
306
+ ],
307
+ [
308
+ 'hidden',
309
+ "export default {\n type: 'component',\n name: 'AcmeTab',\n hidden: true,\n usage: {description: 'x'},\n props: [],\n};",
310
+ ],
311
+ ])(
312
+ "leaves a name to another package's file-named doc even when that doc is %s",
313
+ async (kind, doc) => {
314
+ writeComponent(
315
+ 'gallery/Strip',
316
+ 'AcmeTab',
317
+ 'export function AcmeTab() { return null; }\n',
318
+ 'A strip of tabs.',
319
+ );
320
+ const beta = path.join(tmpDir, 'node_modules', '@beta', 'kit');
321
+ fs.mkdirSync(path.join(beta, 'src'), {recursive: true});
322
+ for (const [file, text] of Object.entries({
323
+ 'package.json': '{"name": "@beta/kit", "version": "1.0.0"}',
324
+ 'astryx.integration.mjs': "export default {components: './src'};",
325
+ 'src/AcmeTab.tsx': 'export function AcmeTab() { return null; }',
326
+ 'src/AcmeTab.doc.mjs': doc,
327
+ })) {
328
+ fs.writeFileSync(path.join(beta, file), `${text}\n`);
329
+ }
330
+ fs.writeFileSync(
331
+ path.join(tmpDir, 'astryx.config.mjs'),
332
+ "export default {integrations: ['@acme/widgets', '@beta/kit']};\n",
333
+ );
334
+
335
+ const cwd = tmpDir;
336
+ expect((await component('AcmeTab', {cwd, source: true})).package).toBe(
337
+ '@beta/kit',
338
+ );
339
+ if (kind === 'invalid') {
340
+ await expect(component('AcmeTab', {cwd})).rejects.toMatchObject({
341
+ code: 'ERR_INVALID_DOC',
342
+ });
343
+ }
344
+ const report = {cwd, category: 'other', reason: 'Needs a size.'};
345
+ for (const name of ['AcmeTab', 'acmetab']) {
346
+ expect((await gapReport(name, report)).data.package).toBe(
347
+ '@astryxdesign/core',
348
+ );
349
+ }
350
+ const hit = (
351
+ await search('AcmeTab', {cwd, type: 'component'})
352
+ ).data.results.find(result => result.package === '@acme/widgets');
353
+ expect(hit?.command).toBe(
354
+ 'astryx component AcmeTab --package @acme/widgets',
355
+ );
356
+ },
357
+ SLOW,
358
+ );
359
+
360
+ it(
361
+ 'leaves a file name Core also has to Core in search, as in detail',
362
+ async () => {
363
+ // Badge.doc.mjs documents AcmeStatusBadge; its file name is Core's.
364
+ writeComponent(
365
+ 'status/Badge',
366
+ 'AcmeStatusBadge',
367
+ 'export function AcmeStatusBadge() { return null; }\n',
368
+ 'Shows a status.',
369
+ );
370
+ const cwd = tmpDir;
371
+ const [first] = (await search('Badge', {cwd, type: 'component'})).data
372
+ .results;
373
+ expect(first).toMatchObject({
374
+ name: 'Badge',
375
+ package: '@astryxdesign/core',
376
+ });
377
+ expect((await component('Badge', {cwd})).data.package).toBe(
378
+ '@astryxdesign/core',
379
+ );
380
+ const [own] = (await search('AcmeStatusBadge', {cwd, type: 'component'}))
381
+ .data.results;
382
+ expect(own).toMatchObject({
383
+ name: 'AcmeStatusBadge',
384
+ command: 'astryx component AcmeStatusBadge',
385
+ });
386
+ },
387
+ SLOW,
388
+ );
389
+
390
+ it(
391
+ 'copies a replacement recorded under its export into its file name folder, as before',
392
+ async () => {
393
+ const manifest = path.join(pkgDir, 'package.json');
394
+ fs.writeFileSync(
395
+ manifest,
396
+ JSON.stringify({
397
+ ...JSON.parse(fs.readFileSync(manifest, 'utf-8')),
398
+ peerDependencies: {
399
+ '@astryxdesign/cli': `>=${COMPONENT_REPLACES_CLI}`,
400
+ },
401
+ }),
402
+ );
403
+ writeComponent(
404
+ 'gallery/Slides',
405
+ 'AcmeCarousel',
406
+ 'export function AcmeCarousel() { return null; }\n',
407
+ 'Cycles through a row of slides.',
408
+ {replaces: 'Carousel'},
409
+ );
410
+
411
+ const copied = await swizzleCopy('Carousel', {cwd: tmpDir});
412
+ expect(copied.data).toMatchObject({
413
+ package: '@acme/widgets',
414
+ outputDir: path.join('components', 'astryx', 'Slides'),
415
+ });
416
+ },
417
+ SLOW,
418
+ );
419
+ });