@testcollab/cli 1.3.0

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.
@@ -0,0 +1,110 @@
1
+ {
2
+ "stats": {
3
+ "suites": 1,
4
+ "tests": 3,
5
+ "passes": 1,
6
+ "pending": 1,
7
+ "failures": 1,
8
+ "start": "2026-02-24T00:00:00.000Z",
9
+ "end": "2026-02-24T00:00:00.620Z",
10
+ "duration": 620,
11
+ "testsRegistered": 3,
12
+ "passPercent": 33.3333,
13
+ "pendingPercent": 33.3333,
14
+ "other": 0,
15
+ "hasOther": false,
16
+ "skipped": 1,
17
+ "hasSkipped": true
18
+ },
19
+ "results": [
20
+ {
21
+ "uuid": "suite-authentication",
22
+ "title": "Authentication",
23
+ "fullFile": "",
24
+ "file": "",
25
+ "beforeHooks": [],
26
+ "afterHooks": [],
27
+ "tests": [
28
+ {
29
+ "title": "[TC-1913] should login with valid credentials",
30
+ "fullTitle": "Authentication.Login [TC-1913] should login with valid credentials",
31
+ "timedOut": false,
32
+ "duration": 120,
33
+ "state": "passed",
34
+ "speed": "fast",
35
+ "pass": true,
36
+ "fail": false,
37
+ "pending": false,
38
+ "context": null,
39
+ "code": "",
40
+ "err": {},
41
+ "uuid": "test-auth-pass",
42
+ "parentUUID": "suite-authentication",
43
+ "isHook": false,
44
+ "skipped": false
45
+ },
46
+ {
47
+ "title": "[TC-1914] should reject invalid password",
48
+ "fullTitle": "Authentication.Login [TC-1914] should reject invalid password",
49
+ "timedOut": false,
50
+ "duration": 430,
51
+ "state": "failed",
52
+ "speed": "slow",
53
+ "pass": false,
54
+ "fail": true,
55
+ "pending": false,
56
+ "context": null,
57
+ "code": "",
58
+ "err": {
59
+ "message": "Expected invalid password error",
60
+ "estack": "AssertionError: expected status 401 but got 200"
61
+ },
62
+ "uuid": "test-auth-fail",
63
+ "parentUUID": "suite-authentication",
64
+ "isHook": false,
65
+ "skipped": false
66
+ },
67
+ {
68
+ "title": "[TC-1915] should support SSO login",
69
+ "fullTitle": "Authentication.Login [TC-1915] should support SSO login",
70
+ "timedOut": false,
71
+ "duration": 70,
72
+ "state": "skipped",
73
+ "speed": "fast",
74
+ "pass": false,
75
+ "fail": false,
76
+ "pending": true,
77
+ "context": null,
78
+ "code": "",
79
+ "err": {},
80
+ "uuid": "test-auth-skip",
81
+ "parentUUID": "suite-authentication",
82
+ "isHook": false,
83
+ "skipped": true
84
+ }
85
+ ],
86
+ "suites": [],
87
+ "passes": [
88
+ "test-auth-pass"
89
+ ],
90
+ "failures": [
91
+ "test-auth-fail"
92
+ ],
93
+ "pending": [
94
+ "test-auth-skip"
95
+ ],
96
+ "skipped": [
97
+ "test-auth-skip"
98
+ ],
99
+ "duration": 620,
100
+ "root": false,
101
+ "rootEmpty": false,
102
+ "_timeout": 0
103
+ }
104
+ ],
105
+ "meta": {
106
+ "source": "sample",
107
+ "generatedBy": "testcollab-cli",
108
+ "generatedAt": "2026-02-24T00:00:00.000Z"
109
+ }
110
+ }
@@ -0,0 +1,145 @@
1
+ import { execSync } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
8
+ const rootDir = path.resolve(__dirname, '..');
9
+
10
+ function parseVersion(version) {
11
+ const cleanVersion = version.split('-')[0];
12
+ const parts = cleanVersion.split('.').map(Number);
13
+ if (parts.length !== 3 || parts.some(Number.isNaN)) {
14
+ throw new Error(`Invalid version format: ${version}`);
15
+ }
16
+ return parts;
17
+ }
18
+
19
+ function compare(v1, v2) {
20
+ const p1 = parseVersion(v1);
21
+ const p2 = parseVersion(v2);
22
+ for (let i = 0; i < 3; i += 1) {
23
+ if (p1[i] > p2[i]) return 1;
24
+ if (p1[i] < p2[i]) return -1;
25
+ }
26
+ return 0;
27
+ }
28
+
29
+ function bumpMinor(version) {
30
+ const [maj, min] = parseVersion(version);
31
+ return `${maj}.${min + 1}.0`;
32
+ }
33
+
34
+ function bumpPatch(version) {
35
+ const [maj, min, pat] = parseVersion(version);
36
+ return `${maj}.${min}.${pat + 1}`;
37
+ }
38
+
39
+ function npmView(pkg, token) {
40
+ if (!token) {
41
+ try {
42
+ const v = execSync(`npm view ${pkg} version`, { encoding: 'utf8' }).trim();
43
+ console.log(`Latest published version for ${pkg}: ${v}`);
44
+ return v;
45
+ } catch (error) {
46
+ console.log(`Could not fetch version from npm for ${pkg} (might not exist yet).`);
47
+ return undefined;
48
+ }
49
+ }
50
+
51
+ const tmpRc = path.join(os.tmpdir(), `.npmrc-${Date.now()}-${Math.random()}`);
52
+ try {
53
+ fs.writeFileSync(tmpRc, `//registry.npmjs.org/:_authToken=${token}\n`, 'utf8');
54
+ const v = execSync(`npm view ${pkg} version --userconfig ${tmpRc}`, { encoding: 'utf8' }).trim();
55
+ console.log(`Latest published version for ${pkg} (auth): ${v}`);
56
+ return v;
57
+ } catch (error) {
58
+ console.log(`Could not fetch version from npm for ${pkg} with auth (might not exist yet).`);
59
+ return undefined;
60
+ } finally {
61
+ try {
62
+ fs.unlinkSync(tmpRc);
63
+ } catch (_) {
64
+ // ignore cleanup errors
65
+ }
66
+ }
67
+ }
68
+
69
+ function readJson(jsonPath) {
70
+ return JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
71
+ }
72
+
73
+ function writeJson(jsonPath, data) {
74
+ fs.writeFileSync(jsonPath, `${JSON.stringify(data, null, 2)}\n`);
75
+ }
76
+
77
+ function updateLockFile(lockPath, newVersion) {
78
+ if (!fs.existsSync(lockPath)) return;
79
+
80
+ const lockJson = readJson(lockPath);
81
+
82
+ if (lockJson.version) {
83
+ lockJson.version = newVersion;
84
+ }
85
+
86
+ if (lockJson.packages && lockJson.packages['']) {
87
+ lockJson.packages[''].version = newVersion;
88
+ }
89
+
90
+ writeJson(lockPath, lockJson);
91
+ console.log(`package-lock.json updated to ${newVersion}`);
92
+ }
93
+
94
+ try {
95
+ const packageJsonPath = path.join(rootDir, 'package.json');
96
+ const lockPath = path.join(rootDir, 'package-lock.json');
97
+
98
+ const packageJson = readJson(packageJsonPath);
99
+ const packageName = packageJson.name;
100
+ const localVersion = packageJson.version;
101
+ console.log(`Local package.json version: ${localVersion}`);
102
+
103
+ const publishedVersion = npmView(packageName, process.env.NPM_TOKEN);
104
+ const knownPublished = [publishedVersion].filter(Boolean);
105
+
106
+ let baseVersion = localVersion;
107
+ for (const version of knownPublished) {
108
+ if (compare(version, baseVersion) === 1) {
109
+ baseVersion = version;
110
+ }
111
+ }
112
+
113
+ let newVersion;
114
+ const localIsHighest = knownPublished.every(
115
+ (version) => !version || compare(localVersion, version) === 1,
116
+ );
117
+
118
+ if (localIsHighest) {
119
+ newVersion = localVersion;
120
+ console.log(`Local version (${localVersion}) is highest; keeping it.`);
121
+ } else {
122
+ newVersion = bumpMinor(baseVersion);
123
+ console.log(`Auto-incrementing minor from ${baseVersion} to ${newVersion}`);
124
+ }
125
+
126
+ const publishedSet = new Set(knownPublished);
127
+ while (publishedSet.has(newVersion)) {
128
+ const bumped = bumpPatch(newVersion);
129
+ console.log(`Version ${newVersion} already published; bumping patch to ${bumped}`);
130
+ newVersion = bumped;
131
+ }
132
+
133
+ if (newVersion !== localVersion) {
134
+ packageJson.version = newVersion;
135
+ writeJson(packageJsonPath, packageJson);
136
+ console.log(`package.json updated to ${newVersion}`);
137
+ } else {
138
+ console.log('package.json version already at desired value; no change needed.');
139
+ }
140
+
141
+ updateLockFile(lockPath, newVersion);
142
+ } catch (error) {
143
+ console.error('Error bumping version:', error);
144
+ process.exit(1);
145
+ }
@@ -0,0 +1,123 @@
1
+ import path from 'path';
2
+
3
+ const DISCOVERY_SCHEMA = {
4
+ type: 'object',
5
+ additionalProperties: false,
6
+ properties: {
7
+ families: {
8
+ type: 'array',
9
+ items: {
10
+ type: 'object',
11
+ additionalProperties: false,
12
+ properties: {
13
+ name: { type: 'string' },
14
+ kind: { type: 'string', enum: ['backend', 'ui', 'cli', 'job', 'lib'] },
15
+ paths: { type: 'array', items: { type: 'string' } },
16
+ priority: { type: 'string' },
17
+ output_root: { type: 'string' },
18
+ },
19
+ required: ['name', 'paths'],
20
+ },
21
+ },
22
+ targets: {
23
+ type: 'array',
24
+ items: {
25
+ type: 'object',
26
+ additionalProperties: false,
27
+ properties: {
28
+ id: { type: 'string' },
29
+ family: { type: 'string' },
30
+ type: { type: 'string' },
31
+ entry: { type: 'array', items: { type: 'string' } },
32
+ priority: { type: 'string' },
33
+ output_path: { type: 'string' },
34
+ },
35
+ required: ['id', 'family', 'entry'],
36
+ },
37
+ },
38
+ notes: {
39
+ type: 'array',
40
+ items: { type: 'string' },
41
+ },
42
+ },
43
+ required: ['families', 'targets'],
44
+ };
45
+
46
+ export function buildDiscoveryPrompt(summaries, snippetLines) {
47
+ const header = [
48
+ 'You are specgen. Infer target families and targets from the supplied source files.',
49
+ '',
50
+ 'Definitions:',
51
+ '- Target family: domain bucket with name, kind (backend/ui/cli/job/lib), and paths (globs/dirs).',
52
+ '- Target: concrete unit with id, family, type (api_controller/service/ui_component/page/job/cli_command/module), and entry file(s).',
53
+ '',
54
+ 'Rules:',
55
+ '- Use directory names for stable ids; prefer one target per route/controller/component/job/cli.',
56
+ '- Skip vendor/build/test/config files.',
57
+ '- Return JSON matching the schema.',
58
+ '',
59
+ 'Files:',
60
+ ];
61
+ const body = summaries.map((s, idx) => {
62
+ const trimmed = (s.snippet || '').split('\n').slice(0, snippetLines).join('\n');
63
+ return `#${idx + 1} ${s.rel}\n---\n${trimmed}\n---`;
64
+ });
65
+ return [...header, ...body].join('\n');
66
+ }
67
+
68
+ export async function aiDiscoverTargets({ provider, anthropic, gemini, model, summaries, outRoot, snippetLines }) {
69
+ const prompt = buildDiscoveryPrompt(summaries, snippetLines);
70
+ if (provider === 'anthropic') {
71
+ const resp = await anthropic.messages.create({
72
+ model,
73
+ max_tokens: 4000,
74
+ temperature: 0.1,
75
+ response_format: { type: 'json_schema', json_schema: { name: 'DiscoverySpec', schema: DISCOVERY_SCHEMA } },
76
+ messages: [
77
+ {
78
+ role: 'user',
79
+ content: [{ type: 'text', text: prompt }],
80
+ },
81
+ ],
82
+ });
83
+ const text = resp?.content?.[0]?.text;
84
+ if (!text) return null;
85
+ return normalizeDiscovery(JSON.parse(text), outRoot);
86
+ }
87
+
88
+ if (provider === 'gemini') {
89
+ const modelHandle = gemini.getGenerativeModel({
90
+ model,
91
+ generationConfig: {
92
+ responseMimeType: 'application/json',
93
+ responseSchema: DISCOVERY_SCHEMA,
94
+ },
95
+ });
96
+ const result = await modelHandle.generateContent([{ text: prompt }]);
97
+ const text = result?.response?.text();
98
+ if (!text) return null;
99
+ return normalizeDiscovery(JSON.parse(text), outRoot);
100
+ }
101
+
102
+ throw new Error(`Unsupported provider for discovery: ${provider}`);
103
+ }
104
+
105
+ function normalizeDiscovery(parsed, outRoot) {
106
+ const normalizedFamilies = (parsed.families || []).map((f) => ({
107
+ ...f,
108
+ output_root: f.output_root || path.join(outRoot, f.name),
109
+ }));
110
+ const targets = (parsed.targets || []).map((t) => ({
111
+ ...t,
112
+ output_path:
113
+ t.output_path ||
114
+ path.join(
115
+ normalizedFamilies.find((f) => f.name === t.family)?.output_root || outRoot,
116
+ `${t.id}.feature`,
117
+ ),
118
+ }));
119
+ if (parsed.notes?.length) {
120
+ console.log(`ℹ️ AI discovery notes: ${parsed.notes.join(' | ')}`);
121
+ }
122
+ return { families: normalizedFamilies, targets };
123
+ }
@@ -0,0 +1,259 @@
1
+ /**
2
+ * createTestPlan.js
3
+ *
4
+ * Creates a Test Plan in TestCollab, adds CI-tagged test cases,
5
+ * and assigns the plan to a user.
6
+ *
7
+ * Options:
8
+ * - --api-key API token
9
+ * - --project Project ID
10
+ * - --ci-tag-id Tag ID to select test cases
11
+ * - --assignee-id User ID to assign the plan
12
+ * - --api-url (defaults to https://api.testcollab.io)
13
+ */
14
+
15
+ import fs from 'fs';
16
+ import {
17
+ TestPlansApi,
18
+ TestPlanTestCasesApi,
19
+ TestPlansAssignmentApi,
20
+ Configuration,
21
+ ProjectsApi,
22
+ UsersApi,
23
+ TestCasesApi,
24
+ ProjectUsersApi
25
+ } from 'testcollab-sdk';
26
+
27
+ function getDate() {
28
+ const now = new Date();
29
+ const dd = String(now.getDate()).padStart(2, '0');
30
+ const mm = String(now.getMonth() + 1).padStart(2, '0');
31
+ const yyyy = now.getFullYear();
32
+ const hh = String(now.getHours()).padStart(2, '0');
33
+ const min = String(now.getMinutes()).padStart(2, '0');
34
+ return `${dd}-${mm}-${yyyy} ${hh}:${min}`;
35
+ }
36
+
37
+ export async function createTestPlan(options) {
38
+ const {
39
+ project,
40
+ ciTagId,
41
+ assigneeId,
42
+ apiUrl
43
+ } = options;
44
+
45
+ // Resolve API key: --api-key flag takes precedence, then TESTCOLLAB_TOKEN env var
46
+ const apiKey = options.apiKey || process.env.TESTCOLLAB_TOKEN;
47
+
48
+ // Normalize/Default API base URL
49
+ const effectiveApiUrl = (apiUrl && String(apiUrl).trim())
50
+ ? String(apiUrl).trim().replace(/\/+$/, '')
51
+ : 'https://api.testcollab.io';
52
+
53
+ // Validate required inputs
54
+ if (!apiKey) {
55
+ console.error('❌ Error: No API key provided');
56
+ console.error(' Pass --api-key <key> or set the TESTCOLLAB_TOKEN environment variable.');
57
+ process.exit(1);
58
+ }
59
+ if (!project) {
60
+ console.error('❌ Error: --project is required');
61
+ process.exit(1);
62
+ }
63
+ if (!ciTagId) {
64
+ console.error('❌ Error: --ci-tag-id is required');
65
+ process.exit(1);
66
+ }
67
+ if (!assigneeId) {
68
+ console.error('❌ Error: --assignee-id is required');
69
+ process.exit(1);
70
+ }
71
+
72
+ const parsedProjectId = Number(project);
73
+ const parsedTagId = Number(ciTagId);
74
+ const parsedAssigneeId = Number(assigneeId);
75
+
76
+ if (Number.isNaN(parsedProjectId)) {
77
+ console.error('❌ Error: --project must be a number');
78
+ process.exit(1);
79
+ }
80
+ if (Number.isNaN(parsedTagId)) {
81
+ console.error('❌ Error: --ci-tag-id must be a number');
82
+ process.exit(1);
83
+ }
84
+ if (Number.isNaN(parsedAssigneeId)) {
85
+ console.error('❌ Error: --assignee-id must be a number');
86
+ process.exit(1);
87
+ }
88
+
89
+ // Configure SDK with token and base URL via fetchApi hook
90
+ const config = new Configuration({
91
+ basePath: effectiveApiUrl,
92
+ fetchApi: (url, opts) => {
93
+ const separator = url.includes('?') ? '&' : '?';
94
+ const urlWithToken = `${url}${separator}token=${apiKey}`;
95
+ return fetch(urlWithToken, opts);
96
+ }
97
+ });
98
+
99
+ const projectsApi = new ProjectsApi(config);
100
+ const usersApi = new UsersApi(config);
101
+ const tcApi = new TestCasesApi(config);
102
+ const projectUsersApi = new ProjectUsersApi(config);
103
+ const testPlansApi = new TestPlansApi(config);
104
+ const testPlanCases = new TestPlanTestCasesApi(config);
105
+ const testPlanAssignment = new TestPlansAssignmentApi(config);
106
+
107
+ // Ensure tmp directory exists and remove old id file if present
108
+ try {
109
+ if (!fs.existsSync('tmp')) {
110
+ fs.mkdirSync('tmp', { recursive: true });
111
+ }
112
+ if (fs.existsSync('tmp/tc_test_plan')) {
113
+ fs.unlinkSync('tmp/tc_test_plan');
114
+ }
115
+ } catch (e) {
116
+ // Non-fatal; continue
117
+ }
118
+
119
+ console.log('validating project and other details...');
120
+ try {
121
+ const projectResponse = await projectsApi.getProjects({
122
+ filter: JSON.stringify({
123
+ id: parsedProjectId
124
+ })
125
+ });
126
+ if(!projectResponse || !projectResponse.length) {
127
+ console.error('❌ Error: Project not found. Ensure you have access to this project.');
128
+ process.exit(1);
129
+ }
130
+ } catch (e) {
131
+ console.error('❌ Error: Failed to validate project. Ensure the project ID is correct and you have access.');
132
+ // console.error(e);
133
+ process.exit(1);
134
+ }
135
+
136
+ try {
137
+ const tagResponse = await tcApi.getTestCasesTags({
138
+ project: parsedProjectId,
139
+ filter: JSON.stringify({
140
+ id: parsedTagId
141
+ })
142
+ });
143
+ if(!tagResponse || !tagResponse.length || !tagResponse[0].id || tagResponse[0].id !== parsedTagId) {
144
+ console.error('❌ Error: Tag not found or tag does not belong to the project');
145
+ process.exit(1);
146
+ }
147
+ } catch (e) {
148
+ console.error('❌ Error: Invalid tag ID or tag does not belong to the project');
149
+ // console.error(e);
150
+ process.exit(1);
151
+ }
152
+
153
+ try{
154
+ let projectUsers = await projectUsersApi.getProjectUsers({
155
+ project: parsedProjectId,
156
+ limit: -1
157
+ });
158
+ if(!projectUsers || !projectUsers.length) {
159
+ console.error('❌ Error: Invalid assignee or assignee does not have access to the project');
160
+ process.exit(1);
161
+ }
162
+ let assigneeFound = false;
163
+ for(let i = 0; i < projectUsers.length; i++) {
164
+ if(projectUsers[i].user.id === parsedAssigneeId) {
165
+ assigneeFound = true;
166
+ break;
167
+ }
168
+ }
169
+ if(!assigneeFound) {
170
+ console.error('❌ Error: Invalid assignee or assignee does not have access to the project');
171
+ process.exit(1);
172
+ }
173
+ }catch (e) {
174
+ console.error('❌ Error: Invalid assignee or assignee does not have access to the project');
175
+ // console.error(e);
176
+ process.exit(1);
177
+ }
178
+
179
+ try {
180
+ console.log('Step 1: Creating a new test plan...');
181
+ const createResponse = await testPlansApi.addTestPlan({
182
+ testPlanPayload: {
183
+ project: parsedProjectId,
184
+ title: `CI Test: ${getDate()}`,
185
+ description: 'This is a test plan created using the Node.js SDK',
186
+ status: 1,
187
+ priority: 1,
188
+ testPlanFolder: null,
189
+ customFields: []
190
+ }
191
+ });
192
+
193
+ const testPlanId = createResponse.id;
194
+ console.log(`Test Plan ID: ${testPlanId}`);
195
+
196
+ console.log('Step 2: Adding test cases (matching CI tag) to the test plan...');
197
+ let tptcAddResult = await testPlanCases.bulkAddTestPlanTestCases({
198
+ testPlanTestCaseBulkAddPayload: {
199
+ testplan: testPlanId,
200
+ testCaseCollection: {
201
+ testCases: [],
202
+ selector: [
203
+ {
204
+ field: 'tags',
205
+ operator: 'jsonstring_2',
206
+ value: `{"filter":[[${parsedTagId}]],"type":"equals","filterType":"number"}`
207
+ }
208
+ ]
209
+ }
210
+ }
211
+ });
212
+ if(tptcAddResult && tptcAddResult.status === false) {
213
+ console.error('❌ Error: Failed to add test cases to the test plan');
214
+ console.error(tptcAddResult);
215
+ process.exit(1);
216
+ }
217
+
218
+ console.log('Step 3: Assigning the test plan to a user...');
219
+ const assignmentResponse = await testPlanAssignment.assignTestPlan({
220
+ project: parsedProjectId,
221
+ testplan: testPlanId,
222
+ testPlanAssignmentPayload: {
223
+ executor: 'team',
224
+ assignmentCriteria: 'testCase',
225
+ assignmentMethod: 'automatic',
226
+ assignment: {
227
+ user: [parsedAssigneeId],
228
+ testCases: { testCases: [], selector: [] },
229
+ configuration: null
230
+ },
231
+ project: parsedProjectId,
232
+ testplan: testPlanId
233
+ }
234
+ });
235
+
236
+ console.log(assignmentResponse);
237
+
238
+ // Persist test plan id
239
+ fs.writeFileSync('tmp/tc_test_plan', `TESTCOLLAB_TEST_PLAN_ID=${testPlanId}`);
240
+ console.log('✅ Test plan created and assigned successfully.');
241
+ } catch (error) {
242
+ // console.error("ERROR:", error);
243
+ // Improve error visibility if error is a Response-like object
244
+ if (error && typeof error === 'object' && 'status' in error && 'text' in error) {
245
+ try {
246
+ const bodyText = await error.text();
247
+ console.error(`❌ Error: HTTP ${error.status} ${error.statusText || ''} - ${bodyText}`);
248
+ } catch {
249
+ console.error(`❌ Error: HTTP ${error.status} ${error.statusText || ''}`);
250
+ }
251
+ } else {
252
+ const message = error?.message || String(error);
253
+ console.error(`❌ Error: ${message}`);
254
+ }
255
+ process.exit(1);
256
+ }
257
+ }
258
+
259
+