@givolt/i3-composer-agent 2.3.0 → 2.3.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.
Files changed (122) hide show
  1. package/.agents/plugins/marketplace.json +1 -1
  2. package/.claude/commands/i3-prototype.md +5 -7
  3. package/.claude-plugin/marketplace.json +1 -1
  4. package/.claude-plugin/plugin.json +1 -1
  5. package/.codex-plugin/plugin.json +1 -1
  6. package/.gemini/commands/i3-prototype.toml +5 -7
  7. package/README.md +12 -7
  8. package/bin/i3-composer-agent.cjs +33 -2
  9. package/bin/install.cjs +14 -0
  10. package/bin/native-migration.cjs +27 -3
  11. package/commands/i3-prototype.toml +5 -7
  12. package/docs/codebuddy-setup.md +65 -2
  13. package/docs/codex-setup.md +4 -0
  14. package/docs/other-hosts.md +1 -1
  15. package/docs/ui-prototype-native/MAIN-SESSION-REPORT.md +66 -0
  16. package/docs/ui-prototype-native/USAGE.md +91 -0
  17. package/docs/workbuddy-setup.md +4 -0
  18. package/evals/cases/i3-frontend-ui-engineering.json +16 -0
  19. package/evals/cases/i3-ui-prototype.json +109 -0
  20. package/evals/cases/i3-using-agent-skills.json +28 -0
  21. package/evals/fixtures/i3-ui-prototype/admin-list/assets/reference.css +3 -0
  22. package/evals/fixtures/i3-ui-prototype/admin-list/assets/reference.js +8 -0
  23. package/evals/fixtures/i3-ui-prototype/admin-list/capability-definition.json +67 -0
  24. package/evals/fixtures/i3-ui-prototype/admin-list/index.html +89 -0
  25. package/evals/fixtures/i3-ui-prototype/admin-list/readme.md +52 -0
  26. package/evals/fixtures/i3-ui-prototype/admin-list/template.json +8 -0
  27. package/evals/zh-CN/cases/i3-frontend-ui-engineering.json +17 -0
  28. package/evals/zh-CN/cases/i3-ui-prototype.json +115 -0
  29. package/evals/zh-CN/cases/i3-using-agent-skills.json +30 -0
  30. package/package.json +35 -1
  31. package/plugin.json +1 -1
  32. package/scripts/design-provider-discovery-test.js +190 -0
  33. package/scripts/design-provider-test.js +75 -0
  34. package/scripts/enterprise-template-test.js +67 -1
  35. package/scripts/lib/prototype-fixture.cjs +17 -0
  36. package/scripts/prototype-locations-test.js +58 -0
  37. package/scripts/ui-prototype-content-test.js +123 -0
  38. package/scripts/ui-prototype-dependencies-test.js +213 -0
  39. package/scripts/ui-prototype-devtools-test.js +53 -0
  40. package/scripts/ui-prototype-native-test.js +504 -0
  41. package/scripts/ui-prototype-template-test.js +261 -0
  42. package/scripts/ui-prototype-test.js +10 -0
  43. package/skills/i3-ui-prototype/SKILL.md +142 -23
  44. package/skills/i3-ui-prototype/assets/capabilities/management.json +294 -0
  45. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/acceptance.json +51 -0
  46. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/component-map.json +13 -0
  47. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/index.html +11 -0
  48. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/package-lock.json +2298 -0
  49. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/package.json +22 -0
  50. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/public/prototype-config.json +6 -0
  51. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/src/App.vue +136 -0
  52. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/src/components/DeviceEditor.vue +111 -0
  53. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/src/config.ts +19 -0
  54. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/src/content.ts +28 -0
  55. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/src/domain/devices.ts +50 -0
  56. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/src/domain/theme.ts +25 -0
  57. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/src/main.ts +18 -0
  58. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/src/style.css +50 -0
  59. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/src/useDevices.ts +66 -0
  60. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/tests/devices.test.ts +44 -0
  61. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/tests/theme.test.ts +26 -0
  62. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/tsconfig.json +15 -0
  63. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/vite.config.ts +4 -0
  64. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/profile.json +174 -0
  65. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/readme.md +61 -0
  66. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/index.html +11 -0
  67. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/package-lock.json +2298 -0
  68. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/package.json +22 -0
  69. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/public/prototype-config.json +21 -0
  70. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/src/App.vue +51 -0
  71. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/src/config.ts +18 -0
  72. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/src/domain/theme.ts +25 -0
  73. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/src/main.ts +18 -0
  74. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/src/style.css +13 -0
  75. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/tests/theme.test.ts +26 -0
  76. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/tsconfig.json +15 -0
  77. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/vite.config.ts +4 -0
  78. package/skills/i3-ui-prototype/assets/optional-skills/host-targets.json +737 -0
  79. package/skills/i3-ui-prototype/assets/optional-skills/sources.json +27 -0
  80. package/skills/i3-ui-prototype/assets/templates/catalog.js +149 -0
  81. package/skills/i3-ui-prototype/assets/templates/catalog.json +3 -2
  82. package/skills/i3-ui-prototype/assets/templates/enterprise-admin/capability-definition.json +294 -0
  83. package/skills/i3-ui-prototype/assets/templates/enterprise-admin/design-system.md +3 -3
  84. package/skills/i3-ui-prototype/assets/templates/enterprise-admin/index.html +507 -2
  85. package/skills/i3-ui-prototype/assets/templates/enterprise-admin/manifest.json +1 -1
  86. package/skills/i3-ui-prototype/assets/templates/enterprise-admin/offline-build.json +265 -0
  87. package/skills/i3-ui-prototype/assets/templates/enterprise-admin/offline.js +8888 -0
  88. package/skills/i3-ui-prototype/assets/templates/enterprise-admin/template.json +9 -0
  89. package/skills/i3-ui-prototype/assets/templates/gallery/index.html +6 -6
  90. package/skills/i3-ui-prototype/assets/templates/index.html +3 -0
  91. package/skills/i3-ui-prototype/assets/templates/serve-gallery.cjs +7 -0
  92. package/skills/i3-ui-prototype/references/artifact-contract.md +25 -0
  93. package/skills/i3-ui-prototype/references/content-template.md +76 -0
  94. package/skills/i3-ui-prototype/references/enterprise-admin.md +9 -3
  95. package/skills/i3-ui-prototype/references/entry-contract.md +71 -0
  96. package/skills/i3-ui-prototype/references/expert-adapter.md +39 -5
  97. package/skills/i3-ui-prototype/references/framework-contract.md +76 -0
  98. package/skills/i3-ui-prototype/references/interaction-acceptance.md +165 -0
  99. package/skills/i3-ui-prototype/references/legacy-native.md +58 -0
  100. package/skills/i3-ui-prototype/references/optional-skills.md +59 -0
  101. package/skills/i3-ui-prototype/references/template-adapters.md +1 -1
  102. package/skills/i3-ui-prototype/references/workflow-contract.md +58 -4
  103. package/skills/i3-ui-prototype/scripts/adapters/easel-html.cjs +3 -3
  104. package/skills/i3-ui-prototype/scripts/content-template.cjs +98 -0
  105. package/skills/i3-ui-prototype/scripts/design-provider-discovery.cjs +128 -0
  106. package/skills/i3-ui-prototype/scripts/enterprise-template.cjs +30 -14
  107. package/skills/i3-ui-prototype/scripts/manage-baseline.cjs +1 -1
  108. package/skills/i3-ui-prototype/scripts/native-browser.cjs +785 -0
  109. package/skills/i3-ui-prototype/scripts/native-build.cjs +91 -0
  110. package/skills/i3-ui-prototype/scripts/native-delivery.cjs +91 -0
  111. package/skills/i3-ui-prototype/scripts/native-devtools.cjs +91 -0
  112. package/skills/i3-ui-prototype/scripts/native-files.cjs +35 -0
  113. package/skills/i3-ui-prototype/scripts/native-pilot-lib.cjs +244 -0
  114. package/skills/i3-ui-prototype/scripts/native-pilot.cjs +34 -0
  115. package/skills/i3-ui-prototype/scripts/optional-skill-install.cjs +203 -0
  116. package/skills/i3-ui-prototype/scripts/prototype-dependencies.cjs +81 -0
  117. package/skills/i3-ui-prototype/scripts/prototype-lib.cjs +50 -26
  118. package/skills/i3-ui-prototype/scripts/prototype-locations.cjs +65 -0
  119. package/skills/i3-ui-prototype/scripts/sync-template.cjs +19 -0
  120. package/skills/i3-ui-prototype/scripts/template-contract.cjs +398 -0
  121. package/skills/i3-ui-prototype/scripts/validate-result.cjs +117 -0
  122. package/skills/i3-using-agent-skills/SKILL.md +2 -5
