217 lines
12 KiB
JavaScript
217 lines
12 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { spawnSync } from 'node:child_process';
|
|
import { createHash } from 'node:crypto';
|
|
import { performance } from 'node:perf_hooks';
|
|
import test from 'node:test';
|
|
|
|
const guard = fileURLToPath(new URL('./ai-generation-guard.mjs', import.meta.url));
|
|
function fixture(files, callback) {
|
|
const tempRoot = fs.realpathSync(os.tmpdir());
|
|
const root = fs.mkdtempSync(path.join(tempRoot, 'hm-fast-core-'));
|
|
assert.equal(path.dirname(root), tempRoot);
|
|
try {
|
|
for (const [name, text] of Object.entries({
|
|
'index.html': '<script type="module" src="/src/main.tsx"></script>',
|
|
'src/main.tsx': 'import App from "./App"; import "./styles.css"; export default App;',
|
|
'src/styles.css': '@import "./tokens.css";\n/* app-specific styles are valid */ .custom { color: coral; }',
|
|
'src/tokens.css': '@import "tailwindcss";\n@import "@hm-design/ui/theme.css";',
|
|
'tsconfig.app.json': '{ /* JSONC */ "compilerOptions": { "paths": { "@/*": ["./src/*"] } } }',
|
|
...files,
|
|
})) {
|
|
const dest = path.join(root, name);
|
|
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
fs.writeFileSync(dest, text);
|
|
}
|
|
callback(root);
|
|
} finally {
|
|
// Only the verified mkdtemp directory created by this test is removed.
|
|
assert.equal(path.dirname(path.resolve(root)), tempRoot);
|
|
assert.ok(path.basename(root).startsWith('hm-fast-core-'));
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
}
|
|
function run(target, extra, json = true) {
|
|
const started = performance.now();
|
|
const result = spawnSync(process.execPath, [guard, '--target', target, ...extra, ...(json ? ['--json'] : [])], { encoding: 'utf8' });
|
|
assert.ifError(result.error);
|
|
assert.equal(result.stderr, '');
|
|
const report = json ? JSON.parse(result.stdout) : null;
|
|
return { ...result, report, wallMs: Math.round(performance.now() - started) };
|
|
}
|
|
|
|
test('1. normal GNB_ONLY; indirect CSS, aliases, unrelated new UI and unused Stories', () => {
|
|
fixture({
|
|
'src/hm.ts': 'export { GNB as Header, GNBMobileMenu as Menu, Button } from "@hm-design/ui";',
|
|
'src/App.tsx': 'import { Header, Menu, Button } from "@/hm"; import NewUI from "./NewUI";\nexport default () => <><Header><Menu title="menu" /></Header><Button>OK</Button><NewUI /></>;',
|
|
'src/NewUI.tsx': 'import "./new.css"; function Sidebar(){ return <aside className="sb-page-inner custom">New UI</aside> }\nexport default Sidebar;',
|
|
'src/new.css': '.sb-page-inner { color: purple; } .sidebar { display: flex; }',
|
|
'src/Unused.stories.tsx': 'import { GNB, DashboardSidebar } from "@hm-design/ui"; export const Example = () => <><GNB/><DashboardSidebar/></>;',
|
|
}, root => {
|
|
const result = run(root, ['--scope', 'FULL_SCREEN', '--mode', 'GNB_ONLY'], false);
|
|
assert.equal(result.status, 0);
|
|
const lines = result.stdout.trim().split(/\r?\n/);
|
|
assert.deepEqual(lines, ['PASS']);
|
|
console.log(`Normal GNB wall time: ${result.wallMs} ms`);
|
|
});
|
|
});
|
|
|
|
test('2. normal LNB_ONLY; namespace and local alias resolve to the official LNB', () => {
|
|
fixture({
|
|
'src/App.tsx': 'import * as HM from "@hm-design/ui"; const Menu = HM.LNB;\nexport default () => <Menu><HM.LNBNav><HM.LNBItem href="/">Home</HM.LNBItem></HM.LNBNav></Menu>;',
|
|
}, root => {
|
|
const { status, report } = run(root, ['--scope', 'FULL_SCREEN', '--mode', 'LNB_ONLY']);
|
|
assert.equal(status, 0);
|
|
assert.equal(report.status, 'PASS');
|
|
assert.deepEqual(report.checkedHMElements, ['LNB', 'LNBItem', 'LNBNav']);
|
|
assert.ok(report.checkedGroups.every(g => g.status === 'PASS'));
|
|
console.log(`Normal LNB static analysis: ${report.durationMs} ms`);
|
|
});
|
|
});
|
|
|
|
test('3. GNB_ONLY detects a reused official Sidebar through a re-export alias', () => {
|
|
fixture({
|
|
'src/hm.ts': 'export { GNB, DashboardSidebar as Side } from "@hm-design/ui";',
|
|
'src/App.tsx': 'import { GNB, Side } from "./hm";\nexport default () => <><GNB/><Side/></>;',
|
|
}, root => {
|
|
const { status, report } = run(root, ['--scope', 'FULL_SCREEN', '--mode', 'GNB_ONLY']);
|
|
assert.equal(status, 1);
|
|
assert.equal(report.status, 'FAIL');
|
|
assert.ok(report.issues.some(i => i.code === 'NAV-001' && i.file === 'src/App.tsx' && i.line === 2));
|
|
assert.ok(report.checkedHMElements.includes('DashboardSidebar'));
|
|
});
|
|
});
|
|
|
|
test('4. Production Story import, StoryPage helper and Storybook CSS are detected', () => {
|
|
fixture({
|
|
'src/App.tsx': 'import { Button } from "@hm-design/ui";\nimport { Demo } from "./Button.stories";\nimport { StoryPage } from "./story-page";\nexport default () => <StoryPage><Button className="sb-page-inner"/><Demo/></StoryPage>;',
|
|
'src/Button.stories.tsx': 'import { Button } from "@hm-design/ui"; export const Demo = () => <Button/>;',
|
|
'src/story-page.tsx': 'export const StoryPage = ({ children }) => <div>{children}</div>;',
|
|
}, root => {
|
|
const { status, report } = run(root, ['--scope', 'FULL_SCREEN']);
|
|
assert.equal(status, 1);
|
|
for (const code of ['SB-001', 'SB-002', 'SB-003']) assert.ok(report.issues.some(i => i.code === code), code);
|
|
assert.ok(!report.issues.some(i => i.code.startsWith('NAV')));
|
|
});
|
|
});
|
|
|
|
test('5. private paths for both packages and a non-public named export are detected', () => {
|
|
fixture({
|
|
'src/App.tsx': 'import { Button } from "@hm-design/ui/src/components/ui/button";\nimport { CenterTitle } from "@hm-design/center-graphic/src/components/CenterTitle";\nimport { NonPublicComponent } from "@hm-design/ui";\nexport default () => <><Button/><CenterTitle/><NonPublicComponent/></>;',
|
|
}, root => {
|
|
const { status, report } = run(root, ['--scope', 'FULL_SCREEN']);
|
|
assert.equal(status, 1);
|
|
assert.equal(report.issues.filter(i => i.code === 'IMP-001').length, 2);
|
|
assert.ok(report.issues.some(i => i.code === 'IMP-002'));
|
|
});
|
|
});
|
|
|
|
test('6. PARTIAL is read-only and never infers a Navigation mode', () => {
|
|
fixture({ 'src/App.tsx': 'import { Button, GNB, GNBMobileMenu } from "@hm-design/ui"; export default () => <GNB><GNBMobileMenu/><Button>OK</Button></GNB>;' }, external => {
|
|
const names = ['src/App.tsx', 'src/main.tsx', 'src/styles.css', 'src/tokens.css', 'tsconfig.app.json', 'index.html'];
|
|
const hashes = () => names.map(name => createHash('sha256').update(fs.readFileSync(path.join(external, name))).digest('hex'));
|
|
const before = hashes();
|
|
const { status, report } = run(external, ['--scope', 'PARTIAL', '--files', 'src/App.tsx']);
|
|
assert.equal(status, 0);
|
|
assert.equal(report.status, 'NOT VERIFIED');
|
|
assert.deepEqual(report.issues.map(i => i.code), ['NAV-003']);
|
|
assert.equal(report.checkedGroups[2].status, 'PASS');
|
|
assert.ok(report.checkedHMElements.includes('Button'));
|
|
assert.ok(report.checkedHMElements.includes('GNB'));
|
|
assert.deepEqual(hashes(), before);
|
|
console.log(`External PARTIAL: ${report.status}; ${report.checkedHMElements.length} HM elements; ${report.durationMs} ms`);
|
|
});
|
|
});
|
|
|
|
test('7. native Select with fake data-slot and import-only reuse fails', () => {
|
|
fixture({ 'src/App.tsx': 'import { Select as Choice } from "@hm-design/ui";\nexport default () => <select data-slot="select"><option>A</option></select>;' }, root => {
|
|
const { status, report } = run(root, ['--scope', 'PARTIAL', '--files', 'src/App.tsx']);
|
|
assert.equal(status, 1);
|
|
for (const code of ['REUSE-001', 'REUSE-002']) assert.ok(report.issues.some(i => i.code === code && i.file === 'src/App.tsx'));
|
|
assert.ok(!report.checkedHMElements.includes('Select'));
|
|
});
|
|
});
|
|
|
|
test('8. fake slots without an HM import, createElement and namespace unused', () => {
|
|
for (const source of [
|
|
'export default () => <div data-slot="select-trigger"/>;',
|
|
'import React from "react"; export default () => React.createElement("select", { "data-slot": "select" });',
|
|
'import * as HM from "@hm-design/ui"; export default () => <div/>;',
|
|
]) fixture({ 'src/App.tsx': source }, root => {
|
|
const { report } = run(root, ['--scope', 'FULL_SCREEN']);
|
|
assert.equal(report.status, 'FAIL');
|
|
assert.ok(report.issues.some(i => /^REUSE-00[12]$/.test(i.code)));
|
|
});
|
|
});
|
|
|
|
test('9. official aliases, namespace, forwarding, types and barrels are not unused', () => {
|
|
fixture({
|
|
'src/hm.ts': 'export { Select as Choice } from "@hm-design/ui";',
|
|
'src/App.tsx': 'import { Choice } from "./hm"; import { Button } from "@hm-design/ui"; import type { ButtonAppearance } from "@hm-design/ui"; import * as HM from "@hm-design/ui"; const Forward = ({ component: Component }) => <Component/>; export default () => <><Choice/><HM.Input/><Forward component={Button}/></>;',
|
|
}, root => {
|
|
const { report } = run(root, ['--scope', 'FULL_SCREEN']);
|
|
assert.equal(report.status, 'PASS');
|
|
});
|
|
});
|
|
|
|
test('10. native controls and CSS customization need review, not FAIL', () => {
|
|
fixture({ 'src/App.tsx': 'export default () => <select className="hm-select"/>;', 'src/styles.css': '.hm-select, [data-slot="select-trigger"] { color: red; }' }, root => {
|
|
const { report } = run(root, ['--scope', 'FULL_SCREEN']);
|
|
assert.equal(report.status, 'NOT VERIFIED');
|
|
for (const code of ['REUSE-003', 'REUSE-004']) assert.ok(report.issues.some(i => i.code === code));
|
|
assert.ok(report.issues.every(i => i.severity !== 'FAIL'));
|
|
});
|
|
});
|
|
|
|
test('11. PARTIAL excludes unchanged sibling errors', () => {
|
|
fixture({
|
|
'src/App.tsx': 'import { Button } from "@hm-design/ui"; import Old from "./Old"; export default () => <><Button/><Old/></>;',
|
|
'src/Old.tsx': 'export default () => <select data-slot="select"/>;',
|
|
}, root => {
|
|
assert.equal(run(root, ['--scope', 'FULL_SCREEN']).report.status, 'FAIL');
|
|
assert.equal(run(root, ['--scope', 'PARTIAL', '--files', 'src/App.tsx']).report.status, 'PASS');
|
|
// Selecting a leaf checks that leaf; its unchanged siblings stay outside scope.
|
|
assert.equal(run(root, ['--scope', 'PARTIAL', '--files', 'src/Old.tsx']).report.issues.some(i => i.file === 'src/App.tsx' && i.code.startsWith('REUSE')), false);
|
|
});
|
|
});
|
|
|
|
test('12. final HTML imitation is flagged; React preview remains browser NOT VERIFIED', () => {
|
|
fixture({
|
|
'preview.html': '<select data-slot="select"><option>A</option></select>',
|
|
'react-preview.html': '<div id="root"></div><script type="module" src="/src/main.tsx"></script>',
|
|
'src/main.tsx': 'import { createRoot } from "react-dom/client"; import App from "./App"; import "./styles.css"; createRoot(document.getElementById("root")).render(<App/>);',
|
|
'src/App.tsx': 'import { Button } from "@hm-design/ui"; export default () => <Button/>;',
|
|
}, root => {
|
|
const fake = run(root, ['--scope', 'FULL_SCREEN', '--result', 'preview.html']).report;
|
|
assert.equal(fake.status, 'FAIL');
|
|
assert.ok(fake.issues.some(i => i.code === 'RENDER-001'));
|
|
const real = run(root, ['--scope', 'FULL_SCREEN', '--result', 'react-preview.html']).report;
|
|
assert.equal(real.status, 'NOT VERIFIED');
|
|
assert.deepEqual(real.issues.map(i => i.code), ['RENDER-002']);
|
|
});
|
|
});
|
|
|
|
test('13. design system implementation is exempt', () => {
|
|
const internal = fileURLToPath(new URL('../packages/ui/src/components/ui/select.tsx', import.meta.url));
|
|
const { report } = run(internal, ['--scope', 'PARTIAL']);
|
|
assert.ok(!report.issues.some(i => i.severity === 'FAIL'));
|
|
});
|
|
|
|
test('14. wrappers and inline HTML strings are not falsely marked FAIL; unused barrel consumer fails', () => {
|
|
fixture({
|
|
'src/hm.ts': 'export { Select as Choice } from "@hm-design/ui";',
|
|
'src/App.tsx': 'import { Choice } from "./hm"; const Wrap = props => <Choice {...props}/>; export default () => <Wrap data-slot="select"/>;',
|
|
'preview.html': '<script>const example = \'<select data-slot="select"></select>\';</script>',
|
|
}, root => {
|
|
const wrapped = run(root, ['--scope', 'FULL_SCREEN']).report;
|
|
assert.equal(wrapped.status, 'NOT VERIFIED');
|
|
assert.ok(wrapped.issues.every(i => i.severity !== 'FAIL'));
|
|
assert.ok(!run(root, ['--scope', 'FULL_SCREEN', '--result', 'preview.html']).report.issues.some(i => i.code === 'REUSE-001'));
|
|
fs.writeFileSync(path.join(root, 'src/App.tsx'), 'import { Choice } from "./hm"; export default () => <div/>;');
|
|
assert.ok(run(root, ['--scope', 'PARTIAL', '--files', 'src/App.tsx']).report.issues.some(i => i.code === 'REUSE-002'));
|
|
});
|
|
});
|