@jlcpcb/cli 0.4.0 → 0.4.1
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/CHANGELOG.md +14 -0
- package/README.md +23 -5
- package/dist/assets/search.html +32 -31
- package/dist/index.js +6604 -6339
- package/package.json +1 -1
- package/src/app/App.tsx +1 -1
- package/src/app/__fixtures__/interactive-install-scenarios.tsx +149 -0
- package/src/app/__fixtures__/navigation-scenarios.tsx +103 -0
- package/src/app/__fixtures__/terminal.tsx +57 -0
- package/src/app/components/DetailView.tsx +8 -5
- package/src/app/navigation/NavigationContext.tsx +1 -1
- package/src/app/navigation/types.ts +8 -1
- package/src/app/navigation.test.ts +24 -0
- package/src/app/screens/EasyEDAInfoScreen.tsx +7 -9
- package/src/app/screens/InfoScreen.tsx +16 -14
- package/src/app/screens/InstallScreen.tsx +3 -5
- package/src/app/screens/InstalledScreen.tsx +2 -2
- package/src/app/screens/LibrarySetupScreen.tsx +12 -5
- package/src/app/screens/SearchScreen.tsx +11 -14
- package/src/app/state/AppStateContext.tsx +0 -23
- package/src/commands/__fixtures__/failure-scenarios.ts +67 -0
- package/src/commands/agent-json.test.ts +0 -9
- package/src/commands/easyeda.ts +4 -4
- package/src/commands/failures.test.ts +105 -0
- package/src/commands/install.ts +4 -4
- package/src/commands/interactive-install.test.ts +29 -0
- package/src/commands/library.ts +1 -0
- package/src/commands/search.ts +1 -1
- package/src/commands/validate.ts +20 -11
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useState } from 'react';
|
|
1
|
+
import React, { useEffect, useRef, useState } from 'react';
|
|
2
2
|
import { Box, Text, useInput } from 'ink';
|
|
3
3
|
import { createLibraryService } from '@jlcpcb/core';
|
|
4
4
|
import { useNavigation, useCurrentScreen } from '../navigation/NavigationContext.js';
|
|
@@ -9,13 +9,19 @@ import { Divider } from '../components/Divider.js';
|
|
|
9
9
|
const libraryService = createLibraryService();
|
|
10
10
|
|
|
11
11
|
export function LibrarySetupScreen() {
|
|
12
|
-
const {
|
|
12
|
+
const { replace, pop } = useNavigation();
|
|
13
13
|
const { params } = useCurrentScreen() as { screen: 'library-setup'; params: LibrarySetupParams };
|
|
14
14
|
const { columns: terminalWidth } = useTerminalSize();
|
|
15
15
|
|
|
16
16
|
const [selectedOption, setSelectedOption] = useState<'install' | 'cancel'>('install');
|
|
17
17
|
const [isInstalling, setIsInstalling] = useState(false);
|
|
18
18
|
const [error, setError] = useState<string | null>(null);
|
|
19
|
+
const activeRef = useRef(true);
|
|
20
|
+
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
activeRef.current = true;
|
|
23
|
+
return () => { activeRef.current = false; };
|
|
24
|
+
}, []);
|
|
19
25
|
|
|
20
26
|
useInput((input, key) => {
|
|
21
27
|
if (isInstalling) return;
|
|
@@ -31,19 +37,20 @@ export function LibrarySetupScreen() {
|
|
|
31
37
|
libraryService
|
|
32
38
|
.ensureGlobalTables()
|
|
33
39
|
.then(() => {
|
|
40
|
+
if (!activeRef.current) return;
|
|
34
41
|
// Success - continue to install the component
|
|
35
|
-
|
|
42
|
+
replace('install', {
|
|
36
43
|
componentId: params.componentId,
|
|
37
44
|
component: params.component,
|
|
45
|
+
installOptions: params.installOptions,
|
|
38
46
|
});
|
|
39
47
|
})
|
|
40
48
|
.catch((err) => {
|
|
49
|
+
if (!activeRef.current) return;
|
|
41
50
|
setError(err instanceof Error ? err.message : 'Unknown error');
|
|
42
51
|
setIsInstalling(false);
|
|
43
52
|
});
|
|
44
53
|
}
|
|
45
|
-
} else if (key.escape) {
|
|
46
|
-
pop();
|
|
47
54
|
}
|
|
48
55
|
});
|
|
49
56
|
|
|
@@ -1,58 +1,55 @@
|
|
|
1
1
|
import React, { useState } from 'react';
|
|
2
2
|
import { Box, Text, useInput } from 'ink';
|
|
3
|
-
import { createComponentService
|
|
3
|
+
import { createComponentService } from '@jlcpcb/core';
|
|
4
4
|
import { useNavigation, useCurrentScreen } from '../navigation/NavigationContext.js';
|
|
5
5
|
import type { SearchParams } from '../navigation/types.js';
|
|
6
|
-
import { useAppState } from '../state/AppStateContext.js';
|
|
7
6
|
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
|
8
7
|
import { ListView } from '../components/ListView.js';
|
|
9
8
|
|
|
10
9
|
const componentService = createComponentService();
|
|
11
10
|
|
|
12
11
|
export function SearchScreen() {
|
|
13
|
-
const { push } = useNavigation();
|
|
12
|
+
const { push, replace } = useNavigation();
|
|
14
13
|
const { params } = useCurrentScreen() as { screen: 'search'; params: SearchParams };
|
|
15
|
-
const { selectedIndex, setSelectedIndex, isFiltered, setIsFiltered, resetSelection } = useAppState();
|
|
16
14
|
const { columns: terminalWidth } = useTerminalSize();
|
|
17
15
|
|
|
18
|
-
const
|
|
16
|
+
const { results, selectedIndex = 0 } = params;
|
|
17
|
+
const isFiltered = params.searchOptions?.basicOnly ?? false;
|
|
19
18
|
const [isSearching, setIsSearching] = useState(false);
|
|
20
19
|
|
|
21
20
|
useInput(async (input, key) => {
|
|
22
21
|
if (isSearching) return;
|
|
23
22
|
|
|
24
23
|
if (key.upArrow) {
|
|
25
|
-
|
|
24
|
+
replace('search', { ...params, selectedIndex: Math.max(0, selectedIndex - 1) });
|
|
26
25
|
} else if (key.downArrow) {
|
|
27
|
-
|
|
26
|
+
replace('search', { ...params, selectedIndex: Math.max(0, Math.min(results.length - 1, selectedIndex + 1)) });
|
|
28
27
|
} else if (key.return && results[selectedIndex]) {
|
|
29
28
|
const selected = results[selectedIndex];
|
|
30
29
|
if (selected.idType === 'easyeda_uuid') {
|
|
31
30
|
push('easyeda-info', {
|
|
32
31
|
uuid: selected.easyedaUuid ?? selected.id,
|
|
32
|
+
installOptions: params.installOptions,
|
|
33
33
|
});
|
|
34
34
|
} else {
|
|
35
35
|
push('info', {
|
|
36
36
|
componentId: selected.lcscId ?? selected.id,
|
|
37
37
|
component: selected,
|
|
38
|
+
installOptions: params.installOptions,
|
|
38
39
|
});
|
|
39
40
|
}
|
|
40
41
|
} else if (key.tab) {
|
|
41
42
|
setIsSearching(true);
|
|
42
43
|
const newFiltered = !isFiltered;
|
|
43
44
|
try {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
basicOnly: newFiltered,
|
|
47
|
-
});
|
|
45
|
+
const searchOptions = { ...params.searchOptions, basicOnly: newFiltered };
|
|
46
|
+
let newResults = await componentService.search(params.query, searchOptions);
|
|
48
47
|
newResults = newResults.sort((a, b) => {
|
|
49
48
|
if (a.libraryType === 'basic' && b.libraryType !== 'basic') return -1;
|
|
50
49
|
if (a.libraryType !== 'basic' && b.libraryType === 'basic') return 1;
|
|
51
50
|
return 0;
|
|
52
51
|
});
|
|
53
|
-
|
|
54
|
-
resetSelection();
|
|
55
|
-
setIsFiltered(newFiltered);
|
|
52
|
+
replace('search', { ...params, results: newResults, searchOptions, selectedIndex: 0 });
|
|
56
53
|
} catch {
|
|
57
54
|
// Keep existing results on error
|
|
58
55
|
}
|
|
@@ -2,15 +2,10 @@ import React, { createContext, useContext, useState, useCallback, type ReactNode
|
|
|
2
2
|
|
|
3
3
|
export interface AppState {
|
|
4
4
|
selectedIndex: number;
|
|
5
|
-
isFiltered: boolean;
|
|
6
|
-
isLoading: boolean;
|
|
7
5
|
}
|
|
8
6
|
|
|
9
7
|
export interface AppStateActions {
|
|
10
8
|
setSelectedIndex: (index: number) => void;
|
|
11
|
-
setIsFiltered: (filtered: boolean) => void;
|
|
12
|
-
setIsLoading: (loading: boolean) => void;
|
|
13
|
-
resetSelection: () => void;
|
|
14
9
|
}
|
|
15
10
|
|
|
16
11
|
export type AppStateContextValue = AppState & AppStateActions;
|
|
@@ -23,33 +18,15 @@ interface AppStateProviderProps {
|
|
|
23
18
|
|
|
24
19
|
export function AppStateProvider({ children }: AppStateProviderProps) {
|
|
25
20
|
const [selectedIndex, setSelectedIndexState] = useState(0);
|
|
26
|
-
const [isFiltered, setIsFilteredState] = useState(false);
|
|
27
|
-
const [isLoading, setIsLoadingState] = useState(false);
|
|
28
21
|
|
|
29
22
|
const setSelectedIndex = useCallback((index: number) => {
|
|
30
23
|
setSelectedIndexState(index);
|
|
31
24
|
}, []);
|
|
32
25
|
|
|
33
|
-
const setIsFiltered = useCallback((filtered: boolean) => {
|
|
34
|
-
setIsFilteredState(filtered);
|
|
35
|
-
}, []);
|
|
36
|
-
|
|
37
|
-
const setIsLoading = useCallback((loading: boolean) => {
|
|
38
|
-
setIsLoadingState(loading);
|
|
39
|
-
}, []);
|
|
40
|
-
|
|
41
|
-
const resetSelection = useCallback(() => {
|
|
42
|
-
setSelectedIndexState(0);
|
|
43
|
-
}, []);
|
|
44
26
|
|
|
45
27
|
const value: AppStateContextValue = {
|
|
46
28
|
selectedIndex,
|
|
47
|
-
isFiltered,
|
|
48
|
-
isLoading,
|
|
49
29
|
setSelectedIndex,
|
|
50
|
-
setIsFiltered,
|
|
51
|
-
setIsLoading,
|
|
52
|
-
resetSelection,
|
|
53
30
|
};
|
|
54
31
|
|
|
55
32
|
return <AppStateContext.Provider value={value}>{children}</AppStateContext.Provider>;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { mock } from 'bun:test';
|
|
2
|
+
import * as core from '@jlcpcb/core';
|
|
3
|
+
|
|
4
|
+
const scenario = process.argv[2];
|
|
5
|
+
const manufacturing = scenario.startsWith('footprint-');
|
|
6
|
+
const symbolScenario = scenario === 'disabled-footprint' || scenario.startsWith('symbol-');
|
|
7
|
+
const symbolReference = `<svg>
|
|
8
|
+
<g c_partid="part_pin" c_spicepin="1" c_origin="-10,0" c_elec="1" ${scenario === 'symbol-unverified' ? '' : 'c_rotation="180"'}>
|
|
9
|
+
${scenario === 'symbol-unverified' ? '' : '<path d="M-10,0h5"/>'}<text>IN</text><text>1</text>
|
|
10
|
+
</g>
|
|
11
|
+
<g c_partid="part_pin" c_spicepin="2" c_origin="10,0" c_elec="2" c_rotation="0">
|
|
12
|
+
<path d="M10,0h-5"/><text>OUT</text><text>2</text>
|
|
13
|
+
</g>
|
|
14
|
+
</svg>`;
|
|
15
|
+
const reference = manufacturing
|
|
16
|
+
? {
|
|
17
|
+
footprintSvg: `<svg><g c_partid="part_pad" number="1" c_origin="0,0" ${scenario === 'footprint-unverified' ? '' : 'layerid="1"'}>
|
|
18
|
+
<rect x="-2" y="-1" width="4" height="2" />
|
|
19
|
+
<circle c_padhole="1" cx="0" cy="0" r="0" />
|
|
20
|
+
</g></svg>`,
|
|
21
|
+
symbolSvg: null,
|
|
22
|
+
}
|
|
23
|
+
: symbolScenario
|
|
24
|
+
? { footprintSvg: null, symbolSvg: symbolReference }
|
|
25
|
+
: { footprintSvg: null, symbolSvg: null };
|
|
26
|
+
|
|
27
|
+
mock.module('@jlcpcb/core', () => ({
|
|
28
|
+
...core,
|
|
29
|
+
fetchReferenceSVG: async () => reference,
|
|
30
|
+
easyedaClient: { getComponentData: async () => ({
|
|
31
|
+
info: { name: scenario === 'footprint-identity-mpn' ? 'DifferentPart' : 'Part' },
|
|
32
|
+
footprint: { name: scenario === 'footprint-identity-package' ? 'DifferentPackage' : 'PKG' },
|
|
33
|
+
}) },
|
|
34
|
+
footprintConverter: {
|
|
35
|
+
convert: () => `(footprint "Part" (pad "1" smd rect (at ${scenario === 'footprint-moved' ? 4 : 0} 0) (size 1.016 0.508) (layers "F.Cu")))`,
|
|
36
|
+
},
|
|
37
|
+
symbolConverter: {
|
|
38
|
+
convert: () => `(symbol "Part" (symbol "Part_1_1"
|
|
39
|
+
(pin input line (at -2.54 0 0) (length 1.27) (name "IN") (number "1"))
|
|
40
|
+
(pin output line (at ${scenario === 'symbol-moved' ? 4.54 : 2.54} 0 180) (length 1.27) (name "OUT") (number "2"))))`,
|
|
41
|
+
},
|
|
42
|
+
getAllTestComponents: () => [{ lcsc: 'C123', name: 'Part', expectedMpn: 'Part', expectedPackage: 'PKG' }],
|
|
43
|
+
createLibraryService: () => ({
|
|
44
|
+
listInstalled: async () => [{ lcscId: 'C123', name: 'Part' }],
|
|
45
|
+
regenerate: async () => ({
|
|
46
|
+
total: 1,
|
|
47
|
+
success: 0,
|
|
48
|
+
failed: 1,
|
|
49
|
+
components: [{ id: 'C123', name: 'Part', status: 'failed', error: 'conversion failed' }],
|
|
50
|
+
}),
|
|
51
|
+
}),
|
|
52
|
+
}));
|
|
53
|
+
|
|
54
|
+
// Commands must load after the subprocess-specific dependency mocks.
|
|
55
|
+
if (scenario === 'regenerate') {
|
|
56
|
+
const { regenerateCommand } = await import('../library.js');
|
|
57
|
+
await regenerateCommand({});
|
|
58
|
+
} else {
|
|
59
|
+
const { validateCommand } = await import('../validate.js');
|
|
60
|
+
const batch = scenario === 'batch-missing' || scenario.startsWith('footprint-identity-');
|
|
61
|
+
await validateCommand(batch ? undefined : 'C123', {
|
|
62
|
+
json: scenario !== 'text-missing',
|
|
63
|
+
all: batch,
|
|
64
|
+
symbolOnly: symbolScenario,
|
|
65
|
+
footprintOnly: manufacturing,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
@@ -1,6 +1,4 @@
|
|
|
1
1
|
import { describe, expect, it, mock, spyOn } from 'bun:test';
|
|
2
|
-
import { readFileSync } from 'fs';
|
|
3
|
-
import { join } from 'path';
|
|
4
2
|
|
|
5
3
|
const mockDetails = {
|
|
6
4
|
lcscId: 'C123',
|
|
@@ -193,11 +191,4 @@ describe('agent JSON CLI output', () => {
|
|
|
193
191
|
write.mockRestore();
|
|
194
192
|
});
|
|
195
193
|
|
|
196
|
-
it('validate --json has a quiet JSON path before human output', () => {
|
|
197
|
-
const source = readFileSync(join(import.meta.dir, 'validate.ts'), 'utf-8');
|
|
198
|
-
|
|
199
|
-
expect(source).toContain('const spinner = options.json ? null : p.spinner()');
|
|
200
|
-
expect(source).toContain('printJson({');
|
|
201
|
-
expect(source.indexOf('if (options.json)')).toBeLessThan(source.indexOf('console.log(chalk.bold'));
|
|
202
|
-
});
|
|
203
194
|
});
|
package/src/commands/easyeda.ts
CHANGED
|
@@ -120,7 +120,7 @@ export async function easyedaInstallCommand(
|
|
|
120
120
|
|
|
121
121
|
try {
|
|
122
122
|
// Ensure libraries are set up
|
|
123
|
-
await libraryService.ensureGlobalTables()
|
|
123
|
+
if (!options.projectPath) await libraryService.ensureGlobalTables()
|
|
124
124
|
|
|
125
125
|
// Install the component
|
|
126
126
|
const result = await libraryService.install(uuid, {
|
|
@@ -161,10 +161,10 @@ export async function easyedaInstallCommand(
|
|
|
161
161
|
return
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
-
// If UUID provided
|
|
164
|
+
// If UUID provided, launch TUI to fetch and display.
|
|
165
165
|
if (uuid) {
|
|
166
166
|
// Launch TUI at EasyEDA info screen - let it fetch the component
|
|
167
|
-
await renderApp('easyeda-info', { uuid })
|
|
167
|
+
await renderApp('easyeda-info', { uuid, installOptions: options })
|
|
168
168
|
return
|
|
169
169
|
}
|
|
170
170
|
|
|
@@ -198,5 +198,5 @@ export async function easyedaInstallCommand(
|
|
|
198
198
|
|
|
199
199
|
// Clear the "Searching..." line and launch interactive UI
|
|
200
200
|
process.stdout.write('\x1b[1A\x1b[2K')
|
|
201
|
-
await renderApp('search', { query: query as string, results })
|
|
201
|
+
await renderApp('search', { query: query as string, results, searchOptions, installOptions: options })
|
|
202
202
|
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
function run(scenario: string) {
|
|
6
|
+
// Isolate module mocks and process.exit from other CLI/core tests.
|
|
7
|
+
return spawnSync(process.execPath, [join(import.meta.dir, '__fixtures__/failure-scenarios.ts'), scenario], {
|
|
8
|
+
encoding: 'utf8',
|
|
9
|
+
timeout: 15_000,
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe('command failure exit status', () => {
|
|
14
|
+
it('reports failed regenerations and exits unsuccessfully', () => {
|
|
15
|
+
const result = run('regenerate');
|
|
16
|
+
expect(result.status).toBe(1);
|
|
17
|
+
expect(result.stdout).toContain('C123');
|
|
18
|
+
expect(result.stdout).toContain('conversion failed');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('does not pass absent requested reference SVGs in JSON', () => {
|
|
22
|
+
const result = run('missing');
|
|
23
|
+
expect(result.status).toBe(1);
|
|
24
|
+
expect(JSON.parse(result.stdout)).toMatchObject({
|
|
25
|
+
success: false,
|
|
26
|
+
result: { passed: false, error: expect.stringContaining('footprint, symbol') },
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('reports absent requested references in text', () => {
|
|
31
|
+
const result = run('text-missing');
|
|
32
|
+
expect(result.status).toBe(1);
|
|
33
|
+
expect(result.stdout).toContain('Missing reference SVG: footprint, symbol');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('counts missing references as batch errors instead of passes', () => {
|
|
37
|
+
const result = run('batch-missing');
|
|
38
|
+
expect(result.status).toBe(1);
|
|
39
|
+
expect(JSON.parse(result.stdout)).toMatchObject({
|
|
40
|
+
success: false,
|
|
41
|
+
summary: { total: 1, passed: 0, failed: 0, errors: 1 },
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('allows an absent footprint reference when its check was disabled', () => {
|
|
46
|
+
const result = run('disabled-footprint');
|
|
47
|
+
expect(result.status).toBe(0);
|
|
48
|
+
expect(JSON.parse(result.stdout)).toMatchObject({
|
|
49
|
+
success: true,
|
|
50
|
+
result: { passed: true, footprint: null, symbol: { passed: true } },
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('rejects symbol pin displacement through the real comparator', () => {
|
|
55
|
+
const result = run('symbol-moved');
|
|
56
|
+
expect(result.status).toBe(1);
|
|
57
|
+
const output = JSON.parse(result.stdout);
|
|
58
|
+
expect(output.success).toBe(false);
|
|
59
|
+
expect(output.result.symbol.errors.some((e: { field: string }) => e.field === 'position')).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('does not pass symbol pins with unknown reference direction', () => {
|
|
63
|
+
const result = run('symbol-unverified');
|
|
64
|
+
expect(result.status).toBe(1);
|
|
65
|
+
const output = JSON.parse(result.stdout);
|
|
66
|
+
expect(output.success).toBe(false);
|
|
67
|
+
expect(output.result.symbol.errors.some((e: { field: string }) => e.field === 'coverage')).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('rejects a manufacturing position mismatch through the real comparator', () => {
|
|
71
|
+
const result = run('footprint-moved');
|
|
72
|
+
expect(result.status).toBe(1);
|
|
73
|
+
const output = JSON.parse(result.stdout);
|
|
74
|
+
expect(output.success).toBe(false);
|
|
75
|
+
expect(output.result.footprint.errors.some((e: { field: string }) => e.field === 'position')).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('does not claim success for an unverified reference copper layer', () => {
|
|
79
|
+
const result = run('footprint-unverified');
|
|
80
|
+
expect(result.status).toBe(1);
|
|
81
|
+
const output = JSON.parse(result.stdout);
|
|
82
|
+
expect(output.success).toBe(false);
|
|
83
|
+
expect(output.result.footprint.errors.some((e: { field: string }) => e.field === 'coverage')).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('validates matching, fully described manufacturing geometry', () => {
|
|
87
|
+
const result = run('footprint-matching');
|
|
88
|
+
expect(result.status).toBe(0);
|
|
89
|
+
expect(JSON.parse(result.stdout)).toMatchObject({
|
|
90
|
+
success: true,
|
|
91
|
+
result: { passed: true, footprint: { passed: true, errors: [] } },
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it.each(['mpn', 'package'])('rejects a changed fixture %s during batch validation', (field) => {
|
|
96
|
+
const result = run(`footprint-identity-${field}`);
|
|
97
|
+
expect(result.status).toBe(1);
|
|
98
|
+
const output = JSON.parse(result.stdout);
|
|
99
|
+
expect(output).toMatchObject({
|
|
100
|
+
success: false,
|
|
101
|
+
summary: { total: 1, passed: 0, failed: 0, errors: 1 },
|
|
102
|
+
});
|
|
103
|
+
expect(output.results[0].error).toMatch(field === 'mpn' ? /Fixture MPN differs/ : /Fixture package differs/);
|
|
104
|
+
});
|
|
105
|
+
});
|
package/src/commands/install.ts
CHANGED
|
@@ -56,7 +56,7 @@ export async function installCommand(id: string | undefined, options: InstallOpt
|
|
|
56
56
|
|
|
57
57
|
try {
|
|
58
58
|
// Ensure libraries are set up
|
|
59
|
-
await libraryService.ensureGlobalTables();
|
|
59
|
+
if (!options.projectPath) await libraryService.ensureGlobalTables();
|
|
60
60
|
|
|
61
61
|
// Install the component
|
|
62
62
|
const result = await libraryService.install(id, {
|
|
@@ -97,7 +97,7 @@ export async function installCommand(id: string | undefined, options: InstallOpt
|
|
|
97
97
|
return;
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
-
// If ID provided
|
|
100
|
+
// If ID provided, fetch component and launch TUI for install.
|
|
101
101
|
if (id) {
|
|
102
102
|
console.log(`Fetching component ${id}...`);
|
|
103
103
|
|
|
@@ -105,7 +105,7 @@ export async function installCommand(id: string | undefined, options: InstallOpt
|
|
|
105
105
|
const details = await componentService.getDetails(id);
|
|
106
106
|
// Clear the "Fetching..." line and launch interactive UI
|
|
107
107
|
process.stdout.write('\x1b[1A\x1b[2K');
|
|
108
|
-
await renderApp('info', { componentId: id, component: details
|
|
108
|
+
await renderApp('info', { componentId: id, component: details, installOptions: options });
|
|
109
109
|
} catch (error) {
|
|
110
110
|
console.error(`Failed to fetch component: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
111
111
|
process.exit(1);
|
|
@@ -147,5 +147,5 @@ export async function installCommand(id: string | undefined, options: InstallOpt
|
|
|
147
147
|
|
|
148
148
|
// Clear the "Searching..." line and launch interactive UI
|
|
149
149
|
process.stdout.write('\x1b[1A\x1b[2K');
|
|
150
|
-
await renderApp('search', { query: query as string, results });
|
|
150
|
+
await renderApp('search', { query: query as string, results, searchOptions, installOptions: options });
|
|
151
151
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
|
|
7
|
+
describe('interactive install artifacts', () => {
|
|
8
|
+
for (const [scenario, behavior] of [
|
|
9
|
+
['lcsc', 'reinstalls the selected LCSC project artifact with downloaded 3D bytes through no-ID search'],
|
|
10
|
+
['community', 'ignores global community installation status and installs/reinstalls locally without 3D through no-ID search'],
|
|
11
|
+
]) {
|
|
12
|
+
it(behavior, () => {
|
|
13
|
+
// Core services and Ink are real; only upstream clients and terminal/prompt IO are substituted.
|
|
14
|
+
const root = mkdtempSync(join(tmpdir(), 'jlc-interactive-install-'));
|
|
15
|
+
try {
|
|
16
|
+
const result = spawnSync(process.execPath, [
|
|
17
|
+
join(import.meta.dir, '../app/__fixtures__/interactive-install-scenarios.tsx'), scenario, root,
|
|
18
|
+
], {
|
|
19
|
+
encoding: 'utf8',
|
|
20
|
+
timeout: 15_000,
|
|
21
|
+
env: { ...process.env, HOME: join(root, 'home'), USERPROFILE: join(root, 'home'), XDG_CONFIG_HOME: join(root, 'config') },
|
|
22
|
+
});
|
|
23
|
+
expect({ status: result.status, stderr: result.stderr }).toEqual({ status: 0, stderr: '' });
|
|
24
|
+
} finally {
|
|
25
|
+
rmSync(root, { recursive: true, force: true });
|
|
26
|
+
}
|
|
27
|
+
}, 20_000);
|
|
28
|
+
}
|
|
29
|
+
});
|
package/src/commands/library.ts
CHANGED
|
@@ -91,5 +91,6 @@ export async function regenerateCommand(options: RegenerateOptions): Promise<voi
|
|
|
91
91
|
for (const comp of result.components.filter(c => c.status === 'failed')) {
|
|
92
92
|
console.log(chalk.red(` • ${comp.name} (${comp.id}): ${comp.error}`));
|
|
93
93
|
}
|
|
94
|
+
process.exitCode = 1;
|
|
94
95
|
}
|
|
95
96
|
}
|
package/src/commands/search.ts
CHANGED
|
@@ -58,7 +58,7 @@ export async function searchCommand(query: string, options: SearchCommandOptions
|
|
|
58
58
|
|
|
59
59
|
// Clear the "Searching..." line and launch interactive UI
|
|
60
60
|
process.stdout.write('\x1b[1A\x1b[2K');
|
|
61
|
-
await renderApp('search', { query, results });
|
|
61
|
+
await renderApp('search', { query, results, searchOptions: options });
|
|
62
62
|
} catch (error) {
|
|
63
63
|
if (options.json) {
|
|
64
64
|
printJsonError('search_failed', getErrorMessage(error), { retryable: true });
|
package/src/commands/validate.ts
CHANGED
|
@@ -18,7 +18,6 @@ import {
|
|
|
18
18
|
formatSymbolComparisonResult,
|
|
19
19
|
generateValidationReport,
|
|
20
20
|
generateBatchReport,
|
|
21
|
-
TEST_CATEGORIES,
|
|
22
21
|
getAllTestComponents,
|
|
23
22
|
getTestComponentsByCategory,
|
|
24
23
|
getCategoryNames,
|
|
@@ -26,6 +25,7 @@ import {
|
|
|
26
25
|
renderSymbolSvg,
|
|
27
26
|
type ValidationResult,
|
|
28
27
|
type ReportSvgs,
|
|
28
|
+
type TestComponent,
|
|
29
29
|
easyedaClient,
|
|
30
30
|
footprintConverter,
|
|
31
31
|
symbolConverter,
|
|
@@ -46,7 +46,8 @@ interface ValidateOptions {
|
|
|
46
46
|
*/
|
|
47
47
|
async function validateComponent(
|
|
48
48
|
lcscCode: string,
|
|
49
|
-
options: { footprint: boolean; symbol: boolean }
|
|
49
|
+
options: { footprint: boolean; symbol: boolean },
|
|
50
|
+
identity?: Pick<TestComponent, 'expectedMpn' | 'expectedPackage'>
|
|
50
51
|
): Promise<ValidationResult> {
|
|
51
52
|
const startTime = Date.now();
|
|
52
53
|
const normalizedCode = lcscCode.replace(/^C/i, '');
|
|
@@ -70,9 +71,18 @@ async function validateComponent(
|
|
|
70
71
|
error: 'Failed to fetch component data from EasyEDA',
|
|
71
72
|
};
|
|
72
73
|
}
|
|
74
|
+
if (identity?.expectedMpn !== undefined && componentData.info.name !== identity.expectedMpn) {
|
|
75
|
+
throw new Error(`Fixture MPN differs: expected ${identity.expectedMpn}, got ${componentData.info.name}`);
|
|
76
|
+
}
|
|
77
|
+
if (identity?.expectedPackage !== undefined && componentData.footprint.name !== identity.expectedPackage) {
|
|
78
|
+
throw new Error(`Fixture package differs: expected ${identity.expectedPackage}, got ${componentData.footprint.name}`);
|
|
79
|
+
}
|
|
73
80
|
|
|
74
81
|
let footprintResult = null;
|
|
75
82
|
let symbolResult = null;
|
|
83
|
+
const missingReferences: string[] = [];
|
|
84
|
+
if (options.footprint && !reference.footprintSvg) missingReferences.push('footprint');
|
|
85
|
+
if (options.symbol && !reference.symbolSvg) missingReferences.push('symbol');
|
|
76
86
|
|
|
77
87
|
// Validate footprint
|
|
78
88
|
if (options.footprint && reference.footprintSvg) {
|
|
@@ -81,10 +91,7 @@ async function validateComponent(
|
|
|
81
91
|
const refData = extractFromReferenceSVG(reference.footprintSvg);
|
|
82
92
|
const genData = extractFromKiCadFootprint(kicadContent);
|
|
83
93
|
|
|
84
|
-
footprintResult = compareFootprints(refData, genData
|
|
85
|
-
sizeWarningsOnly: true,
|
|
86
|
-
positionTolerance: 50,
|
|
87
|
-
});
|
|
94
|
+
footprintResult = compareFootprints(refData, genData);
|
|
88
95
|
}
|
|
89
96
|
|
|
90
97
|
// Validate symbol
|
|
@@ -96,12 +103,11 @@ async function validateComponent(
|
|
|
96
103
|
const refData = extractSymbolFromReferenceSVG(reference.symbolSvg);
|
|
97
104
|
const genData = extractFromKiCadSymbol(kicadContent);
|
|
98
105
|
|
|
99
|
-
symbolResult = compareSymbols(refData, genData
|
|
100
|
-
positionTolerance: 50,
|
|
101
|
-
});
|
|
106
|
+
symbolResult = compareSymbols(refData, genData);
|
|
102
107
|
}
|
|
103
108
|
|
|
104
109
|
const passed =
|
|
110
|
+
missingReferences.length === 0 &&
|
|
105
111
|
(!footprintResult || footprintResult.passed) && (!symbolResult || symbolResult.passed);
|
|
106
112
|
|
|
107
113
|
return {
|
|
@@ -112,6 +118,9 @@ async function validateComponent(
|
|
|
112
118
|
symbol: symbolResult,
|
|
113
119
|
timestamp: new Date(),
|
|
114
120
|
durationMs: Date.now() - startTime,
|
|
121
|
+
...(missingReferences.length > 0
|
|
122
|
+
? { error: `Missing reference SVG: ${missingReferences.join(', ')}` }
|
|
123
|
+
: {}),
|
|
115
124
|
};
|
|
116
125
|
} catch (error) {
|
|
117
126
|
return {
|
|
@@ -287,13 +296,13 @@ export async function validateCommand(
|
|
|
287
296
|
const result = await validateComponent(component.lcsc, {
|
|
288
297
|
footprint: validateFootprint,
|
|
289
298
|
symbol: validateSymbol,
|
|
290
|
-
});
|
|
299
|
+
}, component);
|
|
291
300
|
|
|
292
301
|
results.push(result);
|
|
293
302
|
|
|
294
303
|
if (result.error) {
|
|
295
304
|
errors++;
|
|
296
|
-
if (!options.json) console.log(chalk.yellow(
|
|
305
|
+
if (!options.json) console.log(chalk.yellow(`ERROR: ${result.error}`));
|
|
297
306
|
} else if (result.passed) {
|
|
298
307
|
passed++;
|
|
299
308
|
if (!options.json) console.log(chalk.green('✓ PASS'));
|