@pzy560117/opentest 0.1.2 → 0.1.4
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 +46 -7
- package/assets/manifest.json +84 -27
- package/assets/skills/opentest/SKILL.md +48 -40
- package/assets/skills/opentest/references/codex-harness-coverage-heuristics.md +55 -55
- package/assets/skills/opentest/references/lifecycle.md +1 -1
- package/assets/skills/opentest/references/matrix-format.md +14 -14
- package/assets/skills/opentest/references/opentest-driven-development.md +32 -32
- package/assets/skills/opentest/templates/acceptance-template.md +1 -1
- package/assets/skills/opentest/templates/matrix-template.md +3 -3
- package/assets/skills/opentest/templates/plan-template.md +17 -17
- package/assets/skills/opentest/templates/report-template.md +1 -1
- package/assets/skills/opentest-accept/SKILL.md +13 -12
- package/assets/skills/opentest-archive/SKILL.md +2 -2
- package/assets/skills/opentest-author/SKILL.md +14 -13
- package/assets/skills/opentest-heal/SKILL.md +2 -2
- package/assets/skills/opentest-plan/SKILL.md +17 -16
- package/assets/skills/opentest-run/SKILL.md +16 -15
- package/assets/skills/opentest-verify/SKILL.md +11 -10
- package/assets/skills-zh/opentest/SKILL.md +93 -0
- package/assets/skills-zh/opentest/references/acceptance-evidence.md +27 -0
- package/assets/skills-zh/opentest/references/codex-harness-coverage-heuristics.md +83 -0
- package/assets/skills-zh/opentest/references/command-routing.md +9 -0
- package/assets/skills-zh/opentest/references/lifecycle.md +16 -0
- package/assets/skills-zh/opentest/references/matrix-format.md +27 -0
- package/assets/skills-zh/opentest/references/opentest-driven-development.md +48 -0
- package/assets/skills-zh/opentest/references/quality-gate.md +24 -0
- package/assets/skills-zh/opentest/templates/acceptance-template.md +32 -0
- package/assets/skills-zh/opentest/templates/archive-layout.md +14 -0
- package/assets/skills-zh/opentest/templates/matrix-template.md +6 -0
- package/assets/skills-zh/opentest/templates/plan-template.md +28 -0
- package/assets/skills-zh/opentest/templates/report-template.md +28 -0
- package/assets/skills-zh/opentest-accept/SKILL.md +25 -0
- package/assets/skills-zh/opentest-archive/SKILL.md +8 -0
- package/assets/skills-zh/opentest-author/SKILL.md +27 -0
- package/assets/skills-zh/opentest-heal/SKILL.md +8 -0
- package/assets/skills-zh/opentest-plan/SKILL.md +30 -0
- package/assets/skills-zh/opentest-run/SKILL.md +28 -0
- package/assets/skills-zh/opentest-verify/SKILL.md +24 -0
- package/bin/opentest.js +137 -29
- package/package.json +1 -1
- package/scripts/prepublish-check.js +105 -6
- package/scripts/smoke-test.js +456 -23
package/scripts/smoke-test.js
CHANGED
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
import { readFileSync } from 'fs';
|
|
4
|
-
import { access } from 'fs/promises';
|
|
4
|
+
import { access, mkdir, mkdtemp, rm, writeFile } from 'fs/promises';
|
|
5
5
|
import path from 'path';
|
|
6
|
+
import { tmpdir } from 'os';
|
|
6
7
|
import { spawnSync } from 'child_process';
|
|
7
8
|
|
|
8
9
|
const manifest = JSON.parse(readFileSync('assets/manifest.json', 'utf8'));
|
|
9
10
|
let failures = 0;
|
|
11
|
+
const requiredLanguages = ['en', 'zh'];
|
|
12
|
+
const requiredPlatforms = ['codex', 'claude', 'cursor', 'opencode', 'gemini', 'qwen', 'qoder'];
|
|
13
|
+
const expectedSharedFiles = [
|
|
14
|
+
'opentest/scripts/opentest-state.sh',
|
|
15
|
+
'opentest/scripts/opentest-detect.sh',
|
|
16
|
+
'opentest/scripts/opentest-guard.sh',
|
|
17
|
+
];
|
|
10
18
|
|
|
11
19
|
async function exists(filePath) {
|
|
12
20
|
try {
|
|
@@ -17,45 +25,470 @@ async function exists(filePath) {
|
|
|
17
25
|
}
|
|
18
26
|
}
|
|
19
27
|
|
|
20
|
-
|
|
21
|
-
|
|
28
|
+
function assert(condition, message) {
|
|
29
|
+
if (!condition) {
|
|
30
|
+
console.error(message);
|
|
31
|
+
failures += 1;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function pathSegments(file) {
|
|
36
|
+
return file.split(/[\\/]+/).filter(Boolean);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function manifestPathRelativityErrors(kind, file) {
|
|
40
|
+
const errors = [];
|
|
41
|
+
const normalized = file.replace(/\\/g, '/');
|
|
42
|
+
|
|
43
|
+
if (path.posix.isAbsolute(normalized) || path.win32.isAbsolute(file)) {
|
|
44
|
+
errors.push(`[${kind}] Managed file path must be relative: ${file}`);
|
|
45
|
+
}
|
|
46
|
+
if (pathSegments(file).includes('..')) {
|
|
47
|
+
errors.push(`[${kind}] Managed file path must not traverse parent directories: ${file}`);
|
|
48
|
+
}
|
|
49
|
+
if (kind === 'LOCALIZED' && (normalized.startsWith('skills/') || normalized.startsWith('skills-zh/'))) {
|
|
50
|
+
errors.push(`[${kind}] Localized file must be relative to each language skills dir: ${file}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return errors;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function assertManifestPathRelativity(kind, file) {
|
|
57
|
+
for (const error of manifestPathRelativityErrors(kind, file)) {
|
|
58
|
+
console.error(error);
|
|
59
|
+
failures += 1;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function runManifestPathRelativitySelfCheck() {
|
|
64
|
+
const localizedInvalidPaths = [
|
|
65
|
+
path.resolve('opentest/SKILL.md'),
|
|
66
|
+
'/tmp/x.md',
|
|
67
|
+
'C:/tmp/x.md',
|
|
68
|
+
'C:\\tmp\\x.md',
|
|
69
|
+
'\\\\server\\share\\x.md',
|
|
70
|
+
'../opentest/SKILL.md',
|
|
71
|
+
'skills/opentest/SKILL.md',
|
|
72
|
+
'skills-zh/opentest/SKILL.md',
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
for (const file of localizedInvalidPaths) {
|
|
76
|
+
assert(
|
|
77
|
+
manifestPathRelativityErrors('LOCALIZED', file).length > 0,
|
|
78
|
+
`[SELFTEST] managedFiles.localized path relativity check did not reject ${file}`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const sharedInvalidPaths = [
|
|
83
|
+
path.resolve('opentest/scripts/opentest-state.sh'),
|
|
84
|
+
'/tmp/x.md',
|
|
85
|
+
'C:/tmp/x.md',
|
|
86
|
+
'C:\\tmp\\x.md',
|
|
87
|
+
'\\\\server\\share\\x.md',
|
|
88
|
+
'../opentest/scripts/opentest-state.sh',
|
|
89
|
+
];
|
|
90
|
+
|
|
91
|
+
for (const file of sharedInvalidPaths) {
|
|
92
|
+
assert(
|
|
93
|
+
manifestPathRelativityErrors('SHARED', file).length > 0,
|
|
94
|
+
`[SELFTEST] managedFiles.shared path relativity check did not reject ${file}`,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function languageById(languageId) {
|
|
100
|
+
return Array.isArray(manifest.languages)
|
|
101
|
+
? manifest.languages.find((entry) => entry.id === languageId)
|
|
102
|
+
: undefined;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function assertManifestStructure() {
|
|
106
|
+
assert(typeof manifest.version === 'string' && manifest.version.length > 0, '[MANIFEST] missing version');
|
|
107
|
+
assert(Array.isArray(manifest.languages), '[MANIFEST] languages must be an array');
|
|
108
|
+
for (const language of requiredLanguages) {
|
|
109
|
+
const entry = languageById(language);
|
|
110
|
+
assert(entry, `[MANIFEST] missing languages entry ${language}`);
|
|
111
|
+
assert(entry?.name, `[MANIFEST] missing languages.${language}.name`);
|
|
112
|
+
assert(entry?.skillsDir, `[MANIFEST] missing languages.${language}.skillsDir`);
|
|
113
|
+
}
|
|
114
|
+
assert(languageById('en')?.name === 'English', '[MANIFEST] languages.en.name must be English');
|
|
115
|
+
assert(languageById('en')?.skillsDir === 'skills', '[MANIFEST] languages.en.skillsDir must be skills');
|
|
116
|
+
assert(languageById('zh')?.name === '中文', '[MANIFEST] languages.zh.name must be 中文');
|
|
117
|
+
assert(languageById('zh')?.skillsDir === 'skills-zh', '[MANIFEST] languages.zh.skillsDir must be skills-zh');
|
|
118
|
+
|
|
119
|
+
assert(Array.isArray(manifest.platforms), '[MANIFEST] missing platforms');
|
|
120
|
+
for (const platformId of requiredPlatforms) {
|
|
121
|
+
const platform = manifest.platforms?.find((entry) => entry.id === platformId);
|
|
122
|
+
assert(platform, `[MANIFEST] missing platforms.${platformId}`);
|
|
123
|
+
assert(platform?.name, `[MANIFEST] missing platforms.${platformId}.name`);
|
|
124
|
+
assert(platform?.projectSkillsDir, `[MANIFEST] missing platforms.${platformId}.projectSkillsDir`);
|
|
125
|
+
assert(platform?.globalSkillsDir, `[MANIFEST] missing platforms.${platformId}.globalSkillsDir`);
|
|
126
|
+
}
|
|
127
|
+
const opencode = manifest.platforms?.find((entry) => entry.id === 'opencode');
|
|
128
|
+
assert(opencode?.globalSkillsDir === '.config/opencode/skills', '[MANIFEST] platforms.opencode.globalSkillsDir must be .config/opencode/skills');
|
|
129
|
+
|
|
130
|
+
assert(manifest.managedFiles && typeof manifest.managedFiles === 'object' && !Array.isArray(manifest.managedFiles), '[MANIFEST] missing managedFiles');
|
|
131
|
+
assert(Array.isArray(manifest.managedFiles?.localized), '[MANIFEST] missing managedFiles.localized');
|
|
132
|
+
assert(Array.isArray(manifest.managedFiles?.shared), '[MANIFEST] missing managedFiles.shared');
|
|
133
|
+
for (const file of expectedSharedFiles) {
|
|
134
|
+
assert(manifest.managedFiles?.shared?.includes(file), `[MANIFEST] missing managedFiles.shared entry ${file}`);
|
|
135
|
+
}
|
|
136
|
+
for (const file of manifest.managedFiles?.shared ?? []) {
|
|
137
|
+
assert(!file.startsWith('skills/'), `[MANIFEST] managedFiles.shared must be relative to English skills dir: ${file}`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function assertPrepublishGateCoversManifestAssets() {
|
|
142
|
+
const prepublishCheck = readFileSync('scripts/prepublish-check.js', 'utf8');
|
|
143
|
+
const secretsScanIndex = prepublishCheck.indexOf('for (const filePath of walkFiles');
|
|
144
|
+
const manifestReadIndex = prepublishCheck.indexOf("assets/manifest.json");
|
|
145
|
+
|
|
146
|
+
assert(secretsScanIndex !== -1, '[PREPUBLISH] secrets scan loop not found');
|
|
147
|
+
assert(manifestReadIndex !== -1, '[PREPUBLISH] prepublish check must load assets/manifest.json');
|
|
148
|
+
assert(
|
|
149
|
+
manifestReadIndex !== -1 && secretsScanIndex !== -1 && manifestReadIndex < secretsScanIndex,
|
|
150
|
+
'[PREPUBLISH] manifest checks must run before secrets scanning',
|
|
151
|
+
);
|
|
152
|
+
assert(prepublishCheck.includes('manifest.languages'), '[PREPUBLISH] must validate manifest.languages');
|
|
153
|
+
assert(prepublishCheck.includes("entry.id === 'zh'"), '[PREPUBLISH] must require zh language in manifest');
|
|
154
|
+
assert(prepublishCheck.includes('manifest.managedFiles?.localized'), '[PREPUBLISH] must validate managedFiles.localized');
|
|
155
|
+
assert(prepublishCheck.includes('manifest.managedFiles?.shared'), '[PREPUBLISH] must validate managedFiles.shared');
|
|
156
|
+
assert(prepublishCheck.includes("assets/skills-zh"), '[PREPUBLISH] must validate assets/skills-zh exists');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function assertPrepublishRejectsPublishSecretTextFiles() {
|
|
160
|
+
const fixtureDir = path.join('assets', 'opentest-smoke-secret-fixtures');
|
|
161
|
+
const fixtures = [
|
|
162
|
+
{
|
|
163
|
+
file: path.join(fixtureDir, 'fixture.pem'),
|
|
164
|
+
content: `${['-----BEGIN PRIVATE', 'KEY-----'].join(' ')}\nnot-a-real-key\n-----END PRIVATE KEY-----\n`,
|
|
165
|
+
expected: 'Private key',
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
file: path.join(fixtureDir, '.env'),
|
|
169
|
+
content: `${['OPENTEST', 'TOKEN'].join('_')}="super-secret-token-value"\n`,
|
|
170
|
+
expected: 'Secret/token',
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
file: path.join(fixtureDir, 'extensionless-secret'),
|
|
174
|
+
content: `${['api', 'key'].join('_')}="1234567890abcdefghijklmnop"\n`,
|
|
175
|
+
expected: 'API key',
|
|
176
|
+
},
|
|
177
|
+
];
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
await mkdir(fixtureDir, { recursive: true });
|
|
181
|
+
await Promise.all(fixtures.map((fixture) => writeFile(fixture.file, fixture.content)));
|
|
182
|
+
|
|
183
|
+
const result = spawnSync(process.execPath, ['scripts/prepublish-check.js'], { encoding: 'utf8' });
|
|
184
|
+
assert(result.status !== 0, '[PREPUBLISH] expected secrets in publish text files to fail prepublish check');
|
|
185
|
+
for (const fixture of fixtures) {
|
|
186
|
+
assert(
|
|
187
|
+
result.stderr.includes(fixture.expected) && result.stderr.includes(fixture.file),
|
|
188
|
+
`[PREPUBLISH] expected ${fixture.file} to be reported as ${fixture.expected}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`,
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
} finally {
|
|
192
|
+
await rm(fixtureDir, { recursive: true, force: true });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function localizedFiles() {
|
|
197
|
+
if (!Array.isArray(manifest.managedFiles?.localized)) {
|
|
198
|
+
return [];
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return manifest.managedFiles.localized.flatMap((entry) => (
|
|
202
|
+
(manifest.languages ?? []).map((language) => path.join(language.skillsDir, entry))
|
|
203
|
+
));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function sharedFiles() {
|
|
207
|
+
return Array.isArray(manifest.managedFiles?.shared) ? manifest.managedFiles.shared : [];
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function sharedAssetFiles() {
|
|
211
|
+
return sharedFiles().map((entry) => path.join(languageById('en')?.skillsDir ?? 'skills', entry));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function hasChineseText(content) {
|
|
215
|
+
return /\p{Script=Han}/u.test(content);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function manifestDirSearchRoot(manifestDir, scope) {
|
|
219
|
+
const normalized = manifestDir.replace(/\\/g, '/');
|
|
220
|
+
if (scope === 'global') {
|
|
221
|
+
return normalized.startsWith('~/') ? `$HOME/${normalized.slice(2)}` : `$HOME/${normalized}`;
|
|
222
|
+
}
|
|
223
|
+
return normalized;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function assertOpenTestSearchRootsCoverPlatforms() {
|
|
227
|
+
for (const language of manifest.languages ?? []) {
|
|
228
|
+
const languageLabel = `${language.id ?? '<missing id>'} (${language.name ?? '<missing name>'})`;
|
|
229
|
+
if (!language.skillsDir) {
|
|
230
|
+
assert(false, `[SEARCH_ROOTS] ${languageLabel} missing skillsDir`);
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const skillPath = path.join('assets', language.skillsDir, 'opentest', 'SKILL.md');
|
|
235
|
+
let skillContent;
|
|
236
|
+
try {
|
|
237
|
+
skillContent = readFileSync(skillPath, 'utf8');
|
|
238
|
+
} catch {
|
|
239
|
+
assert(false, `[SEARCH_ROOTS] ${languageLabel} missing router at ${skillPath}`);
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const searchRootsLine = skillContent.split(/\r?\n/).find((line) => line.startsWith('OPENTEST_SEARCH_ROOTS='));
|
|
244
|
+
assert(searchRootsLine, `[SEARCH_ROOTS] ${languageLabel} router ${skillPath} must define OPENTEST_SEARCH_ROOTS`);
|
|
245
|
+
if (!searchRootsLine) continue;
|
|
246
|
+
|
|
247
|
+
for (const platform of manifest.platforms ?? []) {
|
|
248
|
+
for (const [scope, manifestDir] of [
|
|
249
|
+
['project', platform.projectSkillsDir],
|
|
250
|
+
['global', platform.globalSkillsDir],
|
|
251
|
+
]) {
|
|
252
|
+
if (!manifestDir) {
|
|
253
|
+
assert(false, `[SEARCH_ROOTS] ${languageLabel} missing ${platform.id} ${scope} manifest dir`);
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const expectedRoot = manifestDirSearchRoot(manifestDir, scope);
|
|
258
|
+
assert(
|
|
259
|
+
searchRootsLine.includes(`"${expectedRoot}"`),
|
|
260
|
+
`[SEARCH_ROOTS] ${languageLabel} router ${skillPath} missing ${platform.id} ${scope} search root ${expectedRoot}`,
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function assertLanguageAssetContracts() {
|
|
268
|
+
for (const file of manifest.managedFiles?.localized ?? []) {
|
|
269
|
+
const englishPath = path.join('assets', languageById('en')?.skillsDir ?? 'skills', file);
|
|
270
|
+
const chinesePath = path.join('assets', languageById('zh')?.skillsDir ?? 'skills-zh', file);
|
|
271
|
+
|
|
272
|
+
if (existsSyncLike(englishPath)) {
|
|
273
|
+
const content = readFileSync(englishPath, 'utf8');
|
|
274
|
+
assert(!hasChineseText(content), `[LANGUAGE] English asset must not contain Chinese text: ${englishPath}`);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (existsSyncLike(chinesePath)) {
|
|
278
|
+
const content = readFileSync(chinesePath, 'utf8');
|
|
279
|
+
assert(hasChineseText(content), `[LANGUAGE] Chinese asset must remain Chinese: ${chinesePath}`);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function existsSyncLike(filePath) {
|
|
285
|
+
try {
|
|
286
|
+
readFileSync(filePath);
|
|
287
|
+
return true;
|
|
288
|
+
} catch {
|
|
289
|
+
return false;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function assertCliRejects(args, expectedStderr) {
|
|
294
|
+
const result = spawnSync(process.execPath, ['bin/opentest.js', ...args], { encoding: 'utf8' });
|
|
295
|
+
assert(result.status !== 0, `[CLI] expected failure for: ${args.join(' ')}`);
|
|
296
|
+
assert(
|
|
297
|
+
result.stderr.includes(expectedStderr),
|
|
298
|
+
`[CLI] expected stderr to include "${expectedStderr}" for: ${args.join(' ')}\n${result.stderr}`,
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function runCli(args, options = {}) {
|
|
303
|
+
return spawnSync(process.execPath, ['bin/opentest.js', ...args], {
|
|
304
|
+
encoding: 'utf8',
|
|
305
|
+
...options,
|
|
306
|
+
env: {
|
|
307
|
+
...process.env,
|
|
308
|
+
...(options.env ?? {}),
|
|
309
|
+
},
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function assertCliSucceeds(args, options = {}) {
|
|
314
|
+
const result = runCli(args, options);
|
|
315
|
+
assert(
|
|
316
|
+
result.status === 0,
|
|
317
|
+
`[CLI] expected success for: ${args.join(' ')}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`,
|
|
318
|
+
);
|
|
319
|
+
return result;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async function makeTempDir(prefix) {
|
|
323
|
+
return mkdtemp(path.join(tmpdir(), prefix));
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async function assertExists(filePath, message) {
|
|
327
|
+
assert(await exists(filePath), message);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
async function assertMissing(filePath, message) {
|
|
331
|
+
assert(!(await exists(filePath)), message);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async function assertInstallBehavior() {
|
|
335
|
+
const project = await makeTempDir('opentest-project-');
|
|
336
|
+
const projectResult = assertCliSucceeds([
|
|
337
|
+
'install',
|
|
338
|
+
'--scope',
|
|
339
|
+
'project',
|
|
340
|
+
'--platform',
|
|
341
|
+
'qoder',
|
|
342
|
+
'--language',
|
|
343
|
+
'zh',
|
|
344
|
+
'--project',
|
|
345
|
+
project,
|
|
346
|
+
]);
|
|
347
|
+
const qoderSkills = path.join(project, '.qoder', 'skills');
|
|
348
|
+
await assertExists(path.join(qoderSkills, 'opentest', 'SKILL.md'), '[INSTALL] missing zh qoder project opentest/SKILL.md');
|
|
349
|
+
await assertExists(
|
|
350
|
+
path.join(qoderSkills, 'opentest', 'scripts', 'opentest-state.sh'),
|
|
351
|
+
'[INSTALL] missing shared script copied from English assets for zh qoder project install',
|
|
352
|
+
);
|
|
353
|
+
assert(projectResult.stdout.includes('Platform: Qoder'), '[INSTALL] project output must include Platform: Qoder');
|
|
354
|
+
assert(projectResult.stdout.includes('Language: 中文'), '[INSTALL] project output must include Language: 中文');
|
|
355
|
+
assert(
|
|
356
|
+
projectResult.stdout.includes('Restart your AI coding tool or open a new session'),
|
|
357
|
+
'[INSTALL] restart hint must be generic across AI coding tools',
|
|
358
|
+
);
|
|
359
|
+
|
|
360
|
+
const home = await makeTempDir('opentest-home-');
|
|
361
|
+
assertCliSucceeds(['install', '--scope', 'global', '--platform', 'opencode', '--language', 'en'], {
|
|
362
|
+
env: {
|
|
363
|
+
HOME: home,
|
|
364
|
+
USERPROFILE: home,
|
|
365
|
+
},
|
|
366
|
+
});
|
|
367
|
+
await assertExists(
|
|
368
|
+
path.join(home, '.config', 'opencode', 'skills', 'opentest', 'SKILL.md'),
|
|
369
|
+
'[INSTALL] missing en opencode global opentest/SKILL.md',
|
|
370
|
+
);
|
|
371
|
+
|
|
372
|
+
const forceProject = await makeTempDir('opentest-force-');
|
|
373
|
+
const forceSkills = path.join(forceProject, '.qoder', 'skills');
|
|
374
|
+
await mkdir(path.join(forceSkills, 'opentest'), { recursive: true });
|
|
375
|
+
await mkdir(path.join(forceSkills, 'user-skill'), { recursive: true });
|
|
376
|
+
await writeFile(path.join(forceSkills, 'opentest', 'stale.txt'), 'stale opentest managed file\n');
|
|
377
|
+
await writeFile(path.join(forceSkills, 'user-skill', 'SKILL.md'), 'user managed skill\n');
|
|
378
|
+
|
|
379
|
+
const forceResult = assertCliSucceeds([
|
|
380
|
+
'install',
|
|
381
|
+
'--scope',
|
|
382
|
+
'project',
|
|
383
|
+
'--platform',
|
|
384
|
+
'qoder',
|
|
385
|
+
'--language',
|
|
386
|
+
'zh',
|
|
387
|
+
'--project',
|
|
388
|
+
forceProject,
|
|
389
|
+
'--force',
|
|
390
|
+
]);
|
|
391
|
+
await assertExists(
|
|
392
|
+
path.join(forceSkills, 'user-skill', 'SKILL.md'),
|
|
393
|
+
'[INSTALL] --force must preserve non-OpenTest user skill directories',
|
|
394
|
+
);
|
|
395
|
+
await assertMissing(
|
|
396
|
+
path.join(forceSkills, 'opentest', 'stale.txt'),
|
|
397
|
+
'[INSTALL] --force must replace OpenTest managed directories',
|
|
398
|
+
);
|
|
399
|
+
assert(forceResult.stdout.includes('Platform: Qoder'), '[INSTALL] force output must include Platform: Qoder');
|
|
400
|
+
|
|
401
|
+
const noForceProject = await makeTempDir('opentest-no-force-');
|
|
402
|
+
const existingSkill = path.join(noForceProject, '.qoder', 'skills', 'opentest');
|
|
403
|
+
await mkdir(existingSkill, { recursive: true });
|
|
404
|
+
await writeFile(path.join(existingSkill, 'SKILL.md'), 'existing opentest skill\n');
|
|
405
|
+
const noForceResult = runCli([
|
|
406
|
+
'install',
|
|
407
|
+
'--scope',
|
|
408
|
+
'project',
|
|
409
|
+
'--platform',
|
|
410
|
+
'qoder',
|
|
411
|
+
'--language',
|
|
412
|
+
'zh',
|
|
413
|
+
'--project',
|
|
414
|
+
noForceProject,
|
|
415
|
+
]);
|
|
416
|
+
assert(noForceResult.status !== 0, '[INSTALL] expected existing OpenTest skill to fail without --force');
|
|
417
|
+
assert(
|
|
418
|
+
noForceResult.stderr.includes('already exists. Re-run with --force to overwrite.'),
|
|
419
|
+
`[INSTALL] expected overwrite guidance for existing skill\n${noForceResult.stderr}`,
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
runManifestPathRelativitySelfCheck();
|
|
424
|
+
assertManifestStructure();
|
|
425
|
+
assertPrepublishGateCoversManifestAssets();
|
|
426
|
+
assertOpenTestSearchRootsCoverPlatforms();
|
|
427
|
+
assertLanguageAssetContracts();
|
|
428
|
+
await assertPrepublishRejectsPublishSecretTextFiles();
|
|
429
|
+
|
|
430
|
+
assertCliRejects(['install', '--language', 'jp'], 'Available languages: en, zh');
|
|
431
|
+
assertCliRejects(['install', '--platform', 'invalid'], 'Available platforms: codex, claude, cursor, opencode, gemini, qwen, qoder');
|
|
432
|
+
assertCliRejects(['install', '--scope', 'team'], 'Available scopes: project, global');
|
|
433
|
+
await assertInstallBehavior();
|
|
434
|
+
|
|
435
|
+
for (const assetPath of [...localizedFiles(), ...sharedAssetFiles()]) {
|
|
436
|
+
const fullPath = path.join('assets', assetPath);
|
|
22
437
|
if (!(await exists(fullPath))) {
|
|
23
438
|
console.error(`[MISSING] ${fullPath}`);
|
|
24
439
|
failures += 1;
|
|
25
440
|
}
|
|
26
441
|
}
|
|
27
442
|
|
|
28
|
-
for (const
|
|
29
|
-
'
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
'
|
|
35
|
-
'opentest-heal',
|
|
36
|
-
'opentest-archive',
|
|
37
|
-
]) {
|
|
38
|
-
const skillFile = path.join('assets', 'skills', skillName, 'SKILL.md');
|
|
39
|
-
const content = readFileSync(skillFile, 'utf8');
|
|
443
|
+
for (const skillFile of localizedFiles().filter((entry) => entry.endsWith('SKILL.md'))) {
|
|
444
|
+
const fullPath = path.join('assets', skillFile);
|
|
445
|
+
if (!(await exists(fullPath))) {
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const content = readFileSync(fullPath, 'utf8');
|
|
40
450
|
if (!/^name:\s+/m.test(content) || !/^description:\s+/m.test(content)) {
|
|
41
|
-
console.error(`[FRONTMATTER] Missing name or description in ${
|
|
451
|
+
console.error(`[FRONTMATTER] Missing name or description in ${fullPath}`);
|
|
42
452
|
failures += 1;
|
|
43
453
|
}
|
|
44
454
|
}
|
|
45
455
|
|
|
46
|
-
for (const script of
|
|
47
|
-
'assets
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
456
|
+
for (const script of sharedAssetFiles()) {
|
|
457
|
+
const scriptPath = path.join('assets', script);
|
|
458
|
+
if (!(await exists(scriptPath))) {
|
|
459
|
+
continue;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
if (!scriptPath.endsWith('.sh')) {
|
|
463
|
+
console.error(`[SCRIPT] Shared file must be a shell script: ${scriptPath}`);
|
|
464
|
+
failures += 1;
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const result = spawnSync('bash', ['-n', scriptPath], { encoding: 'utf8' });
|
|
52
469
|
if (result.status !== 0) {
|
|
53
|
-
console.error(`[BASH] ${
|
|
470
|
+
console.error(`[BASH] ${scriptPath}`);
|
|
54
471
|
console.error(result.stderr || result.stdout);
|
|
55
472
|
failures += 1;
|
|
56
473
|
}
|
|
57
474
|
}
|
|
58
475
|
|
|
476
|
+
for (const file of manifest.managedFiles?.localized ?? []) {
|
|
477
|
+
assertManifestPathRelativity('LOCALIZED', file);
|
|
478
|
+
if (!file.endsWith('.md')) {
|
|
479
|
+
console.error(`[LOCALIZED] Localized file must be Markdown: ${file}`);
|
|
480
|
+
failures += 1;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
for (const file of manifest.managedFiles?.shared ?? []) {
|
|
485
|
+
assertManifestPathRelativity('SHARED', file);
|
|
486
|
+
if (file.endsWith('.md')) {
|
|
487
|
+
console.error(`[SHARED] Shared file must not be Markdown: ${file}`);
|
|
488
|
+
failures += 1;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
59
492
|
if (failures > 0) {
|
|
60
493
|
console.error(`[SMOKE] ${failures} failure(s)`);
|
|
61
494
|
process.exit(1);
|