thinkncollab-shell 0.0.10 → 0.0.13

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.
@@ -1,47 +1,760 @@
1
- /*
2
- src/commands/tasks/complete.js — Mark your task assignment as completed
3
- */
4
- const chalk = require('chalk');
5
1
 
6
- module.exports = {
7
- name: 'complete',
8
- description: 'Mark a task as completed',
9
- aliases: ['done', 'finish', 'complete'],
10
- requiresAuth: true,
11
2
 
12
- async execute(args, shell) {
13
- if (!args[0]) {
14
- console.log(chalk.red('Usage: complete <task-id>'));
15
- return;
3
+ const chalk = require('chalk');
4
+ const crypto = require('crypto');
5
+ const cp = require('child_process');
6
+ const path = require('path');
7
+
8
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
9
+
10
+ function getFetchFn() {
11
+ return globalThis.fetch || require('node-fetch');
12
+ }
13
+
14
+ // Parse raw Set-Cookie header into Playwright cookie objects
15
+ function parseCookieHeader(raw, base) {
16
+ const domain = new URL(base).hostname;
17
+ const cookies = [];
18
+ const parts = raw.split(/,(?=[^ ][^=]+=)/);
19
+
20
+ for (const part of parts) {
21
+ const segments = part.split(';').map(s => s.trim());
22
+ const [nameVal, ...attrs] = segments;
23
+ const eqIdx = nameVal.indexOf('=');
24
+ if (eqIdx === -1) continue;
25
+
26
+ const name = nameVal.slice(0, eqIdx).trim();
27
+ const value = nameVal.slice(eqIdx + 1).trim();
28
+ const attrMap = {};
29
+
30
+ for (const attr of attrs) {
31
+ const [k, v] = attr.split('=').map(s => s.trim());
32
+ attrMap[k.toLowerCase()] = v || true;
33
+ }
34
+
35
+ cookies.push({
36
+ name,
37
+ value,
38
+ domain: attrMap.domain || domain,
39
+ path: attrMap.path || '/',
40
+ httpOnly: !!attrMap.httponly,
41
+ secure: !!attrMap.secure,
42
+ sameSite: attrMap.samesite === 'None' ? 'None'
43
+ : attrMap.samesite === 'Strict' ? 'Strict'
44
+ : 'Lax',
45
+ });
46
+ }
47
+
48
+ return cookies;
49
+ }
50
+
51
+ function extractCsrf(html, customSelector) {
52
+ if (customSelector) {
53
+ const nameMatch = customSelector.match(/name=['"]([^'"]+)['"]/);
54
+ if (nameMatch) {
55
+ const re = new RegExp(
56
+ `name=["']${nameMatch[1]}["'][^>]*value=["']([^"']+)["']|value=["']([^"']+)["'][^>]*name=["']${nameMatch[1]}["']`
57
+ );
58
+ const m = html.match(re);
59
+ if (m) return { field: nameMatch[1], token: m[1] || m[2] };
60
+ }
61
+ }
62
+
63
+ const patterns = [
64
+ { field: '_csrf', re: /name=["']_csrf["'][^>]*value=["']([^"']+)["']/ },
65
+ { field: '_token', re: /name=["']_token["'][^>]*value=["']([^"']+)["']/ },
66
+ { field: 'authenticity_token', re: /name=["']authenticity_token["'][^>]*value=["']([^"']+)["']/ },
67
+ { field: '__RequestVerificationToken', re: /name=["']__RequestVerificationToken["'][^>]*value=["']([^"']+)["']/ },
68
+ ];
69
+
70
+ for (const { field, re } of patterns) {
71
+ const m = html.match(re);
72
+ if (m) return { field, token: m[1] };
73
+ }
74
+
75
+ return null;
76
+ }
77
+
78
+ // ─── Strategy: request ────────────────────────────────────────────────────────
79
+ async function authViaRequest(context, base, email, password, authConfig) {
80
+ const fetch = getFetchFn();
81
+ const signinPath = authConfig.signinUrl || '/signin';
82
+ const signinUrl = base.replace(/\/$/, '') + signinPath;
83
+
84
+ console.log(chalk.dim(` → [request] GET ${signinUrl}`));
85
+
86
+ // 1. GET signin page — grab CSRF + initial cookies
87
+ const getRes = await fetch(signinUrl, {
88
+ headers: { 'User-Agent': 'Mozilla/5.0 (compatible; TNCBot/1.0)' },
89
+ redirect: 'follow',
90
+ });
91
+ const html = await getRes.text();
92
+ const getCookies = getRes.headers.get('set-cookie') || '';
93
+ const csrf = extractCsrf(html, authConfig.csrfSelector);
94
+
95
+ if (csrf) console.log(chalk.dim(` → CSRF detected: ${csrf.field}`));
96
+
97
+ // 2. POST credentials
98
+ const formBody = new URLSearchParams({ email, password });
99
+ if (csrf) formBody.append(csrf.field, csrf.token);
100
+
101
+ console.log(chalk.dim(` → [request] POST ${signinUrl}`));
102
+
103
+ const postRes = await fetch(signinUrl, {
104
+ method: 'POST',
105
+ headers: {
106
+ 'Content-Type': 'application/x-www-form-urlencoded',
107
+ 'Cookie': getCookies,
108
+ 'User-Agent': 'Mozilla/5.0 (compatible; TNCBot/1.0)',
109
+ 'Referer': signinUrl,
110
+ },
111
+ body: formBody.toString(),
112
+ redirect: 'manual',
113
+ });
114
+
115
+ // 3. Extract session cookies — follow redirect manually if needed
116
+ let finalCookies = postRes.headers.get('set-cookie');
117
+
118
+ if (!finalCookies && postRes.status >= 300 && postRes.status < 400) {
119
+ const loc = postRes.headers.get('location');
120
+ if (loc) {
121
+ const redirectRes = await fetch(
122
+ loc.startsWith('http') ? loc : base + loc,
123
+ { headers: { 'Cookie': getCookies }, redirect: 'manual' }
124
+ );
125
+ finalCookies = redirectRes.headers.get('set-cookie');
126
+ }
127
+ }
128
+
129
+ if (!finalCookies) {
130
+ throw new Error(
131
+ 'Auth failed — no session cookie returned. ' +
132
+ 'Check testUser credentials or switch to strategy: "form"'
133
+ );
134
+ }
135
+
136
+ const parsed = parseCookieHeader(finalCookies, base);
137
+ if (!parsed.length) throw new Error('Could not parse session cookies from response');
138
+
139
+ await context.addCookies(parsed);
140
+ console.log(chalk.dim(` → Cookies injected: ${parsed.map(c => c.name).join(', ')}`));
141
+ }
142
+
143
+ async function authViaForm(page, base, email, password, authConfig) {
144
+ const signinPath = authConfig.signinUrl || '/signin';
145
+ await page.goto(base + signinPath, { waitUntil: 'networkidle', timeout: 15000 });
146
+ console.log(chalk.dim(` → [form] ${page.url()}`));
147
+
148
+ if (!page.url().includes(signinPath.replace(/^\//, ''))) return;
149
+
150
+ await page.evaluate(() => {
151
+ document.querySelectorAll('form input, form button, form > div, form > section').forEach(el => {
152
+ el.style.display = '';
153
+ el.style.visibility = 'visible';
154
+ el.style.opacity = '1';
155
+ });
156
+ });
157
+
158
+ const fields = authConfig.fields || {};
159
+
160
+ const emailSel = fields.email || [
161
+ 'input[type="email"]', 'input[name="email"]',
162
+ 'input[id="email"]', 'input[autocomplete="email"]',
163
+ ].join(', ');
164
+
165
+ const passSel = fields.password || [
166
+ 'input[type="password"]', 'input[name="password"]',
167
+ 'input[id="password"]', 'input[autocomplete="current-password"]',
168
+ ].join(', ');
169
+
170
+ const submitSel = fields.submit || [
171
+ 'button[type="submit"]', 'input[type="submit"]',
172
+ 'button:has-text("Sign in")', 'button:has-text("Login")',
173
+ 'button:has-text("Log in")', 'button:has-text("Continue")',
174
+ 'form button',
175
+ ].join(', ');
176
+
177
+ await page.locator(emailSel).first().fill(email);
178
+ console.log(chalk.dim(` → Email filled`));
179
+ await page.locator(passSel).first().fill(password);
180
+ console.log(chalk.dim(` → Password filled`));
181
+
182
+ const hasSubmit = await page.locator(submitSel).first().count().catch(() => 0);
183
+ if (hasSubmit) {
184
+ await page.locator(submitSel).first().click();
185
+ console.log(chalk.dim(` → Submit clicked`));
186
+ } else {
187
+ await page.locator(passSel).first().press('Enter');
188
+ console.log(chalk.dim(` → Submitted via Enter`));
189
+ }
190
+
191
+ const successPath = authConfig.successRedirect || null;
192
+ await page.waitForURL(
193
+ u => successPath
194
+ ? u.includes(successPath)
195
+ : !u.includes('/signin') && !u.includes('/login') && !u.includes('/verify'),
196
+ { timeout: 15000 }
197
+ ).catch(() => {});
198
+
199
+ if (page.url().includes('/signin') || page.url().includes('/login')) {
200
+ const errText = await page
201
+ .locator('.flash, .error, .alert, [class*="error"], [role="alert"]')
202
+ .first().textContent().catch(() => null);
203
+ throw new Error(
204
+ `Auth failed — still on signin.${errText ? ` Page: "${errText.trim()}"` : ' Check credentials.'}`
205
+ );
206
+ }
207
+
208
+ console.log(chalk.dim(` → Redirected: ${page.url()}`));
209
+ }
210
+
211
+ // ─── Strategy: cookie ─────────────────────────────────────────────────────────
212
+ async function authViaCookie(context, base, authConfig) {
213
+ const cookies = authConfig.cookies;
214
+ if (!Array.isArray(cookies) || !cookies.length) {
215
+ throw new Error('auth.strategy "cookie" requires auth.cookies array in testConfig');
216
+ }
217
+
218
+ const domain = new URL(base).hostname;
219
+ const normalized = cookies.map(c => ({
220
+ name: c.name,
221
+ value: c.value,
222
+ domain: c.domain || domain,
223
+ path: c.path || '/',
224
+ httpOnly: c.httpOnly || false,
225
+ secure: c.secure || false,
226
+ sameSite: c.sameSite || 'Lax',
227
+ }));
228
+
229
+ await context.addCookies(normalized);
230
+ console.log(chalk.dim(` → Cookies injected: ${normalized.map(c => c.name).join(', ')}`));
231
+ }
232
+
233
+ // ─── Auth Dispatcher ──────────────────────────────────────────────────────────
234
+ async function authenticate(page, context, base, testUser, authConfig = {}) {
235
+ const strategy = authConfig.strategy || 'request';
236
+ console.log(chalk.dim(` → Auth strategy: ${strategy}`));
237
+
238
+ switch (strategy) {
239
+ case 'request':
240
+ await authViaRequest(context, base, testUser.email, testUser.password, authConfig);
241
+ break;
242
+ case 'form':
243
+ await authViaForm(page, base, testUser.email, testUser.password, authConfig);
244
+ break;
245
+ case 'cookie':
246
+ await authViaCookie(context, base, authConfig);
247
+ break;
248
+ case 'skip':
249
+ console.log(chalk.dim(` → Auth skipped`));
250
+ break;
251
+ default:
252
+ throw new Error(`Unknown auth strategy: "${strategy}". Use: request | form | cookie | skip`);
253
+ }
254
+ }
255
+
256
+ // ─── HTTP Test ────────────────────────────────────────────────────────────────
257
+ async function runHttpTest(testConfig, testUser, authConfig = {}) {
258
+ const { baseUrl, method = 'GET', endpoint, headers = {}, body } = testConfig;
259
+ if (!baseUrl || !endpoint) throw new Error('testConfig missing baseUrl or endpoint');
260
+
261
+ const url = baseUrl.replace(/\/$/, '') + endpoint;
262
+ const fetch = getFetchFn();
263
+
264
+ // ── Auth — session cookie lo ──────────────────────────────────────────────
265
+ let sessionCookie = '';
266
+ if (testUser && authConfig.strategy !== 'skip') {
267
+ const signinUrl = baseUrl.replace(/\/$/, '') + (authConfig.signinUrl || '/signin');
268
+ const getRes = await fetch(signinUrl, { redirect: 'follow' });
269
+ const html = await getRes.text();
270
+ const getCookie = getRes.headers.get('set-cookie') || '';
271
+ const csrf = extractCsrf(html, authConfig.csrfSelector);
272
+
273
+ const formBody = new URLSearchParams({ email: testUser.email, password: testUser.password });
274
+ if (csrf) formBody.append(csrf.field, csrf.token);
275
+
276
+ const postRes = await fetch(signinUrl, {
277
+ method: 'POST',
278
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Cookie': getCookie },
279
+ body: formBody.toString(),
280
+ redirect: 'manual',
281
+ });
282
+
283
+ const setCookie = postRes.headers.get('set-cookie');
284
+ if (setCookie) {
285
+ sessionCookie = setCookie.split(',').map(c => c.split(';')[0].trim()).join('; ');
286
+ console.log(chalk.dim(` → HTTP auth: cookie obtained`));
287
+ } else {
288
+ console.log(chalk.yellow(` ⚠️ HTTP auth: no cookie returned`));
289
+ }
290
+ }
291
+
292
+ // ── API call ──────────────────────────────────────────────────────────────
293
+ let res, responseBody;
294
+ try {
295
+ res = await fetch(url, {
296
+ method,
297
+ headers: {
298
+ 'Content-Type': 'application/json',
299
+ ...(sessionCookie ? { 'Cookie': sessionCookie } : {}),
300
+ ...headers,
301
+ },
302
+ ...(body && method !== 'GET' ? { body: JSON.stringify(body) } : {})
303
+ });
304
+ responseBody = await res.json().catch(() => ({}));
305
+ } catch (err) {
306
+ throw new Error(`Could not reach ${url} — is your server running? (${err.message})`);
307
+ }
308
+
309
+ return { status: res.status, body: responseBody };
310
+ }
311
+
312
+ // ─── Browser Test ─────────────────────────────────────────────────────────────
313
+ async function runBrowserTest(testConfig, testUser, boardAuth = {}) {
314
+ let playwright;
315
+ try {
316
+ playwright = require('playwright');
317
+ } catch {
318
+ throw new Error(
319
+ 'Playwright not installed.\n Run: npm install -g playwright && npx playwright install chromium'
320
+ );
321
+ }
322
+
323
+ const { url, flow = [], expect, auth = {} } = testConfig;
324
+ if (!url) throw new Error('testConfig missing url');
325
+
326
+ const authConfig = { ...boardAuth, ...auth };
327
+
328
+ const base = new URL(url).origin;
329
+ const browser = await playwright.chromium.launch({ headless: true });
330
+ const context = await browser.newContext();
331
+ const page = await context.newPage();
332
+
333
+ try {
334
+ // ── Auth ──────────────────────────────────────────────────────────────────
335
+ const strategy = authConfig.strategy || (testUser ? 'request' : 'skip');
336
+ if (strategy === 'cookie') {
337
+ await authenticate(page, context, base, testUser, authConfig);
338
+ } else if (testUser && strategy !== 'skip') {
339
+ await authenticate(page, context, base, testUser, authConfig);
340
+
341
+ } else if (!testUser && strategy !== 'skip') {
342
+ console.log(chalk.yellow(` ⚠️ No testUser — auth skipped`));
343
+ }
344
+
345
+ // ── Navigate to target ────────────────────────────────────────────────────
346
+ await page.goto(url, { waitUntil: 'networkidle', timeout: 15000 });
347
+
348
+ if (page.url().includes('/signin') || page.url().includes('/login')) {
349
+ throw new Error('Auth failed — redirected to signin on target page');
350
+ }
351
+
352
+ console.log(chalk.dim(` → Target: ${page.url()}`));
353
+
354
+ // ── Flow steps ────────────────────────────────────────────────────────────
355
+ for (const step of flow) {
356
+ switch (step.action) {
357
+ case 'fill': await page.fill(step.selector, step.value || ''); break;
358
+ case 'click': await page.click(step.selector); break;
359
+ case 'wait': await page.waitForTimeout(step.ms || 1000); break;
360
+ case 'waitForElement':await page.waitForSelector(step.selector, { timeout: 8000 }); break;
361
+ case 'select': await page.selectOption(step.selector, step.value); break;
362
+ case 'screenshot': await page.screenshot({ path: step.path || '/tmp/tnc.png' }); break;
363
+ }
364
+ }
365
+
366
+ // ── Capture result ────────────────────────────────────────────────────────
367
+ const currentUrl = page.url();
368
+ const bodyText = await page.textContent('body').catch(() => '');
369
+
370
+ let elementExists = false;
371
+ let elementText = '';
372
+ if (expect?.element) {
373
+ try {
374
+ await page.waitForSelector(expect.element, { timeout: 5000 });
375
+ elementExists = true;
376
+ elementText = await page.textContent(expect.element).catch(() => '');
377
+ } catch { elementExists = false; }
378
+ }
379
+
380
+ return { url: currentUrl, elementExists, elementText, bodyText };
381
+ } finally {
382
+ await browser.close();
383
+ }
384
+ }
385
+
386
+ // Helper to run a local command and capture exitCode & stdout/stderr
387
+ function runLocalCommand(command) {
388
+ return new Promise((resolve) => {
389
+ cp.exec(command, (error, stdout, stderr) => {
390
+ const exitCode = error ? (error.code || 1) : 0;
391
+ resolve({
392
+ exitCode,
393
+ stdout: stdout.toString(),
394
+ stderr: stderr.toString()
395
+ });
396
+ });
397
+ });
398
+ }
399
+
400
+ // ─── Local Judge ──────────────────────────────────────────────────────────────
401
+ function judgeLocally(testConfig, result) {
402
+ const { type, value, condition, expect } = testConfig;
403
+ const failures = [];
404
+
405
+ try {
406
+ switch (type) {
407
+ case 'exitCode': {
408
+ const passed = result.exitCode === value;
409
+ return {
410
+ passed,
411
+ reason: passed
412
+ ? `Exit code matched: ${value}`
413
+ : `Expected exit code ${value}, got ${result.exitCode}`
414
+ };
415
+ }
416
+
417
+ case 'stdout': {
418
+ const actualOut = (result.stdout || '').trim();
419
+ const expectedOut = (value || '').trim();
420
+ const passed = actualOut === expectedOut;
421
+ return {
422
+ passed,
423
+ reason: passed ? `Output matched` : `Output mismatch`,
424
+ diff: passed ? undefined : { expected: expectedOut, actual: actualOut }
425
+ };
426
+ }
427
+
428
+ case 'regex': {
429
+ const pattern = new RegExp(value);
430
+ const target = result.stdout || result.output || '';
431
+ const passed = pattern.test(target);
432
+ return {
433
+ passed,
434
+ reason: passed ? `Regex matched: ${value}` : `Regex did not match: ${value}`
435
+ };
436
+ }
437
+
438
+ case 'http': {
439
+ if (expect?.status !== undefined && result.status !== expect.status) {
440
+ failures.push(`Status: expected ${expect.status}, got ${result.status}`);
16
441
  }
442
+ if (expect?.body) {
443
+ for (const [key, condition] of Object.entries(expect.body)) {
444
+ const actualVal = getNestedValue(result.body || {}, key);
445
+ if (condition?.exists) {
446
+ if (actualVal === undefined || actualVal === null)
447
+ failures.push(`body.${key}: expected to exist`);
448
+ } else if (condition?.eq !== undefined) {
449
+ if (actualVal !== condition.eq)
450
+ failures.push(`body.${key}: expected "${condition.eq}", got "${actualVal}"`);
451
+ } else if (condition?.contains !== undefined) {
452
+ if (!String(actualVal).includes(String(condition.contains)))
453
+ failures.push(`body.${key}: expected to contain "${condition.contains}"`);
454
+ }
455
+ }
456
+ }
457
+ break;
458
+ }
459
+
460
+ case 'json': {
461
+ const actualData = result.output || result.json || {};
462
+ if (condition) {
463
+ return judgeWithConditions(condition, actualData);
464
+ } else {
465
+ const passed = JSON.stringify(value) === JSON.stringify(actualData);
466
+ return {
467
+ passed,
468
+ reason: passed ? `JSON matched` : `JSON mismatch`,
469
+ diff: passed ? undefined : { expected: value, actual: actualData }
470
+ };
471
+ }
472
+ }
473
+ case 'loadtest':
474
+ case 'geekload': {
475
+ const actualData = result.output || result.json || {};
476
+ const cond = condition || expect?.condition || expect || {};
477
+ return judgeWithConditions(cond, actualData);
478
+ }
479
+
480
+ case 'browser': {
481
+ if (expect?.element && !result.elementExists) {
482
+ failures.push(`Element "${expect.element}" not found on page`);
483
+ }
484
+ if (expect?.text && result.elementText) {
485
+ if (!result.elementText.includes(expect.text))
486
+ failures.push(`Element text: expected to contain "${expect.text}", got "${result.elementText}"`);
487
+ }
488
+ if (expect?.redirectUrl) {
489
+ try {
490
+ const actualPath = new URL(result.url).pathname;
491
+ if (!actualPath.includes(expect.redirectUrl))
492
+ failures.push(`URL: expected "${expect.redirectUrl}", got "${actualPath}"`);
493
+ } catch {
494
+ failures.push(`Invalid URL in result: "${result.url}"`);
495
+ }
496
+ }
497
+ break;
498
+ }
499
+
500
+ default:
501
+ return { passed: false, reason: `Unknown test type: ${type}` };
502
+ }
503
+
504
+ return {
505
+ passed: failures.length === 0,
506
+ reason: failures.length === 0 ? 'All checks passed' : failures.join(' | '),
507
+ failures,
508
+ };
509
+ } catch (err) {
510
+ return { passed: false, reason: `Judge error: ${err.message}` };
511
+ }
512
+ }
513
+
514
+ function judgeWithConditions(conditions, actual) {
515
+ const failures = [];
516
+
517
+ for (const [key, ops] of Object.entries(conditions)) {
518
+ const actualVal = actual[key];
519
+
520
+ if (actualVal === undefined) {
521
+ failures.push(`Missing key: "${key}"`);
522
+ continue;
523
+ }
524
+
525
+ for (const [op, expected] of Object.entries(ops)) {
526
+ let pass = false;
527
+ switch (op) {
528
+ case 'gte': pass = actualVal >= expected; break;
529
+ case 'lte': pass = actualVal <= expected; break;
530
+ case 'gt': pass = actualVal > expected; break;
531
+ case 'lt': pass = actualVal < expected; break;
532
+ case 'eq': pass = actualVal === expected; break;
533
+ case 'contains': pass = String(actualVal).includes(String(expected)); break;
534
+ case 'regex': pass = new RegExp(expected).test(String(actualVal)); break;
535
+ default: pass = false;
536
+ }
537
+ if (!pass) {
538
+ failures.push(`"${key}" failed: ${actualVal} ${op} ${expected}`);
539
+ }
540
+ }
541
+ }
542
+
543
+ return {
544
+ passed: failures.length === 0,
545
+ reason: failures.length === 0
546
+ ? `All conditions passed`
547
+ : `Failed conditions: ${failures.join('; ')}`,
548
+ diff: failures.length > 0 ? { conditions, actual } : undefined
549
+ };
550
+ }
551
+
552
+ function getNestedValue(obj, key) {
553
+ return key.split('.').reduce((o, k) => (o && o[k] !== undefined ? o[k] : undefined), obj);
554
+ }
555
+
556
+ // ─── Webhook ──────────────────────────────────────────────────────────────────
557
+ async function hitWebhook(apiUrl, taskId, webhookSecret, payload) {
558
+ const body = JSON.stringify(payload);
559
+ const signature = 'sha256=' + crypto.createHmac('sha256', webhookSecret).update(body).digest('hex');
560
+ const fetch = getFetchFn();
561
+
562
+ const res = await fetch(`${apiUrl}/webhooks/tasks/${taskId}/judge`, {
563
+ method: 'POST',
564
+ headers: { 'Content-Type': 'application/json', 'x-tnc-signature': signature },
565
+ body,
566
+ });
567
+
568
+ if (!res.ok) {
569
+ const err = await res.json().catch(() => ({}));
570
+ throw new Error(err.error || `Webhook failed: ${res.status}`);
571
+ }
572
+
573
+ return res.json();
574
+ }
575
+
576
+ // ─── Command ──────────────────────────────────────────────────────────────────
577
+ module.exports = {
578
+ name: 'task-submit',
579
+ description: 'Auto-test and submit a task for verification',
580
+ aliases: ['submit'],
581
+ requiresAuth: true,
582
+
583
+ async execute(args, shell) {
584
+ if (!args[0]) {
585
+ console.log(chalk.red('Usage: task-submit <task-id>'));
586
+ console.log(chalk.dim(' Alias: submit <task-id>'));
587
+ return;
588
+ }
589
+
590
+ const taskId = args[0];
591
+ const apiUrl = shell.api.apiUrl || 'https://thinkncollab.com';
592
+
593
+ // ── Fetch task ────────────────────────────────────────────────────────────
594
+ let task;
595
+ try {
596
+ task = await shell.api._request('GET', `/thinknsh/${taskId}/cli`);
597
+ } catch (err) {
598
+ if (err.message?.includes('404')) console.log(chalk.red('❌ Task not found'));
599
+ else if (err.message?.includes('403')) console.log(chalk.red('❌ Access denied'));
600
+ else console.log(chalk.red(`❌ ${err.message}`));
601
+ return;
602
+ }
603
+
604
+ if (!task.testConfig) {
605
+ console.log(chalk.yellow('⚠️ This task has no test config defined.'));
606
+ console.log(chalk.dim(' complete <task-id> to mark manually\n'));
607
+ return;
608
+ }
609
+
610
+ const { title, testConfig, webhookSecret } = task;
611
+
612
+ const testUser = (task.testuserEmail && task.testuserPassword)
613
+ ? { email: task.testuserEmail, password: task.testuserPassword }
614
+ : null;
615
+
616
+ const boardAuth = {
617
+ strategy: task.testauthStrategy || 'request',
618
+ signinUrl: task.testauthSigninUrl || '/signin',
619
+ csrfSelector: task.testauthCsrf || null,
620
+ };
17
621
 
18
- const taskId = args[0];
622
+ // ── Print summary ─────────────────────────────────────────────────────────
623
+ console.log(chalk.cyan(`\n ── Submitting: "${title}" ──────────────────────`));
624
+ console.log(chalk.dim(` Type : ${testConfig.type}`));
625
+ const authConfig = { ...boardAuth, ...(testConfig.auth || {}) };
626
+ if (testConfig.type === 'http') {
627
+ console.log(chalk.dim(` Target : ${testConfig.method || 'GET'} ${testConfig.baseUrl}${testConfig.endpoint}`));
628
+ } else if (testConfig.type === 'browser') {
629
+ const strategy = testConfig.auth?.strategy || (testUser ? 'request' : 'skip');
630
+ console.log(chalk.dim(` Target : ${testConfig.url}`));
631
+ console.log(chalk.dim(` Auth : ${strategy}`));
632
+ if (testUser) console.log(chalk.dim(` TestUser : ${testUser.email}`));
633
+ else console.log(chalk.yellow(` ⚠️ No testUser — auth will be skipped`));
634
+ }
635
+ console.log(chalk.dim(` Running virtual user...\n`));
19
636
 
20
- try {
21
- const result = await shell.api._request('POST', `/thinknsh/${taskId}/cli/complete`);
637
+ // ── Run test ──────────────────────────────────────────────────────────────
638
+ let result;
639
+ try {
640
+ if (testConfig.type === 'http') {
641
+ result = await runHttpTest(testConfig, testUser, authConfig);
642
+ console.log(chalk.dim(` → Status : ${result.status}`));
643
+ console.log(chalk.dim(` → Body : ${JSON.stringify(result.body).slice(0, 80)}`));
22
644
 
23
- console.log(chalk.green(`✅ Task completed: "${result.task?.title || taskId}"`));
645
+ } else if (testConfig.type === 'browser') {
646
+ console.log(chalk.dim(` → Launching headless browser...`));
647
+ result = await runBrowserTest(testConfig, testUser, authConfig);
648
+ console.log(chalk.dim(` → Page : ${result.url}`));
649
+ console.log(chalk.dim(` → Element: ${result.elementExists ? 'found ✓' : 'not found ✗'}`));
650
+ if (result.elementText)
651
+ console.log(chalk.dim(` → Text : "${result.elementText.trim().slice(0, 60)}"`));
24
652
 
25
- if (result.allCompleted)
26
- console.log(chalk.green(' 🎉 All assignees have completed this task!'));
653
+ } else if (testConfig.type === 'exitCode' || testConfig.type === 'stdout' || testConfig.type === 'regex') {
654
+ const cmd = testConfig.command || testConfig.script;
655
+ if (!cmd) {
656
+ throw new Error(`testConfig type "${testConfig.type}" requires a "command" or "script" to execute.`);
657
+ }
658
+ console.log(chalk.dim(` → Executing local command: "${cmd}"`));
659
+ const runRes = await runLocalCommand(cmd);
660
+ result = {
661
+ exitCode: runRes.exitCode,
662
+ stdout: runRes.stdout,
663
+ stderr: runRes.stderr
664
+ };
665
+ console.log(chalk.dim(` → Exit Code: ${result.exitCode}`));
666
+ if (result.stdout) console.log(chalk.dim(` → Stdout : ${result.stdout.trim().slice(0, 80)}`));
27
667
 
28
- // Push to notification window
29
- shell.pushNotification({
30
- type: 'notification',
31
- level: 'success',
32
- title: 'Task Completed',
33
- message: result.task?.title || taskId,
34
- });
668
+ } else if (testConfig.type === 'json') {
669
+ const cmd = testConfig.command || testConfig.script;
670
+ if (cmd) {
671
+ console.log(chalk.dim(` → Executing local command: "${cmd}"`));
672
+ const runRes = await runLocalCommand(cmd);
673
+ let parsedJson = {};
674
+ try {
675
+ parsedJson = JSON.parse(runRes.stdout.trim());
676
+ } catch (e) {
677
+ throw new Error(`Command executed but stdout was not valid JSON: ${runRes.stdout}`);
678
+ }
679
+ result = {
680
+ exitCode: runRes.exitCode,
681
+ stdout: runRes.stdout,
682
+ stderr: runRes.stderr,
683
+ json: parsedJson,
684
+ output: parsedJson
685
+ };
686
+ } else {
687
+ throw new Error('testConfig type "json" requires a "command" or "script" to output JSON.');
688
+ }
35
689
 
36
- } catch (err) {
37
- if (err.message?.includes('403'))
38
- console.log(chalk.red('❌ You are not assigned to this task'));
39
- else if (err.message?.includes('404'))
40
- console.log(chalk.red('❌ Task not found'));
41
- else if (err.message?.includes('already'))
42
- console.log(chalk.yellow('⚠️ Task already completed'));
43
- else
44
- console.log(chalk.red(`❌ ${err.message}`));
690
+ } else if (testConfig.type === 'loadtest' || testConfig.type === 'geekload') {
691
+ const script = testConfig.script || testConfig.command;
692
+ if (!script) {
693
+ throw new Error(`testConfig type "${testConfig.type}" requires a "script" or "command" specifying the load test script path.`);
45
694
  }
695
+ const scriptPath = path.resolve(process.cwd(), script);
696
+ console.log(chalk.dim(` → Running GeekLoad test script: ${script}`));
697
+ const { runLoadTest } = require('../../core/geekload');
698
+ const loadtestResult = await runLoadTest(scriptPath);
699
+
700
+ const Table = require('cli-table3');
701
+ const table = new Table({
702
+ head: [chalk.cyan('Metric'), chalk.cyan('Value')],
703
+ colWidths: [25, 20]
704
+ });
705
+ table.push(
706
+ ['Virtual Users', loadtestResult.virtual_users],
707
+ ['Duration (sec)', loadtestResult.duration_sec],
708
+ ['Total Requests', loadtestResult.total_requests],
709
+ ['Failed Requests', loadtestResult.failed_requests],
710
+ ['Failure Rate', `${(loadtestResult.failure_rate * 100).toFixed(2)}%`],
711
+ ['p95 Latency (ms)', loadtestResult.p95_latency_ms]
712
+ );
713
+ console.log('\n' + table.toString() + '\n');
714
+
715
+ result = {
716
+ exitCode: loadtestResult.failed_requests > 0 ? 1 : 0,
717
+ output: loadtestResult,
718
+ json: loadtestResult
719
+ };
720
+
721
+ } else {
722
+ console.log(chalk.red(`❌ Unsupported test type: ${testConfig.type}`));
723
+ return;
724
+ }
725
+ } catch (err) {
726
+ console.log(chalk.red(`\n ❌ Virtual user failed: ${err.message}\n`));
727
+ return;
46
728
  }
729
+
730
+ // ── Judge ─────────────────────────────────────────────────────────────────
731
+ const verdict = judgeLocally(testConfig, result);
732
+ const border = verdict.passed ? chalk.green('─'.repeat(50)) : chalk.red('─'.repeat(50));
733
+ const badge = verdict.passed ? chalk.bgGreen.black(' PASS ') : chalk.bgRed.white(' FAIL ');
734
+
735
+ console.log('\n' + border);
736
+ console.log(` ${badge} ${chalk.bold(title)}`);
737
+ console.log(` ${chalk.dim('Result :')} ${verdict.passed ? chalk.green(verdict.reason) : chalk.red(verdict.reason)}`);
738
+ console.log(border);
739
+
740
+ // ── Webhook ───────────────────────────────────────────────────────────────
741
+ try {
742
+ console.log(chalk.dim('\n Syncing with ThinkNCollab...'));
743
+ await hitWebhook(apiUrl, taskId, webhookSecret, result);
744
+ console.log(chalk.green(' ✓ Verdict saved — task updated\n'));
745
+ } catch (err) {
746
+ console.log(chalk.yellow(` ⚠️ Webhook sync failed: ${err.message}`));
747
+ console.log(chalk.dim(' Local result shown above is accurate.\n'));
748
+ }
749
+
750
+ // ── Notification ──────────────────────────────────────────────────────────
751
+ shell.pushNotification({
752
+ type: 'verdict',
753
+ passed: verdict.passed,
754
+ title,
755
+ reason: verdict.reason,
756
+ taskId,
757
+ at: new Date().toISOString(),
758
+ });
759
+ },
47
760
  };