@@ -10,6 +10,7 @@ const install = path.join(__dirname, 'lib/installation.js');
10
10
  const skills = fs.existsSync(install) ? require(install).loadInstallation().skillsDirectory : path.resolve(__dirname, '../skills');
11
11
  const skill = path.join(skills, 'i3-ui-prototype');
12
12
  const lib = require(path.join(skill, 'scripts/prototype-lib.cjs'));
13
+ const enterprise = require(path.join(skill, 'scripts/enterprise-template.cjs'));
13
14
  const read = (root, file) => JSON.parse(fs.readFileSync(path.join(root, file), 'utf8'));
14
15
  function project(t) {
15
16
  const root = fs.mkdtempSync(path.join(os.tmpdir(), 'enterprise-template-'));
@@ -19,6 +20,45 @@ function project(t) {
19
20
  function initialize(t, options = {}) {
20
21
  return lib.initPrototype({ project: project(t), id: 'enterprise-demo', expertMode: 'template', purpose: 'template-review', ...options }).directory;
21
22
  }
23
+ test('runtime branding changes uppercase product text only in runtime HTML and JavaScript', () => {
24
+ const source = 'EASEL easel-html /easel EASEL 研发';
25
+ for (const file of ['index.html', 'src/shell/ConsoleShell.html', 'src/models/data.js']) {
26
+ assert.equal(enterprise.brandRuntimeResource(file, source), 'i3-Composer easel-html /easel i3-Composer 研发');
27
+ }
28
+ for (const file of ['origin.json', 'licenses/EASEL.txt', 'qa/REVIEW.md', 'src/styles.css', 'tests/behavior.js', 'app.js']) {
29
+ const bytes = Buffer.from(source);
30
+ assert.equal(enterprise.brandRuntimeResource(file, bytes), bytes, file);
31
+ }
32
+ assert.ok(Buffer.isBuffer(enterprise.brandRuntimeResource('src/models/data.js', Buffer.from(source))));
33
+ });
34
+ test('generated enterprise runtime uses i3-Composer branding with one final adaptation record per changed source', t => {
35
+ const dir = initialize(t, { title: 'i3-Composer 品牌验收' });
36
+ const shell = fs.readFileSync(path.join(dir, 'app/src/shell/ConsoleShell.html'), 'utf8');
37
+ assert.match(shell, /<strong>i3-Composer<\/strong>/);
38
+ assert.doesNotMatch(shell, /EASEL/);
39
+ assert.match(fs.readFileSync(path.join(dir, 'app/index.html'), 'utf8'), /<title>i3-Composer/);
40
+ const models = fs.readFileSync(path.join(dir, 'app/src/models/data.js'), 'utf8');
41
+ assert.match(models, /i3-Composer 研发/);
42
+ assert.doesNotMatch(models, /EASEL/);
43
+ assert.match(fs.readFileSync(path.join(dir, 'app/src/permissions/MemberEditor.html'), 'utf8'), /i3-Composer 业务角色/);
44
+ assert.doesNotMatch(read(dir, 'requirements-map.json').scope, /EASEL/);
45
+ assert.doesNotMatch(fs.readFileSync(path.join(dir, 'mocks/README.md'), 'utf8'), /EASEL/);
46
+ const origin = enterprise.verifyReference(), source = read(dir, 'design-system/source.json');
47
+ assert.equal(origin.fileCount, 326);
48
+ assert.equal(new Set(source.adaptations.map(record => record.file)).size, source.adaptations.length);
49
+ const adapted = new Map(source.adaptations.map(record => [record.file, record]));
50
+ for (const [file, expected] of Object.entries(origin.files)) {
51
+ const generated = crypto.createHash('sha256').update(fs.readFileSync(path.join(dir, 'app', file))).digest('hex');
52
+ if (generated === expected) continue;
53
+ const record = adapted.get(file);
54
+ assert.ok(record, `Unrecorded runtime adaptation: ${file}`);
55
+ assert.equal(record.source, expected, file);
56
+ assert.equal(record.generated, generated, file);
57
+ assert.ok(record.reason, file);
58
+ }
59
+ assert.match(adapted.get('index.html').reason, /i3-Composer/);
60
+ assert.match(adapted.get('src/models/data.js').reason, /i3-Composer/);
61
+ });
22
62
  test('four prototype families expose the EASEL enterprise template and explicitly defer the other three', () => {
23
63
  const entries = lib.listTemplates();
24
64
  assert.deepEqual(entries.map(x => x.id), ['enterprise-admin', 'ai-workbench', 'digital-twin', 'mobile-app']);
@@ -64,11 +104,37 @@ test('generated enterprise instances isolate their local demo storage and retain
64
104
  const source = fs.readFileSync(path.join(dir, 'app/src/shared/session.js'), 'utf8');
65
105
  assert.match(source, /i3-enterprise-enterprise-demo-/);
66
106
  assert.doesNotMatch(source, /i3-prototype-antdv-/);
67
- assert.match(fs.readFileSync(path.join(dir, 'app/index.html'), 'utf8'), /enterprise-bootstrap\.mjs/);
107
+ assert.match(fs.readFileSync(path.join(dir, 'app/index.html'), 'utf8'), /src="offline\.js"/);
108
+ assert.ok(fs.existsSync(path.join(dir, 'app/enterprise-bootstrap.mjs')), 'Retain the historical module bootstrap for compatibility');
68
109
  assert.match(fs.readFileSync(path.join(dir, 'app/index.html'), 'utf8'), /tokens\.css/);
69
110
  const other = initialize(t);
70
111
  assert.notEqual(read(dir, 'prototype.json').runtime.storagePrefix, read(other, 'prototype.json').runtime.storagePrefix);
71
112
  });
113
+ test('offline entry carries every editable HTML fragment and routes without changing the reference', t => {
114
+ const root = path.join(skill, 'assets/templates/enterprise-admin');
115
+ const build = read(root, 'offline-build.json');
116
+ for (const [file, expected] of Object.entries(build.files)) {
117
+ assert.equal(crypto.createHash('sha256').update(fs.readFileSync(path.join(root, file))).digest('hex'), expected, file);
118
+ }
119
+ const html = fs.readFileSync(path.join(root, 'index.html'), 'utf8');
120
+ assert.equal([...html.matchAll(/<template data-source=/g)].length, build.htmlFragmentCount);
121
+ const routes = JSON.parse(html.match(/id="prototype-offline-routes">([\s\S]*?)<\/script>/)[1]);
122
+ assert.deepEqual(routes, read(root, 'reference/routes.json'));
123
+ for (const component of new Set(routes.map(route => route.component).filter(Boolean))) {
124
+ const source = fs.readFileSync(path.resolve(root, 'reference', component), 'utf8');
125
+ const fragment = source.match(/template: new URL\('\.\/([^']+\.html)'/)[1];
126
+ assert.ok(html.includes(`data-source="reference/${path.posix.dirname(component).replace(/^\.\//, '')}/${fragment}"`), component);
127
+ }
128
+ assert.doesNotMatch(html, /type="module"/);
129
+ assert.doesNotMatch(fs.readFileSync(path.join(root, 'offline.js'), 'utf8'), /\bfetch\s*\(|\bimport\s*\(|import\.meta/);
130
+ const dir = initialize(t, { title: '离线配置 <安全标题>' });
131
+ const instance = fs.readFileSync(path.join(dir, 'app/index.html'), 'utf8');
132
+ assert.equal(JSON.parse(instance.match(/id="prototype-offline-config">([\s\S]*?)<\/script>/)[1]).title, '离线配置 <安全标题>');
133
+ assert.equal(read(dir, 'prototype.json').runtime.offlineVersion, 1);
134
+ assert.equal(lib.checkPrototype(dir).status, 'passed');
135
+ fs.unlinkSync(path.join(dir, 'app/offline.js'));
136
+ assert.equal(lib.checkPrototype(dir).status, 'failed', 'A new offline instance must require its runtime');
137
+ });
72
138
  test('missing enterprise runtime imports are rejected while declarative data-bind attributes remain valid', t => {
73
139
  const dir = initialize(t);
74
140
  assert.equal(lib.checkPrototype(dir).status, 'passed');
@@ -0,0 +1,17 @@
1
+ 'use strict';
2
+ // Historical admin-list input for regression tests only; no public template entry.
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ function prepareTemplate(contract, { project, id = 'admin-list' }) {
6
+ if (!/^[a-z][a-z0-9-]{0,63}$/.test(id)) throw new Error('Invalid identifier');
7
+ const directory = path.resolve(project, 'ui-templates', id);
8
+ if (fs.existsSync(path.join(project, 'ui-templates')) && fs.lstatSync(path.join(project, 'ui-templates')).isSymbolicLink()) throw new Error('Refusing symlink');
9
+ if (fs.existsSync(directory)) return { directory, created: false, index: contract.loadTemplate(directory).index };
10
+ fs.cpSync(path.resolve(__dirname, '../../evals/fixtures/i3-ui-prototype/admin-list'), directory, { recursive: true });
11
+ const file = path.join(directory, 'template.json');
12
+ const manifest = JSON.parse(fs.readFileSync(file));
13
+ manifest.id = id;
14
+ fs.writeFileSync(file, JSON.stringify(manifest, null, 2) + '\n');
15
+ return { directory, created: true, index: contract.syncTemplate(directory) };
16
+ }
17
+ module.exports = { prepareTemplate };
@@ -0,0 +1,58 @@
1
+ 'use strict';
2
+ const test = require('node:test');
3
+ const assert = require('node:assert/strict');
4
+ const fs = require('node:fs');
5
+ const path = require('node:path');
6
+ const os = require('node:os');
7
+ const installation = path.join(__dirname, 'lib/installation.js');
8
+ const skills = fs.existsSync(installation) ? require(installation).loadInstallation().skillsDirectory : path.resolve(__dirname, '../skills');
9
+ const locations = require(path.join(skills, 'i3-ui-prototype/scripts/prototype-locations.cjs'));
10
+ function workspace(t) {
11
+ const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'i3-tool-locations-')));
12
+ t.after(() => fs.rmSync(root, { recursive: true, force: true }));
13
+ const project = path.join(root, 'business'), home = path.join(root, 'home');
14
+ fs.mkdirSync(project); fs.mkdirSync(home);
15
+ return { project, home, env: {} };
16
+ }
17
+ test('all registered project and global targets keep tools under their host base without writes', t => {
18
+ const options = workspace(t);
19
+ for (const agent of locations.registry.agents) {
20
+ const local = locations.resolveLocations({ ...options, host: agent.id });
21
+ assert.equal(local.skillsDirectory, path.resolve(options.project, agent.projectSkillsDir));
22
+ const toolBase = path.dirname(local.skillsDirectory) === options.project ? path.join(options.project, `.${agent.id}`) : path.dirname(local.skillsDirectory);
23
+ assert.equal(local.toolsDirectory, path.join(toolBase, 'tools/i3-prototype'));
24
+ assert.notEqual(local.toolsDirectory, path.join(options.project, 'tools/i3-prototype'));
25
+ if (agent.globalSkillsDir) {
26
+ const global = locations.resolveLocations({ ...options, host: agent.id, scope: 'global' });
27
+ assert.equal(global.toolsDirectory, path.join(path.dirname(global.skillsDirectory), 'tools/i3-prototype'));
28
+ assert.ok(global.base.startsWith(options.home + path.sep));
29
+ } else assert.throws(() => locations.resolveLocations({ ...options, host: agent.id, scope: 'global' }), /project installation only/);
30
+ }
31
+ assert.deepEqual(fs.readdirSync(options.project), []);
32
+ assert.deepEqual(fs.readdirSync(options.home), []);
33
+ });
34
+ test('CodeBuddy and WorkBuddy share project tools but use separate global roots; Codex follows agents', t => {
35
+ const options = workspace(t), get = (host, scope = 'project') => locations.resolveLocations({ ...options, host, scope });
36
+ assert.equal(get('codebuddy').toolsDirectory, get('workbuddy').toolsDirectory);
37
+ assert.notEqual(get('codebuddy', 'global').toolsDirectory, get('workbuddy', 'global').toolsDirectory);
38
+ assert.equal(get('codex').base, path.join(options.project, '.agents'));
39
+ assert.equal(get('codex', 'global').base, path.join(options.home, '.agents'));
40
+ });
41
+ test('configuration roots and aliases follow installer mapping and explicit global checks exclude business paths', t => {
42
+ const options = workspace(t);
43
+ const context = locations.resolveLocations({ ...options, host: 'claude', scope: 'global', env: { CLAUDE_CONFIG_DIR: path.join(options.home, 'custom claude') } });
44
+ assert.equal(context.base, path.join(options.home, 'custom claude'));
45
+ assert.equal(context.host, 'claude-code');
46
+ const opencode = locations.resolveLocations({ ...options, host: 'opencode', env: { XDG_CONFIG_HOME: path.join(options.home, 'config') }, scope: 'global' });
47
+ assert.equal(opencode.base, path.join(options.home, 'config/opencode'));
48
+ assert.ok(locations.toolCandidates(context).every(item => !item.root.startsWith(options.project + path.sep)));
49
+ assert.throws(() => locations.resolveLocations({ ...options, host: 'unknown' }), /Unknown host/);
50
+ assert.throws(() => locations.resolveLocations({ ...options, scope: 'all' }), /scope/);
51
+ });
52
+ test('new project and global tools precede both legacy layouts', t => {
53
+ const options = workspace(t), context = locations.resolveLocations({ ...options, host: 'codex' });
54
+ const paths = locations.toolCandidates(context);
55
+ assert.deepEqual(paths.map(item => item.source), ['project-tool', 'global-tool', 'legacy-project-tool', 'legacy-project-tool']);
56
+ assert.ok(paths[0].root.includes('/.agents/tools/i3-prototype/antdv-cli/'));
57
+ assert.ok(paths.at(-1).root.includes('/.prototype-tools/node_modules/'));
58
+ });
@@ -0,0 +1,123 @@
1
+ 'use strict';
2
+ const test = require('node:test');
3
+ const assert = require('node:assert/strict');
4
+ const fs = require('node:fs');
5
+ const os = require('node:os');
6
+ const path = require('node:path');
7
+ const crypto = require('node:crypto');
8
+ const installation = path.join(__dirname, 'lib/installation.js');
9
+ const skills = fs.existsSync(installation) ? require(installation).loadInstallation().skillsDirectory : path.resolve(__dirname, '../skills');
10
+ const root = path.join(skills, 'i3-ui-prototype');
11
+ const contract = require(path.join(root, 'scripts/template-contract.cjs'));
12
+ const native = require(path.join(root, 'scripts/native-pilot-lib.cjs'));
13
+ const read = (directory, file) => JSON.parse(fs.readFileSync(path.join(directory, file), 'utf8'));
14
+ const write = (directory, file, data) => fs.writeFileSync(path.join(directory, file), JSON.stringify(data, null, 2));
15
+ const files = directory => Object.fromEntries(fs.readdirSync(directory, { recursive: true }).filter(file => fs.statSync(path.join(directory, file)).isFile()).sort().map(file => [file, crypto.createHash('sha256').update(fs.readFileSync(path.join(directory, file))).digest('hex')]));
16
+ function fixture(t, { prepare = true } = {}) {
17
+ const project = fs.mkdtempSync(path.join(os.tmpdir(), 'i3-full-content-'));
18
+ t.after(() => fs.rmSync(project, { recursive: true, force: true }));
19
+ const template = prepare ? contract.prepareTemplate({ project }) : null;
20
+ const scope = { schemaVersion: 1, title: '资产审核', pages: [{ id: 'assets', title: '资产台账', referencePage: 'models-page',
21
+ regions: [{ id: 'list', mode: 'management-list' }, { id: 'editor', mode: 'edit-form' }] }],
22
+ requirements: [{ id: 'ASSET-01', text: '查询、选择和修改资产', pages: ['assets'], source: { type: 'prd', reference: 'PRD.md#ASSET-01', statement: '按资产字段实现清单与编辑;使用模板通用能力约定。' } }], exemptions: [] };
23
+ write(project, 'scope.json', scope);
24
+ const create = options => native.createPrototype({ project, id: 'assets', expertMode: 'template', scope: path.join(project, 'scope.json'), ...(template ? { templateRoot: template.directory } : {}), ...options });
25
+ return { project, template, scope, create };
26
+ }
27
+ test('explicit legacy preparation uses the complete offline reference and never overwrites a working copy', t => {
28
+ const { project, template } = fixture(t);
29
+ const snapshot = contract.loadTemplate(template.directory);
30
+ assert.equal(snapshot.kind, 'content-reference'); assert.equal(snapshot.id, 'enterprise-admin');
31
+ assert.equal(snapshot.index.pages.length, 35); assert.equal(snapshot.index.routes.length, 44); assert.equal(snapshot.index.htmlFragments.length, 93);
32
+ assert.deepEqual(Object.keys(snapshot.index.extractor.files).sort(), ['content-template.cjs', 'template-contract.cjs']);
33
+ const file = path.join(template.directory, 'index.html');
34
+ fs.writeFileSync(file, fs.readFileSync(file, 'utf8').replace('<h1>模型管理</h1>', '<h1>资产参考内容</h1>'));
35
+ assert.throws(() => contract.loadTemplate(template.directory), /stale|不同步/);
36
+ const index = contract.syncTemplate(template.directory);
37
+ assert.equal(index.pages.find(page => page.id === 'models-page').title, '资产参考内容');
38
+ const content = require(path.join(root, 'scripts/content-template.cjs')).pageContent(contract.loadTemplate(template.directory), 'models-page');
39
+ assert.ok(content.html.includes('<h1>资产参考内容</h1>')); assert.equal(content.readOnly, true);
40
+ assert.equal(contract.prepareTemplate({ project }).created, false);
41
+ assert.ok(fs.readFileSync(file, 'utf8').includes('资产参考内容'));
42
+ });
43
+ test('default creation reads the released template without preparing a business template directory or modifying Skill assets', t => {
44
+ const { project, create } = fixture(t, { prepare: false });
45
+ const builtin = path.join(root, 'assets/templates/enterprise-admin');
46
+ const before = files(builtin);
47
+ const created = create();
48
+ assert.equal(fs.existsSync(path.join(project, 'ui-templates')), false);
49
+ assert.equal(fs.existsSync(path.join(project, '.prototype-preparation')), false);
50
+ assert.deepEqual(files(builtin), before);
51
+ assert.equal(native.inspect(created.directory).status, 'passed');
52
+ const snapshot = read(created.directory, 'inputs/template-snapshot.json');
53
+ assert.equal(snapshot.id, 'enterprise-admin'); assert.equal(snapshot.index.pages.length, 35);
54
+ assert.deepEqual(snapshot, contract.loadBundledTemplate());
55
+ assert.deepEqual(read(created.directory, 'inputs/scope.json'), read(project, 'scope.json'));
56
+ });
57
+ test('an existing edited template copy is preserved and used only when explicitly selected', t => {
58
+ const { project, template, create } = fixture(t);
59
+ const entry = path.join(template.directory, 'index.html');
60
+ fs.writeFileSync(entry, fs.readFileSync(entry, 'utf8').replace('<h1>模型管理</h1>', '<h1>用户维护的旧模板</h1>'));
61
+ contract.syncTemplate(template.directory);
62
+ const before = files(template.directory);
63
+ const current = create({ id: 'current', templateRoot: undefined });
64
+ const legacy = create({ id: 'legacy' });
65
+ assert.notEqual(read(current.directory, 'inputs/template-snapshot.json').digest, read(legacy.directory, 'inputs/template-snapshot.json').digest);
66
+ assert.equal(read(legacy.directory, 'inputs/template-snapshot.json').index.pages.find(page => page.id === 'models-page').title, '用户维护的旧模板');
67
+ assert.deepEqual(files(template.directory), before);
68
+ });
69
+ test('new business generation requires a sourced scope and does not silently generate a device list', t => {
70
+ const { project, create } = fixture(t);
71
+ assert.throws(() => create({ scope: undefined }), /scope|需求/i);
72
+ assert.equal(fs.existsSync(path.join(project, 'prototypes')), false);
73
+ const created = create(), meta = read(created.directory, 'prototype.json');
74
+ assert.equal(meta.runtime.contentReference, true); assert.equal(meta.business.status, 'needs-adaptation');
75
+ assert.equal(native.inspect(created.directory).status, 'passed');
76
+ assert.match(native.inspect(created.directory, { acceptance: true }).errors.join('; '), /implementation pending|scaffold/);
77
+ const config = read(created.directory, 'app/public/prototype-config.json');
78
+ assert.deepEqual(config.pages, [{ id: 'assets', title: '资产台账' }]);
79
+ assert.deepEqual(config.theme, { defaultTheme: 'light', presets: ['light', 'dark', 'fresh', 'tech'] });
80
+ assert.equal(config.content, undefined); assert.equal(read(created.directory, 'mocks/contract.json').count, undefined);
81
+ assert.equal(fs.existsSync(path.join(created.directory, 'app/src/domain/devices.ts')), false);
82
+ const state = native.state(created.directory, {});
83
+ assert.equal(state.nextAction, 'implement-business'); assert.equal(state.commands.browser, undefined); assert.ok(state.commands.recordRun);
84
+ });
85
+ test('the complete content reference yields fixed common capabilities for the selected business regions', t => {
86
+ const { create } = fixture(t), directory = create().directory;
87
+ const fixed = read(directory, 'inputs/capabilities.json'), acceptance = read(directory, 'inputs/acceptance.json');
88
+ for (const id of ['filter', 'selection', 'pagination', 'bulk', 'feedback']) assert.ok(fixed.bindings.some(binding => binding.id === `assets/list/${id}`));
89
+ assert.ok(fixed.bindings.some(binding => binding.id === 'assets/editor/edit'));
90
+ assert.ok(fixed.bindings.some(binding => binding.id === 'app-shell/appearance/theme'));
91
+ assert.ok(acceptance.requiredScenarios.includes('assets/list/pagination/pages'));
92
+ assert.ok(acceptance.requiredScenarios.includes('app-shell/appearance/theme/persistence'));
93
+ assert.equal(acceptance.requiredScenarios.some(id => id.startsWith('devices/')), false);
94
+ fixed.bindings = fixed.bindings.filter(binding => binding.capabilityId !== 'selection'); write(directory, 'inputs/capabilities.json', fixed);
95
+ const candidate = native.manifest(directory);
96
+ assert.throws(() => native.revise(directory, { revision: 'r002', expectedRevision: candidate.revision, expectedDigest: candidate.digest, acceptInputs: true }), /binding|capabilit|合同/i);
97
+ });
98
+ test('business page and requirement references must remain explicit and unambiguous', t => {
99
+ const { project, scope, create } = fixture(t);
100
+ for (const modify of [
101
+ value => { value.pages[0].referencePage = 'missing-page'; },
102
+ value => { value.pages.push(structuredClone(value.pages[0])); },
103
+ value => { value.requirements.push(structuredClone(value.requirements[0])); },
104
+ value => { value.requirements[0].source = {}; },
105
+ value => { value.requirements[0].pages = ['other-business']; },
106
+ value => { value.pages[0].regions[0].mode = 'unknown-mode'; },
107
+ ]) {
108
+ const broken = structuredClone(scope); modify(broken); write(project, 'scope.json', broken);
109
+ assert.throws(() => create()); assert.equal(fs.existsSync(path.join(project, 'prototypes')), false);
110
+ }
111
+ });
112
+ test('offline reference synchronization rejects missing runtime resources and module loading', t => {
113
+ const { template } = fixture(t), file = path.join(template.directory, 'index.html'), html = fs.readFileSync(file, 'utf8');
114
+ fs.writeFileSync(file, html.replace('src="offline.js"', 'src="missing.js"')); assert.throws(() => contract.syncTemplate(template.directory), /resource missing/);
115
+ fs.writeFileSync(file, html.replace('<script src="offline.js"', '<script type="module" src="offline.js"')); assert.throws(() => contract.syncTemplate(template.directory), /modules/);
116
+ fs.writeFileSync(file, html); fs.appendFileSync(path.join(template.directory, 'offline.js'), '\nfetch("parts.html")'); assert.throws(() => contract.syncTemplate(template.directory), /Offline runtime/);
117
+ });
118
+ test('the historical device regression runner refuses a business contract before writing evidence', async t => {
119
+ const { create } = fixture(t), directory = create().directory;
120
+ const before = fs.readdirSync(path.join(directory, 'qa'));
121
+ await assert.rejects(require(path.join(root, 'scripts/native-browser.cjs')).main(directory), /historical device-list/);
122
+ assert.deepEqual(fs.readdirSync(path.join(directory, 'qa')), before);
123
+ });
@@ -0,0 +1,213 @@
1
+ 'use strict';
2
+ const test = require('node:test');
3
+ const assert = require('node:assert/strict');
4
+ const fs = require('node:fs');
5
+ const os = require('node:os');
6
+ const path = require('node:path');
7
+ const installation = path.join(__dirname, 'lib/installation.js');
8
+ const skills = fs.existsSync(installation) ? require(installation).loadInstallation().skillsDirectory : path.resolve(__dirname, '../skills');
9
+ const deps = require(path.join(skills, 'i3-ui-prototype/scripts/prototype-dependencies.cjs'));
10
+ const installer = require(path.join(skills, 'i3-ui-prototype/scripts/optional-skill-install.cjs'));
11
+ function workspace(t) {
12
+ const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'i3-optional-deps-')));
13
+ t.after(() => fs.rmSync(root, { recursive: true, force: true }));
14
+ const project = path.join(root, 'business'), home = path.join(root, 'home');
15
+ fs.mkdirSync(project); fs.mkdirSync(home);
16
+ return { project, home, env: {
17
+ ...process.env,
18
+ HOME: home, USERPROFILE: home,
19
+ XDG_CONFIG_HOME: path.join(home, '.config'),
20
+ CODEBUDDY_CONFIG_DIR: path.join(home, '.codebuddy'),
21
+ WORKBUDDY_CONFIG_DIR: path.join(home, '.workbuddy'),
22
+ CLAUDE_CONFIG_DIR: path.join(home, '.claude'),
23
+ CODEX_HOME: path.join(home, '.codex'),
24
+ } };
25
+ }
26
+ test('missing optional Skills do not fail read-only inspection or write files', t => {
27
+ const options = workspace(t), before = fs.readdirSync(options.project);
28
+ const result = deps.check(options);
29
+ assert.equal(result.status, 'checked');
30
+ assert.equal(result.professionalReady, false);
31
+ assert.equal(result.antdv.skill.availability, 'missing');
32
+ assert.equal(result.antdv.tool.availability, 'missing');
33
+ assert.deepEqual(fs.readdirSync(options.project), before);
34
+ });
35
+ test('later installed antDv Skill is detected without reinstalling i3 or claiming the CLI ready', t => {
36
+ const options = workspace(t);
37
+ assert.equal(deps.check(options).antdv.skill.availability, 'missing');
38
+ const root = path.join(options.project, '.codebuddy/skills/antdv');
39
+ fs.mkdirSync(root, { recursive: true }); fs.writeFileSync(path.join(root, 'SKILL.md'), 'official Skill fixture');
40
+ const result = deps.check(options);
41
+ assert.equal(result.antdv.skill.root, root);
42
+ assert.equal(result.antdv.skill.availability, 'available');
43
+ assert.equal(result.antdv.skill.invocationVerified, false);
44
+ assert.equal(result.antdv.tool.availability, 'missing');
45
+ });
46
+ test('CodeBuddy global antDv is reusable and current project entry has precedence', t => {
47
+ const options = workspace(t);
48
+ options.env.CODEBUDDY_CONFIG_DIR = path.join(options.home, 'legacy CodeBuddy config');
49
+ const global = path.join(options.env.CODEBUDDY_CONFIG_DIR, 'skills/antdv');
50
+ fs.mkdirSync(global, { recursive: true }); fs.writeFileSync(path.join(global, 'SKILL.md'), 'global');
51
+ assert.equal(deps.skillAt(options.project, 'antdv', options).source, 'global-skill');
52
+ const local = path.join(options.project, '.codebuddy/skills/antdv');
53
+ fs.mkdirSync(local, { recursive: true }); fs.writeFileSync(path.join(local, 'SKILL.md'), 'local');
54
+ assert.equal(deps.skillAt(options.project, 'antdv', options).root, local);
55
+ });
56
+ test('global UIUX detection reuses enabled user plugins, preserves disabled status and excludes business plugins', t => {
57
+ const options = { ...workspace(t), host: 'codebuddy', scope: 'global' }, config = path.join(options.home, '.codebuddy');
58
+ const plugin = path.join(options.home, 'uiux-plugin'), root = path.join(plugin, 'skills/ui-ux-pro-max'), id = 'ui-ux-pro-max@fixture';
59
+ const write = (file, content) => { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, typeof content === 'string' ? content : JSON.stringify(content)); };
60
+ write(path.join(plugin, '.codebuddy-plugin/plugin.json'), { name: 'ui-ux-pro-max', version: '2.15.0' });
61
+ for (const file of ['SKILL.md', 'scripts/core.py', 'scripts/design_system.py', ...['ux-guidelines','styles','colors','typography','products','landing','ui-reasoning'].map(name => `data/${name}.csv`)]) write(path.join(root, file), 'synthetic fixture');
62
+ write(path.join(root, 'scripts/search.py'), 'import json\nprint(json.dumps({"domain":"ux","results":[{"id":"synthetic-probe-only"}]}))\n');
63
+ write(path.join(config, 'settings.json'), { enabledPlugins: { [id]: true } });
64
+ const record = { scope: 'user', installPath: plugin, version: '2.15.0' };
65
+ write(path.join(config, 'plugins/installed_plugins.json'), { version: 2, plugins: { [id]: [record] } });
66
+ const result = deps.check(options);
67
+ assert.equal(result.uiux.root, root);
68
+ assert.equal(result.uiux.availability, 'available');
69
+ assert.equal(result.uiux.source, 'codebuddy-plugin');
70
+ write(path.join(config, 'settings.json'), { enabledPlugins: { [id]: false } });
71
+ assert.equal(deps.check(options).uiux.availability, 'disabled');
72
+ write(path.join(config, 'settings.json'), { enabledPlugins: { [id]: true } });
73
+ write(path.join(config, 'plugins/installed_plugins.json'), { version: 2, plugins: { [id]: [{ ...record, scope: 'project', projectPath: options.project }] } });
74
+ assert.equal(deps.check(options).uiux.availability, 'missing');
75
+ });
76
+ test('dependency CLI rejects unintended writes and unrelated hosts', () => {
77
+ assert.throws(() => deps.parse(['check', '--force']), /Invalid option/);
78
+ assert.throws(() => deps.check({ host: 'not-a-host' }), /Unknown host/);
79
+ assert.throws(() => deps.parse(['update']), /check or install/);
80
+ });
81
+ function officialFixture(name, folder) {
82
+ const skill = path.join(folder, 'skill'), tool = path.join(folder, 'tool');
83
+ fs.mkdirSync(skill); fs.mkdirSync(tool);
84
+ fs.writeFileSync(path.join(skill, 'SKILL.md'), `---\nname: ${name === 'uiux' ? 'ui-ux-pro-max' : 'antdv'}\n---\nOfficial fixture; not host invocation evidence.\n`);
85
+ if (name === 'uiux') {
86
+ for (const file of ['scripts/search.py', 'scripts/core.py', 'scripts/design_system.py', 'data/ux-guidelines.csv']) {
87
+ fs.mkdirSync(path.dirname(path.join(skill, file)), { recursive: true }); fs.writeFileSync(path.join(skill, file), 'fixture');
88
+ }
89
+ } else {
90
+ const pkg = path.join(tool, 'node_modules/@antdv-next/cli'); fs.mkdirSync(pkg, { recursive: true });
91
+ fs.writeFileSync(path.join(pkg, 'package.json'), JSON.stringify({ name: '@antdv-next/cli', version: '0.0.0-beta.1', bin: { antdv: 'cli.cjs' } }));
92
+ fs.writeFileSync(path.join(pkg, 'cli.cjs'), 'process.stdout.write(JSON.stringify({name:"Table",props:[]}));');
93
+ }
94
+ return { skill, tool };
95
+ }
96
+ test('explicit skip never downloads, writes or permanently disables dependencies', async t => {
97
+ const options = workspace(t), before = fs.readdirSync(options.project);
98
+ const result = await installer.install({ ...options, skills: 'none' }, { prepare() { throw new Error('Unexpected download'); } });
99
+ assert.equal(result.status, 'skipped');
100
+ assert.deepEqual(fs.readdirSync(options.project), before);
101
+ assert.deepEqual(installer.selection('uiux,antdv'), ['uiux', 'antdv']);
102
+ assert.throws(() => installer.selection('none,uiux'), /Choose/);
103
+ });
104
+ test('optional antDv installation preserves user files and a later invocation reuses the Skill', async t => {
105
+ const options = workspace(t);
106
+ fs.writeFileSync(path.join(options.project, 'PRD.md'), 'human requirements');
107
+ const services = { prepare: officialFixture, progress() {} };
108
+ const result = await installer.install({ ...options, skills: 'antdv' }, services);
109
+ assert.equal(result.status, 'installed');
110
+ assert.equal(result.detection.antdv.skill.availability, 'available');
111
+ assert.equal(fs.readFileSync(path.join(options.project, 'PRD.md'), 'utf8'), 'human requirements');
112
+ const skillFile = path.join(options.project, '.codebuddy/skills/antdv/SKILL.md');
113
+ fs.appendFileSync(skillFile, 'user edit'); const before = fs.readFileSync(skillFile);
114
+ const again = await installer.install({ ...options, skills: 'antdv' }, { prepare() { throw new Error('Existing tool must not be replaced'); } });
115
+ assert.equal(again.status, 'reused');
116
+ assert.deepEqual(fs.readFileSync(skillFile), before);
117
+ });
118
+ test('a failed second preparation publishes neither selected Skill and removes temporary files', async t => {
119
+ const options = workspace(t);
120
+ await assert.rejects(installer.install({ ...options, skills: 'uiux,antdv' }, {
121
+ progress() {}, prepare(name, folder) { if (name === 'antdv') throw new Error('simulated network failure'); return officialFixture(name, folder); },
122
+ }), /simulated network failure/);
123
+ assert.deepEqual(fs.readdirSync(options.project), []);
124
+ });
125
+ test('antDv error text or error JSON with exit zero is not successful knowledge', async t => {
126
+ const options = workspace(t);
127
+ await installer.install({ ...options, skills: 'antdv' }, { prepare: officialFixture, progress() {} });
128
+ const file = path.join(resultTool(options), 'node_modules/@antdv-next/cli/cli.cjs');
129
+ for (const output of ['Error: Table knowledge missing', JSON.stringify({ error: 'Table knowledge missing' })]) {
130
+ fs.writeFileSync(file, `process.stdout.write(${JSON.stringify(output)});`);
131
+ assert.equal(deps.check(options).antdv.tool.availability, 'unusable');
132
+ }
133
+ });
134
+ function resultTool(options) { return path.join(deps.check(options).locations.toolsDirectory, 'antdv-cli'); }
135
+ for (const host of ['codebuddy', 'workbuddy', 'codex', 'claude-code']) for (const scope of ['project', 'global']) {
136
+ test(`${host} ${scope} optional installation keeps all tools inside the selected host root`, async t => {
137
+ const options = { ...workspace(t), host, scope };
138
+ const result = await installer.install({ ...options, skills: 'antdv' }, { prepare: officialFixture, progress() {} });
139
+ assert.equal(result.detection.antdv.tool.source, `${scope}-tool`);
140
+ assert.ok(fs.existsSync(path.join(resultTool(options), 'node_modules/@antdv-next/cli/package.json')));
141
+ assert.equal(fs.existsSync(path.join(options.project, '.prototype-tools')), false);
142
+ assert.equal(fs.existsSync(path.join(options.project, 'package.json')), false);
143
+ if (scope === 'global') assert.deepEqual(fs.readdirSync(options.project), []);
144
+ });
145
+ }
146
+ test('an available global tool is reused by a later project without project writes', async t => {
147
+ const options = workspace(t);
148
+ await installer.install({ ...options, host: 'codex', scope: 'global', skills: 'antdv' }, { prepare: officialFixture, progress() {} });
149
+ const result = await installer.install({ ...options, host: 'codex', skills: 'antdv' }, { prepare() { throw new Error('No download expected'); } });
150
+ assert.equal(result.status, 'reused');
151
+ assert.equal(result.detection.antdv.tool.source, 'global-tool');
152
+ assert.deepEqual(fs.readdirSync(options.project), []);
153
+ });
154
+ test('legacy independent tools stay readable until an explicit owned migration', async t => {
155
+ const options = workspace(t), legacy = path.join(options.project, '.prototype-tools/antdv-cli');
156
+ const stage = path.join(options.project, 'fixture'); fs.mkdirSync(stage);
157
+ const prepared = officialFixture('antdv', stage);
158
+ fs.mkdirSync(path.dirname(legacy)); fs.renameSync(prepared.tool, legacy); fs.rmSync(stage, { recursive: true });
159
+ fs.writeFileSync(path.join(legacy, 'UPSTREAM-ORIGIN.json'), JSON.stringify({ ...require(path.join(skills, 'i3-ui-prototype/assets/optional-skills/sources.json')).antdv, installer: 'i3 optional dependency preparation; official Skill body unchanged' }));
160
+ assert.equal(deps.check(options).antdv.tool.source, 'legacy-project-tool');
161
+ await installer.install({ ...options, skills: 'antdv' }, { prepare: officialFixture, progress() {} });
162
+ assert.equal(fs.existsSync(legacy), true);
163
+ fs.writeFileSync(path.join(options.project, '.prototype-tools/user-note.txt'), 'keep this location');
164
+ await installer.install({ ...options, scope: 'global', skills: 'antdv' }, { prepare: officialFixture, progress() {} });
165
+ assert.equal(deps.check(options).antdv.tool.source, 'global-tool', 'normal lookup prefers a current global tool over legacy');
166
+ const result = await installer.install({ ...options, skills: 'antdv', migrateTools: true }, { prepare() { throw new Error('No download expected'); } });
167
+ assert.equal(result.status, 'migrated');
168
+ assert.equal(result.detection.antdv.tool.source, 'project-tool');
169
+ assert.equal(fs.existsSync(legacy), false);
170
+ assert.equal(fs.readFileSync(path.join(options.project, '.prototype-tools/user-note.txt'), 'utf8'), 'keep this location');
171
+ });
172
+ test('legacy mixed tools without i3 ownership remain untouched by migration', async t => {
173
+ const options = workspace(t), stage = path.join(options.project, 'fixture'); fs.mkdirSync(stage);
174
+ const prepared = officialFixture('antdv', stage), legacy = path.join(options.project, '.prototype-tools/antdv-cli');
175
+ fs.mkdirSync(path.dirname(legacy)); fs.renameSync(prepared.tool, legacy); fs.rmSync(stage, { recursive: true });
176
+ await assert.rejects(installer.install({ ...options, skills: 'antdv', migrateTools: true }, { prepare() { throw new Error('No download expected'); } }), /ownership|来源|归属/);
177
+ assert.equal(deps.check(options).antdv.tool.source, 'legacy-project-tool');
178
+ });
179
+ test('a broken project tool is reported instead of silently borrowing a healthy global tool', async t => {
180
+ const options = workspace(t);
181
+ await installer.install({ ...options, scope: 'global', skills: 'antdv' }, { prepare: officialFixture, progress() {} });
182
+ const local = path.join(options.project, '.codebuddy/tools/i3-prototype/antdv-cli/node_modules/@antdv-next/cli');
183
+ fs.mkdirSync(local, { recursive: true }); fs.writeFileSync(path.join(local, 'package.json'), '{broken');
184
+ assert.equal(deps.check(options).antdv.tool.availability, 'unusable');
185
+ assert.equal(deps.check({ ...options, scope: 'global' }).antdv.tool.availability, 'available');
186
+ await assert.rejects(installer.install({ ...options, skills: 'antdv' }), /Existing tool unusable/);
187
+ assert.equal(fs.readFileSync(path.join(local, 'package.json'), 'utf8'), '{broken');
188
+ });
189
+ test('failed post-migration execution restores the old location and removes newly created destinations', async t => {
190
+ const options = workspace(t), stage = path.join(options.project, 'fixture'); fs.mkdirSync(stage);
191
+ const prepared = officialFixture('antdv', stage), legacy = path.join(options.project, '.prototype-tools/antdv-cli');
192
+ fs.mkdirSync(path.dirname(legacy)); fs.renameSync(prepared.tool, legacy); fs.rmSync(stage, { recursive: true });
193
+ const file = path.join(legacy, 'node_modules/@antdv-next/cli/cli.cjs');
194
+ fs.writeFileSync(file, 'if(__dirname.includes("tools/i3-prototype"))process.exit(1);process.stdout.write(JSON.stringify({name:"Table",props:[]}));');
195
+ fs.writeFileSync(path.join(legacy, 'UPSTREAM-ORIGIN.json'), JSON.stringify({ ...require(path.join(skills, 'i3-ui-prototype/assets/optional-skills/sources.json')).antdv, installer: 'i3 optional dependency preparation; official Skill body unchanged' }));
196
+ await assert.rejects(installer.install({ ...options, skills: 'antdv', migrateTools: true }, { prepare: officialFixture, progress() {} }), /tool query failed/);
197
+ assert.equal(deps.check(options).antdv.tool.source, 'legacy-project-tool');
198
+ assert.equal(fs.existsSync(path.join(options.project, '.codebuddy')), false);
199
+ assert.ok(fs.existsSync(file));
200
+ });
201
+ test('unusable existing UIUX resources are preserved without attempting a download', async t => {
202
+ const options = workspace(t), target = path.join(options.project, '.codebuddy/skills/ui-ux-pro-max');
203
+ fs.mkdirSync(target, { recursive: true }); fs.writeFileSync(path.join(target, 'SKILL.md'), 'human-owned incomplete skill');
204
+ await assert.rejects(installer.install({ ...options, skills: 'uiux' }, { prepare() { throw new Error('must not download'); } }), /Existing Skill preserved/);
205
+ assert.equal(fs.readFileSync(path.join(target, 'SKILL.md'), 'utf8'), 'human-owned incomplete skill');
206
+ });
207
+ test('optional installer rejects symlink escapes, including dangling targets, and bad download bytes', async t => {
208
+ const options = workspace(t), outside = path.join(options.project, 'outside');
209
+ fs.symlinkSync(outside, path.join(options.project, '.codebuddy'), 'dir');
210
+ await assert.rejects(installer.install({ ...options, skills: 'antdv' }, { prepare: officialFixture }), /symlink/);
211
+ assert.equal(fs.existsSync(outside), false);
212
+ assert.throws(() => installer.verify(Buffer.from('altered'), '0'.repeat(64)), /integrity mismatch/);
213
+ });
@@ -0,0 +1,53 @@
1
+ 'use strict';
2
+ const test=require('node:test'),assert=require('node:assert/strict'),fs=require('node:fs'),path=require('node:path'),os=require('node:os');
3
+ const installationFile=path.join(__dirname,'lib/installation.js');
4
+ const skills=fs.existsSync(installationFile)?require(installationFile).loadInstallation().skillsDirectory:path.resolve(__dirname,'../skills');
5
+ const review=require(path.join(skills,'i3-ui-prototype/scripts/native-devtools.cjs'));
6
+ // Synthetic contract fixtures only; none is browser or host invocation evidence.
7
+ function fixture(t){
8
+ const directory=fs.mkdtempSync(path.join(os.tmpdir(),'devtools-review-'));t.after(()=>fs.rmSync(directory,{recursive:true,force:true}));
9
+ const write=(file,value)=>{const full=path.join(directory,file);fs.mkdirSync(path.dirname(full),{recursive:true});fs.writeFileSync(full,Buffer.isBuffer(value)||typeof value==='string'?value:JSON.stringify(value));};
10
+ const current={revision:'r001',digest:'a'.repeat(64)},build={buildId:'b-fixture',buildDigest:'b'.repeat(64)};
11
+ write('inputs/acceptance.json',{browserReview:review.policy()});
12
+ const png=Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII=','base64');
13
+ const observations={};for(const kind of review.KINDS){const evidence=`qa/fixture/${kind}.${kind==='screenshot'?'png':kind==='performance'?'json':'txt'}`;write(evidence,kind==='screenshot'?png:kind==='performance'?{traceEvents:[{name:'synthetic-fixture',ts:1}]}:`Synthetic ${kind} fixture; not executed`);observations[kind]={status:'passed',summary:'Synthetic guard fixture, not executed',evidence};}
14
+ const report={schemaVersion:1,status:'passed',revision:current.revision,candidateDigest:current.digest,...build,executor:'synthetic-unit-fixture',startedAt:'2026-10-17T00:00:00.000Z',completedAt:'2026-10-17T00:00:01.000Z',previewBaseURL:'http://127.0.0.1:4173/',skill:{name:review.policy().skill,toolCallId:'fixture-skill',evidence:'synthetic-fixture only'},mcp:{name:'chrome-devtools-mcp',version:'1.11.0',evidence:'synthetic-fixture only',calls:['evaluate_script','take_screenshot','list_console_messages','list_network_requests','take_snapshot','performance_start_trace','performance_stop_trace'].map((name,index)=>({tool:`mcp__chrome-devtools__${name}`,toolCallId:`fixture-${index}`}))},observations,openItems:[]};
15
+ const lib={manifest:()=>current,verifyBuild:(_root,id)=>{assert.equal(id,build.buildId);return build;},claim:()=>{},release:()=>{}};
16
+ return {directory,write,current,build,report,lib};
17
+ }
18
+ test('new DevTools policy is explicit; old fixed inputs remain read-only and not required',t=>{
19
+ const f=fixture(t);assert.equal(review.check(f.directory,f.lib).status,'not-run');
20
+ f.write('inputs/acceptance.json',{});const before=fs.readdirSync(f.directory);assert.deepEqual(review.check(f.directory,f.lib),{status:'not-required',required:false});assert.deepEqual(fs.readdirSync(f.directory),before);
21
+ });
22
+ test('fixed framework inputs retain all six DevTools observations and reject policy removal',t=>{
23
+ const f=fixture(t);f.write('inputs/framework.json',{acceptance:{browserReview:review.policy()}});
24
+ for(const mutate of [value=>delete value.browserReview,value=>value.browserReview.required=false,value=>value.browserReview.observations.pop()]){
25
+ const input={browserReview:review.policy()};mutate(input);f.write('inputs/acceptance.json',input);
26
+ assert.equal(review.check(f.directory,f.lib).status,'failed');assert.throws(()=>review.record(f.directory,f.report,f.lib),/policy|observations|opt out/);
27
+ }
28
+ });
29
+ test('a review binds six actual-format evidence files to current candidate and build without attesting invocation',t=>{
30
+ const f=fixture(t),before=f.current.digest;const result=review.record(f.directory,f.report,f.lib);
31
+ assert.equal(result.status,'passed');assert.equal(result.invocationVerified,false);assert.equal(f.current.digest,before);
32
+ const checked=review.check(f.directory,f.lib);assert.equal(checked.status,'current');assert.equal(Object.keys(checked.evidenceDigests).length,6);
33
+ assert.match(checked.limitations,/host transcript/);
34
+ });
35
+ test('missing observation, stale identity and substituted evidence cannot pass the review gate',t=>{
36
+ const f=fixture(t);
37
+ for(const mutate of [r=>delete r.observations.network,r=>r.observations.console.status='not-run',r=>r.candidateDigest='c'.repeat(64),r=>r.buildDigest='c'.repeat(64),r=>r.skill.name='other-skill',r=>r.mcp.calls=r.mcp.calls.filter(c=>!c.tool.endsWith('performance_stop_trace')),r=>r.observations.network.evidence=r.observations.dom.evidence]){
38
+ const report=structuredClone(f.report);mutate(report);assert.throws(()=>review.record(f.directory,report,f.lib));assert.equal(fs.existsSync(path.join(f.directory,'qa/devtools-review.json')),false);
39
+ }
40
+ review.record(f.directory,f.report,f.lib);f.write(f.report.observations.network.evidence,'changed QA artifact');assert.equal(review.check(f.directory,f.lib).status,'failed');
41
+ });
42
+ test('Playwright ZIP and mislabeled screenshots do not become DevTools trace or PNG evidence',t=>{
43
+ const f=fixture(t);f.write(f.report.observations.performance.evidence,Buffer.from('PK\x03\x04fake-playwright-trace'));assert.throws(()=>review.record(f.directory,f.report,f.lib),/trace|JSON/);
44
+ f.write(f.report.observations.performance.evidence,{traceEvents:[{name:'fixture'}]});f.write(f.report.observations.screenshot.evidence,'not a PNG');assert.throws(()=>review.record(f.directory,f.report,f.lib),/PNG/);
45
+ });
46
+ test('a truncated PNG header is not a complete screenshot artifact',t=>{
47
+ const f=fixture(t),fake=Buffer.alloc(24);Buffer.from([137,80,78,71,13,10,26,10]).copy(fake);fake.write('IHDR',12);fake.writeUInt32BE(1,16);fake.writeUInt32BE(1,20);f.write(f.report.observations.screenshot.evidence,fake);
48
+ assert.throws(()=>review.record(f.directory,f.report,f.lib),/PNG/);
49
+ });
50
+ test('evidence paths cannot escape QA through traversal or symlinks',t=>{
51
+ const f=fixture(t),r=structuredClone(f.report);r.observations.dom.evidence='../outside.txt';assert.throws(()=>review.record(f.directory,r,f.lib),/QA|path/);
52
+ const link=path.join(f.directory,'qa/link.txt');fs.symlinkSync(path.join(f.directory,f.report.observations.dom.evidence),link);r.observations.dom.evidence='qa/link.txt';assert.throws(()=>review.record(f.directory,r,f.lib),/symlink/);
53
+ });