@openwebf/webf 0.24.0 → 0.24.2
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/README.md +9 -1
- package/bin/webf.js +9 -1
- package/dist/agents.js +245 -0
- package/dist/analyzer.js +183 -121
- package/dist/commands.js +20 -9
- package/dist/generator.js +39 -16
- package/package.json +2 -1
- package/src/agents.ts +267 -0
- package/src/analyzer.ts +186 -114
- package/src/commands.ts +22 -12
- package/src/generator.ts +32 -12
- package/templates/module.package.json.tpl +17 -6
- package/templates/{module.tsup.config.ts.tpl → module.tsdown.config.ts.tpl} +2 -7
- package/templates/react.component.tsx.tpl +18 -2
- package/templates/react.package.json.tpl +16 -4
- package/templates/{react.tsup.config.ts.tpl → react.tsdown.config.ts.tpl} +2 -4
- package/test/agents-init.test.ts +80 -0
- package/test/analyzer.test.ts +45 -1
- package/test/commands.test.ts +4 -4
- package/test/generator.test.ts +37 -0
- package/test/react.test.ts +5 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { agentsInitCommand } from '../src/agents';
|
|
5
|
+
|
|
6
|
+
function readText(filePath: string) {
|
|
7
|
+
return fs.readFileSync(filePath, 'utf-8');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe('webf agents init', () => {
|
|
11
|
+
let tempDir: string;
|
|
12
|
+
let consoleSpy: jest.SpyInstance;
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webf-agents-init-'));
|
|
16
|
+
consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
consoleSpy.mockRestore();
|
|
21
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('initializes a new project with skills and CLAUDE.md', async () => {
|
|
25
|
+
await agentsInitCommand(tempDir);
|
|
26
|
+
|
|
27
|
+
expect(fs.existsSync(path.join(tempDir, 'CLAUDE.md'))).toBe(true);
|
|
28
|
+
expect(fs.existsSync(path.join(tempDir, '.claude', 'skills', 'webf-quickstart', 'SKILL.md'))).toBe(true);
|
|
29
|
+
|
|
30
|
+
const claude = readText(path.join(tempDir, 'CLAUDE.md'));
|
|
31
|
+
expect(claude).toContain('<!-- webf-agents:init start -->');
|
|
32
|
+
expect(claude).toContain('Source: `@openwebf/claude-code-skills@');
|
|
33
|
+
expect(claude).toContain('### Skills');
|
|
34
|
+
|
|
35
|
+
const version = readText(path.join(tempDir, '.claude', 'webf-claude-code-skills.version'));
|
|
36
|
+
expect(version).toMatch(/^@openwebf\/claude-code-skills@/);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('updates an existing CLAUDE.md without removing existing content', async () => {
|
|
40
|
+
fs.writeFileSync(path.join(tempDir, 'CLAUDE.md'), '# Existing\n\nHello\n', 'utf-8');
|
|
41
|
+
|
|
42
|
+
await agentsInitCommand(tempDir);
|
|
43
|
+
|
|
44
|
+
const claude = readText(path.join(tempDir, 'CLAUDE.md'));
|
|
45
|
+
expect(claude).toContain('# Existing');
|
|
46
|
+
expect(claude).toContain('Hello');
|
|
47
|
+
expect(claude).toContain('<!-- webf-agents:init start -->');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('is idempotent (does not duplicate the injected block)', async () => {
|
|
51
|
+
await agentsInitCommand(tempDir);
|
|
52
|
+
await agentsInitCommand(tempDir);
|
|
53
|
+
|
|
54
|
+
const claude = readText(path.join(tempDir, 'CLAUDE.md'));
|
|
55
|
+
const occurrences = claude.split('<!-- webf-agents:init start -->').length - 1;
|
|
56
|
+
expect(occurrences).toBe(1);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('backs up modified skill files before overwriting', async () => {
|
|
60
|
+
const skillDir = path.join(tempDir, '.claude', 'skills', 'webf-quickstart');
|
|
61
|
+
fs.mkdirSync(skillDir, { recursive: true });
|
|
62
|
+
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), 'local edits', 'utf-8');
|
|
63
|
+
|
|
64
|
+
await agentsInitCommand(tempDir);
|
|
65
|
+
|
|
66
|
+
const files = fs.readdirSync(skillDir);
|
|
67
|
+
expect(files.some(f => f.startsWith('SKILL.md.bak.'))).toBe(true);
|
|
68
|
+
|
|
69
|
+
const sourceSkill = fs.readFileSync(
|
|
70
|
+
path.join(
|
|
71
|
+
path.dirname(require.resolve('@openwebf/claude-code-skills/package.json')),
|
|
72
|
+
'webf-quickstart',
|
|
73
|
+
'SKILL.md'
|
|
74
|
+
),
|
|
75
|
+
'utf-8'
|
|
76
|
+
);
|
|
77
|
+
const installedSkill = readText(path.join(skillDir, 'SKILL.md'));
|
|
78
|
+
expect(installedSkill).toBe(sourceSkill);
|
|
79
|
+
});
|
|
80
|
+
});
|
package/test/analyzer.test.ts
CHANGED
|
@@ -366,5 +366,49 @@ describe('Analyzer', () => {
|
|
|
366
366
|
expect(classObj.props[1].type.value).toBe(FunctionArgumentType.promise);
|
|
367
367
|
expect(classObj.props[2].type.value).toBe(FunctionArgumentType.int);
|
|
368
368
|
});
|
|
369
|
+
|
|
370
|
+
it('should preserve complex CustomEvent generic types', () => {
|
|
371
|
+
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
|
372
|
+
|
|
373
|
+
const blob = new IDLBlob('/test/source.d.ts', '/test/target', 'test', 'test');
|
|
374
|
+
blob.raw = `
|
|
375
|
+
interface VideoError {
|
|
376
|
+
code: int;
|
|
377
|
+
message: string;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
interface Events {
|
|
381
|
+
error: CustomEvent<VideoError>;
|
|
382
|
+
loadedmetadata: CustomEvent<{ duration: double; videoWidth: int; videoHeight: int }>;
|
|
383
|
+
volumechange: CustomEvent<{ volume: double; muted: boolean }>;
|
|
384
|
+
}
|
|
385
|
+
`;
|
|
386
|
+
|
|
387
|
+
const propertyCollector = {
|
|
388
|
+
properties: new Set<string>(),
|
|
389
|
+
files: new Set<string>(),
|
|
390
|
+
interfaces: new Set<string>(),
|
|
391
|
+
};
|
|
392
|
+
const unionTypeCollector = {
|
|
393
|
+
types: new Set<ParameterType[]>(),
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
analyzer(blob, propertyCollector, unionTypeCollector);
|
|
397
|
+
|
|
398
|
+
const eventsObj = blob.objects.find(o => (o as ClassObject).name === 'Events') as ClassObject;
|
|
399
|
+
expect(eventsObj).toBeDefined();
|
|
400
|
+
|
|
401
|
+
const errorProp = eventsObj.props.find(p => p.name === 'error');
|
|
402
|
+
expect(errorProp?.type.value).toBe('CustomEvent<VideoError>');
|
|
403
|
+
|
|
404
|
+
const loadedProp = eventsObj.props.find(p => p.name === 'loadedmetadata');
|
|
405
|
+
expect(loadedProp?.type.value).toBe('CustomEvent<{ duration: number; videoWidth: number; videoHeight: number }>');
|
|
406
|
+
|
|
407
|
+
const volumeProp = eventsObj.props.find(p => p.name === 'volumechange');
|
|
408
|
+
expect(volumeProp?.type.value).toBe('CustomEvent<{ volume: number; muted: boolean }>');
|
|
409
|
+
|
|
410
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
411
|
+
warnSpy.mockRestore();
|
|
412
|
+
});
|
|
369
413
|
});
|
|
370
|
-
});
|
|
414
|
+
});
|
package/test/commands.test.ts
CHANGED
|
@@ -24,7 +24,7 @@ mockFs.readFileSync = jest.fn().mockImplementation((filePath: any) => {
|
|
|
24
24
|
if (pathStr.includes('gitignore.tpl')) return 'gitignore template';
|
|
25
25
|
if (pathStr.includes('react.package.json.tpl')) return '<%= packageName %> <%= version %> <%= description %>';
|
|
26
26
|
if (pathStr.includes('react.tsconfig.json.tpl')) return 'react tsconfig';
|
|
27
|
-
if (pathStr.includes('react.
|
|
27
|
+
if (pathStr.includes('react.tsdown.config.ts.tpl')) return 'tsdown config';
|
|
28
28
|
if (pathStr.includes('react.index.ts.tpl')) return 'index template';
|
|
29
29
|
if (pathStr.includes('vue.package.json.tpl')) return '<%= packageName %> <%= version %> <%= description %>';
|
|
30
30
|
if (pathStr.includes('vue.tsconfig.json.tpl')) return 'vue tsconfig';
|
|
@@ -823,7 +823,7 @@ describe('Commands', () => {
|
|
|
823
823
|
name: 'test-package',
|
|
824
824
|
version: '1.0.0',
|
|
825
825
|
dependencies: { react: '^18.0.0' },
|
|
826
|
-
scripts: { build: '
|
|
826
|
+
scripts: { build: 'tsdown' }
|
|
827
827
|
});
|
|
828
828
|
}
|
|
829
829
|
return '';
|
|
@@ -1300,7 +1300,7 @@ describe('Commands', () => {
|
|
|
1300
1300
|
name: 'test-package',
|
|
1301
1301
|
version: '1.0.0',
|
|
1302
1302
|
scripts: {
|
|
1303
|
-
build: '
|
|
1303
|
+
build: 'tsdown'
|
|
1304
1304
|
},
|
|
1305
1305
|
dependencies: { react: '^18.0.0' }
|
|
1306
1306
|
});
|
|
@@ -1349,7 +1349,7 @@ describe('Commands', () => {
|
|
|
1349
1349
|
name: 'test-package',
|
|
1350
1350
|
version: '1.0.0',
|
|
1351
1351
|
scripts: {
|
|
1352
|
-
build: '
|
|
1352
|
+
build: 'tsdown'
|
|
1353
1353
|
},
|
|
1354
1354
|
dependencies: { react: '^18.0.0' }
|
|
1355
1355
|
});
|
package/test/generator.test.ts
CHANGED
|
@@ -395,6 +395,43 @@ describe('Generator', () => {
|
|
|
395
395
|
const indexWrite = mockFs.writeFileSync.mock.calls.find(call => call[0].toString().includes('index.ts'));
|
|
396
396
|
expect(indexWrite).toBeUndefined();
|
|
397
397
|
});
|
|
398
|
+
|
|
399
|
+
it('should merge type-only Element exports into existing index.ts', async () => {
|
|
400
|
+
mockAnalyzer.analyzer.mockImplementation((blob: any) => {
|
|
401
|
+
const props = new ClassObject();
|
|
402
|
+
props.name = blob.filename === 'test' ? 'TestProperties' : 'ComponentProperties';
|
|
403
|
+
const events = new ClassObject();
|
|
404
|
+
events.name = blob.filename === 'test' ? 'TestEvents' : 'ComponentEvents';
|
|
405
|
+
blob.objects = [props, events];
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
mockFs.existsSync.mockImplementation((p: any) => {
|
|
409
|
+
const s = p.toString();
|
|
410
|
+
if (s.includes(path.join('/test/target', 'src', 'index.ts'))) return true;
|
|
411
|
+
return true;
|
|
412
|
+
});
|
|
413
|
+
mockFs.readFileSync.mockImplementation((p: any) => {
|
|
414
|
+
const s = p.toString();
|
|
415
|
+
if (s.includes(path.join('/test/target', 'src', 'index.ts'))) {
|
|
416
|
+
return '/* empty index scaffold */\n';
|
|
417
|
+
}
|
|
418
|
+
return 'test content';
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
await reactGen({
|
|
422
|
+
source: '/test/source',
|
|
423
|
+
target: '/test/target',
|
|
424
|
+
command: 'test command'
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
const indexWrite = mockFs.writeFileSync.mock.calls.find(call => call[0].toString().includes('index.ts'));
|
|
428
|
+
expect(indexWrite).toBeDefined();
|
|
429
|
+
const content = String(indexWrite![1]);
|
|
430
|
+
expect(content).toContain('export { Test }');
|
|
431
|
+
expect(content).toContain('export type { TestElement }');
|
|
432
|
+
expect(content).toContain('export { Component }');
|
|
433
|
+
expect(content).toContain('export type { ComponentElement }');
|
|
434
|
+
});
|
|
398
435
|
});
|
|
399
436
|
|
|
400
437
|
describe('vueGen', () => {
|
package/test/react.test.ts
CHANGED
|
@@ -184,6 +184,11 @@ describe('React Generator', () => {
|
|
|
184
184
|
// Should include custom props with generated TS types
|
|
185
185
|
expect(result).toContain('title: __webfTypes.dom_string;');
|
|
186
186
|
expect(result).toContain('disabled?: __webfTypes.boolean;');
|
|
187
|
+
|
|
188
|
+
// Element interface should surface properties for refs
|
|
189
|
+
expect(result).toContain('export interface TestComponentElement extends WebFElementWithMethods<{');
|
|
190
|
+
expect(result).toContain('title: __webfTypes.dom_string;');
|
|
191
|
+
expect(result).toContain('disabled?: __webfTypes.boolean;');
|
|
187
192
|
|
|
188
193
|
// And still include standard HTML props
|
|
189
194
|
expect(result).toContain('id?: string;');
|