thinkncollab-shell 0.0.9 → 0.0.12

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,567 @@
1
- /*
2
- src/commands/tasks/complete.js — Mark your task assignment as completed
3
- */
4
- const chalk = require('chalk');
5
1
 
2
+
3
+ const chalk = require('chalk');
4
+ const crypto = require('crypto');
5
+
6
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
7
+
8
+ function getFetchFn() {
9
+ return globalThis.fetch || require('node-fetch');
10
+ }
11
+
12
+ // Parse raw Set-Cookie header into Playwright cookie objects
13
+ function parseCookieHeader(raw, base) {
14
+ const domain = new URL(base).hostname;
15
+ const cookies = [];
16
+ const parts = raw.split(/,(?=[^ ][^=]+=)/);
17
+
18
+ for (const part of parts) {
19
+ const segments = part.split(';').map(s => s.trim());
20
+ const [nameVal, ...attrs] = segments;
21
+ const eqIdx = nameVal.indexOf('=');
22
+ if (eqIdx === -1) continue;
23
+
24
+ const name = nameVal.slice(0, eqIdx).trim();
25
+ const value = nameVal.slice(eqIdx + 1).trim();
26
+ const attrMap = {};
27
+
28
+ for (const attr of attrs) {
29
+ const [k, v] = attr.split('=').map(s => s.trim());
30
+ attrMap[k.toLowerCase()] = v || true;
31
+ }
32
+
33
+ cookies.push({
34
+ name,
35
+ value,
36
+ domain: attrMap.domain || domain,
37
+ path: attrMap.path || '/',
38
+ httpOnly: !!attrMap.httponly,
39
+ secure: !!attrMap.secure,
40
+ sameSite: attrMap.samesite === 'None' ? 'None'
41
+ : attrMap.samesite === 'Strict' ? 'Strict'
42
+ : 'Lax',
43
+ });
44
+ }
45
+
46
+ return cookies;
47
+ }
48
+
49
+ function extractCsrf(html, customSelector) {
50
+ if (customSelector) {
51
+ const nameMatch = customSelector.match(/name=['"]([^'"]+)['"]/);
52
+ if (nameMatch) {
53
+ const re = new RegExp(
54
+ `name=["']${nameMatch[1]}["'][^>]*value=["']([^"']+)["']|value=["']([^"']+)["'][^>]*name=["']${nameMatch[1]}["']`
55
+ );
56
+ const m = html.match(re);
57
+ if (m) return { field: nameMatch[1], token: m[1] || m[2] };
58
+ }
59
+ }
60
+
61
+ const patterns = [
62
+ { field: '_csrf', re: /name=["']_csrf["'][^>]*value=["']([^"']+)["']/ },
63
+ { field: '_token', re: /name=["']_token["'][^>]*value=["']([^"']+)["']/ },
64
+ { field: 'authenticity_token', re: /name=["']authenticity_token["'][^>]*value=["']([^"']+)["']/ },
65
+ { field: '__RequestVerificationToken', re: /name=["']__RequestVerificationToken["'][^>]*value=["']([^"']+)["']/ },
66
+ ];
67
+
68
+ for (const { field, re } of patterns) {
69
+ const m = html.match(re);
70
+ if (m) return { field, token: m[1] };
71
+ }
72
+
73
+ return null;
74
+ }
75
+
76
+ // ─── Strategy: request ────────────────────────────────────────────────────────
77
+ async function authViaRequest(context, base, email, password, authConfig) {
78
+ const fetch = getFetchFn();
79
+ const signinPath = authConfig.signinUrl || '/signin';
80
+ const signinUrl = base.replace(/\/$/, '') + signinPath;
81
+
82
+ console.log(chalk.dim(` → [request] GET ${signinUrl}`));
83
+
84
+ // 1. GET signin page — grab CSRF + initial cookies
85
+ const getRes = await fetch(signinUrl, {
86
+ headers: { 'User-Agent': 'Mozilla/5.0 (compatible; TNCBot/1.0)' },
87
+ redirect: 'follow',
88
+ });
89
+ const html = await getRes.text();
90
+ const getCookies = getRes.headers.get('set-cookie') || '';
91
+ const csrf = extractCsrf(html, authConfig.csrfSelector);
92
+
93
+ if (csrf) console.log(chalk.dim(` → CSRF detected: ${csrf.field}`));
94
+
95
+ // 2. POST credentials
96
+ const formBody = new URLSearchParams({ email, password });
97
+ if (csrf) formBody.append(csrf.field, csrf.token);
98
+
99
+ console.log(chalk.dim(` → [request] POST ${signinUrl}`));
100
+
101
+ const postRes = await fetch(signinUrl, {
102
+ method: 'POST',
103
+ headers: {
104
+ 'Content-Type': 'application/x-www-form-urlencoded',
105
+ 'Cookie': getCookies,
106
+ 'User-Agent': 'Mozilla/5.0 (compatible; TNCBot/1.0)',
107
+ 'Referer': signinUrl,
108
+ },
109
+ body: formBody.toString(),
110
+ redirect: 'manual',
111
+ });
112
+
113
+ // 3. Extract session cookies — follow redirect manually if needed
114
+ let finalCookies = postRes.headers.get('set-cookie');
115
+
116
+ if (!finalCookies && postRes.status >= 300 && postRes.status < 400) {
117
+ const loc = postRes.headers.get('location');
118
+ if (loc) {
119
+ const redirectRes = await fetch(
120
+ loc.startsWith('http') ? loc : base + loc,
121
+ { headers: { 'Cookie': getCookies }, redirect: 'manual' }
122
+ );
123
+ finalCookies = redirectRes.headers.get('set-cookie');
124
+ }
125
+ }
126
+
127
+ if (!finalCookies) {
128
+ throw new Error(
129
+ 'Auth failed — no session cookie returned. ' +
130
+ 'Check testUser credentials or switch to strategy: "form"'
131
+ );
132
+ }
133
+
134
+ const parsed = parseCookieHeader(finalCookies, base);
135
+ if (!parsed.length) throw new Error('Could not parse session cookies from response');
136
+
137
+ await context.addCookies(parsed);
138
+ console.log(chalk.dim(` → Cookies injected: ${parsed.map(c => c.name).join(', ')}`));
139
+ }
140
+
141
+ async function authViaForm(page, base, email, password, authConfig) {
142
+ const signinPath = authConfig.signinUrl || '/signin';
143
+ await page.goto(base + signinPath, { waitUntil: 'networkidle', timeout: 15000 });
144
+ console.log(chalk.dim(` → [form] ${page.url()}`));
145
+
146
+ if (!page.url().includes(signinPath.replace(/^\//, ''))) return;
147
+
148
+ await page.evaluate(() => {
149
+ document.querySelectorAll('form input, form button, form > div, form > section').forEach(el => {
150
+ el.style.display = '';
151
+ el.style.visibility = 'visible';
152
+ el.style.opacity = '1';
153
+ });
154
+ });
155
+
156
+ const fields = authConfig.fields || {};
157
+
158
+ const emailSel = fields.email || [
159
+ 'input[type="email"]', 'input[name="email"]',
160
+ 'input[id="email"]', 'input[autocomplete="email"]',
161
+ ].join(', ');
162
+
163
+ const passSel = fields.password || [
164
+ 'input[type="password"]', 'input[name="password"]',
165
+ 'input[id="password"]', 'input[autocomplete="current-password"]',
166
+ ].join(', ');
167
+
168
+ const submitSel = fields.submit || [
169
+ 'button[type="submit"]', 'input[type="submit"]',
170
+ 'button:has-text("Sign in")', 'button:has-text("Login")',
171
+ 'button:has-text("Log in")', 'button:has-text("Continue")',
172
+ 'form button',
173
+ ].join(', ');
174
+
175
+ await page.locator(emailSel).first().fill(email);
176
+ console.log(chalk.dim(` → Email filled`));
177
+ await page.locator(passSel).first().fill(password);
178
+ console.log(chalk.dim(` → Password filled`));
179
+
180
+ const hasSubmit = await page.locator(submitSel).first().count().catch(() => 0);
181
+ if (hasSubmit) {
182
+ await page.locator(submitSel).first().click();
183
+ console.log(chalk.dim(` → Submit clicked`));
184
+ } else {
185
+ await page.locator(passSel).first().press('Enter');
186
+ console.log(chalk.dim(` → Submitted via Enter`));
187
+ }
188
+
189
+ const successPath = authConfig.successRedirect || null;
190
+ await page.waitForURL(
191
+ u => successPath
192
+ ? u.includes(successPath)
193
+ : !u.includes('/signin') && !u.includes('/login') && !u.includes('/verify'),
194
+ { timeout: 15000 }
195
+ ).catch(() => {});
196
+
197
+ if (page.url().includes('/signin') || page.url().includes('/login')) {
198
+ const errText = await page
199
+ .locator('.flash, .error, .alert, [class*="error"], [role="alert"]')
200
+ .first().textContent().catch(() => null);
201
+ throw new Error(
202
+ `Auth failed — still on signin.${errText ? ` Page: "${errText.trim()}"` : ' Check credentials.'}`
203
+ );
204
+ }
205
+
206
+ console.log(chalk.dim(` → Redirected: ${page.url()}`));
207
+ }
208
+
209
+ // ─── Strategy: cookie ─────────────────────────────────────────────────────────
210
+ async function authViaCookie(context, base, authConfig) {
211
+ const cookies = authConfig.cookies;
212
+ if (!Array.isArray(cookies) || !cookies.length) {
213
+ throw new Error('auth.strategy "cookie" requires auth.cookies array in testConfig');
214
+ }
215
+
216
+ const domain = new URL(base).hostname;
217
+ const normalized = cookies.map(c => ({
218
+ name: c.name,
219
+ value: c.value,
220
+ domain: c.domain || domain,
221
+ path: c.path || '/',
222
+ httpOnly: c.httpOnly || false,
223
+ secure: c.secure || false,
224
+ sameSite: c.sameSite || 'Lax',
225
+ }));
226
+
227
+ await context.addCookies(normalized);
228
+ console.log(chalk.dim(` → Cookies injected: ${normalized.map(c => c.name).join(', ')}`));
229
+ }
230
+
231
+ // ─── Auth Dispatcher ──────────────────────────────────────────────────────────
232
+ async function authenticate(page, context, base, testUser, authConfig = {}) {
233
+ const strategy = authConfig.strategy || 'request';
234
+ console.log(chalk.dim(` → Auth strategy: ${strategy}`));
235
+
236
+ switch (strategy) {
237
+ case 'request':
238
+ await authViaRequest(context, base, testUser.email, testUser.password, authConfig);
239
+ break;
240
+ case 'form':
241
+ await authViaForm(page, base, testUser.email, testUser.password, authConfig);
242
+ break;
243
+ case 'cookie':
244
+ await authViaCookie(context, base, authConfig);
245
+ break;
246
+ case 'skip':
247
+ console.log(chalk.dim(` → Auth skipped`));
248
+ break;
249
+ default:
250
+ throw new Error(`Unknown auth strategy: "${strategy}". Use: request | form | cookie | skip`);
251
+ }
252
+ }
253
+
254
+ // ─── HTTP Test ────────────────────────────────────────────────────────────────
255
+ async function runHttpTest(testConfig, testUser, authConfig = {}) {
256
+ const { baseUrl, method = 'GET', endpoint, headers = {}, body } = testConfig;
257
+ if (!baseUrl || !endpoint) throw new Error('testConfig missing baseUrl or endpoint');
258
+
259
+ const url = baseUrl.replace(/\/$/, '') + endpoint;
260
+ const fetch = getFetchFn();
261
+
262
+ // ── Auth — session cookie lo ──────────────────────────────────────────────
263
+ let sessionCookie = '';
264
+ if (testUser && authConfig.strategy !== 'skip') {
265
+ const signinUrl = baseUrl.replace(/\/$/, '') + (authConfig.signinUrl || '/signin');
266
+ const getRes = await fetch(signinUrl, { redirect: 'follow' });
267
+ const html = await getRes.text();
268
+ const getCookie = getRes.headers.get('set-cookie') || '';
269
+ const csrf = extractCsrf(html, authConfig.csrfSelector);
270
+
271
+ const formBody = new URLSearchParams({ email: testUser.email, password: testUser.password });
272
+ if (csrf) formBody.append(csrf.field, csrf.token);
273
+
274
+ const postRes = await fetch(signinUrl, {
275
+ method: 'POST',
276
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Cookie': getCookie },
277
+ body: formBody.toString(),
278
+ redirect: 'manual',
279
+ });
280
+
281
+ const setCookie = postRes.headers.get('set-cookie');
282
+ if (setCookie) {
283
+ sessionCookie = setCookie.split(',').map(c => c.split(';')[0].trim()).join('; ');
284
+ console.log(chalk.dim(` → HTTP auth: cookie obtained`));
285
+ } else {
286
+ console.log(chalk.yellow(` ⚠️ HTTP auth: no cookie returned`));
287
+ }
288
+ }
289
+
290
+ // ── API call ──────────────────────────────────────────────────────────────
291
+ let res, responseBody;
292
+ try {
293
+ res = await fetch(url, {
294
+ method,
295
+ headers: {
296
+ 'Content-Type': 'application/json',
297
+ ...(sessionCookie ? { 'Cookie': sessionCookie } : {}),
298
+ ...headers,
299
+ },
300
+ ...(body && method !== 'GET' ? { body: JSON.stringify(body) } : {})
301
+ });
302
+ responseBody = await res.json().catch(() => ({}));
303
+ } catch (err) {
304
+ throw new Error(`Could not reach ${url} — is your server running? (${err.message})`);
305
+ }
306
+
307
+ return { status: res.status, body: responseBody };
308
+ }
309
+
310
+ // ─── Browser Test ─────────────────────────────────────────────────────────────
311
+ async function runBrowserTest(testConfig, testUser, boardAuth = {}) {
312
+ let playwright;
313
+ try {
314
+ playwright = require('playwright');
315
+ } catch {
316
+ throw new Error(
317
+ 'Playwright not installed.\n Run: npm install -g playwright && npx playwright install chromium'
318
+ );
319
+ }
320
+
321
+ const { url, flow = [], expect, auth = {} } = testConfig;
322
+ if (!url) throw new Error('testConfig missing url');
323
+
324
+ const authConfig = { ...boardAuth, ...auth };
325
+
326
+ const base = new URL(url).origin;
327
+ const browser = await playwright.chromium.launch({ headless: true });
328
+ const context = await browser.newContext();
329
+ const page = await context.newPage();
330
+
331
+ try {
332
+ // ── Auth ──────────────────────────────────────────────────────────────────
333
+ const strategy = authConfig.strategy || (testUser ? 'request' : 'skip');
334
+ if (strategy === 'cookie') {
335
+ await authenticate(page, context, base, testUser, authConfig);
336
+ } else if (testUser && strategy !== 'skip') {
337
+ await authenticate(page, context, base, testUser, authConfig);
338
+
339
+ } else if (!testUser && strategy !== 'skip') {
340
+ console.log(chalk.yellow(` ⚠️ No testUser — auth skipped`));
341
+ }
342
+
343
+ // ── Navigate to target ────────────────────────────────────────────────────
344
+ await page.goto(url, { waitUntil: 'networkidle', timeout: 15000 });
345
+
346
+ if (page.url().includes('/signin') || page.url().includes('/login')) {
347
+ throw new Error('Auth failed — redirected to signin on target page');
348
+ }
349
+
350
+ console.log(chalk.dim(` → Target: ${page.url()}`));
351
+
352
+ // ── Flow steps ────────────────────────────────────────────────────────────
353
+ for (const step of flow) {
354
+ switch (step.action) {
355
+ case 'fill': await page.fill(step.selector, step.value || ''); break;
356
+ case 'click': await page.click(step.selector); break;
357
+ case 'wait': await page.waitForTimeout(step.ms || 1000); break;
358
+ case 'waitForElement':await page.waitForSelector(step.selector, { timeout: 8000 }); break;
359
+ case 'select': await page.selectOption(step.selector, step.value); break;
360
+ case 'screenshot': await page.screenshot({ path: step.path || '/tmp/tnc.png' }); break;
361
+ }
362
+ }
363
+
364
+ // ── Capture result ────────────────────────────────────────────────────────
365
+ const currentUrl = page.url();
366
+ const bodyText = await page.textContent('body').catch(() => '');
367
+
368
+ let elementExists = false;
369
+ let elementText = '';
370
+ if (expect?.element) {
371
+ try {
372
+ await page.waitForSelector(expect.element, { timeout: 5000 });
373
+ elementExists = true;
374
+ elementText = await page.textContent(expect.element).catch(() => '');
375
+ } catch { elementExists = false; }
376
+ }
377
+
378
+ return { url: currentUrl, elementExists, elementText, bodyText };
379
+ } finally {
380
+ await browser.close();
381
+ }
382
+ }
383
+
384
+ // ─── Local Judge ──────────────────────────────────────────────────────────────
385
+ function judgeLocally(testConfig, result) {
386
+ const { type, expect } = testConfig;
387
+ const failures = [];
388
+
389
+ if (type === 'http') {
390
+ if (expect?.status !== undefined && result.status !== expect.status)
391
+ failures.push(`Status: expected ${expect.status}, got ${result.status}`);
392
+
393
+ if (expect?.body) {
394
+ for (const [key, condition] of Object.entries(expect.body)) {
395
+ const actual = getNestedValue(result.body, key);
396
+ if (condition?.exists && (actual === undefined || actual === null))
397
+ failures.push(`body.${key}: expected to exist`);
398
+ else if (condition?.eq !== undefined && actual !== condition.eq)
399
+ failures.push(`body.${key}: expected "${condition.eq}", got "${actual}"`);
400
+ else if (condition?.contains !== undefined && !String(actual).includes(String(condition.contains)))
401
+ failures.push(`body.${key}: expected to contain "${condition.contains}"`);
402
+ }
403
+ }
404
+ }
405
+
406
+ if (type === 'browser') {
407
+ if (expect?.element && !result.elementExists)
408
+ failures.push(`Element "${expect.element}" not found on page`);
409
+
410
+ if (expect?.text && result.elementText && !result.elementText.includes(expect.text))
411
+ failures.push(`Element text: expected "${expect.text}", got "${result.elementText}"`);
412
+
413
+ if (expect?.redirectUrl) {
414
+ const actualPath = new URL(result.url).pathname;
415
+ if (!actualPath.includes(expect.redirectUrl))
416
+ failures.push(`URL: expected "${expect.redirectUrl}", got "${actualPath}"`);
417
+ }
418
+ }
419
+
420
+ return {
421
+ passed: failures.length === 0,
422
+ reason: failures.length === 0 ? 'All checks passed' : failures.join(' | '),
423
+ failures,
424
+ };
425
+ }
426
+
427
+ function getNestedValue(obj, key) {
428
+ return key.split('.').reduce((o, k) => (o && o[k] !== undefined ? o[k] : undefined), obj);
429
+ }
430
+
431
+ // ─── Webhook ──────────────────────────────────────────────────────────────────
432
+ async function hitWebhook(apiUrl, taskId, webhookSecret, payload) {
433
+ const body = JSON.stringify(payload);
434
+ const signature = 'sha256=' + crypto.createHmac('sha256', webhookSecret).update(body).digest('hex');
435
+ const fetch = getFetchFn();
436
+
437
+ const res = await fetch(`${apiUrl}/webhooks/tasks/${taskId}/judge`, {
438
+ method: 'POST',
439
+ headers: { 'Content-Type': 'application/json', 'x-tnc-signature': signature },
440
+ body,
441
+ });
442
+
443
+ if (!res.ok) {
444
+ const err = await res.json().catch(() => ({}));
445
+ throw new Error(err.error || `Webhook failed: ${res.status}`);
446
+ }
447
+
448
+ return res.json();
449
+ }
450
+
451
+ // ─── Command ──────────────────────────────────────────────────────────────────
6
452
  module.exports = {
7
- name: 'complete',
8
- description: 'Mark a task as completed',
9
- aliases: ['done', 'finish', 'complete'],
10
- requiresAuth: true,
11
-
12
- async execute(args, shell) {
13
- if (!args[0]) {
14
- console.log(chalk.red('Usage: complete <task-id>'));
15
- return;
16
- }
17
-
18
- const taskId = args[0];
19
-
20
- try {
21
- const result = await shell.api._request('POST', `/thinknsh/${taskId}/cli/complete`);
22
-
23
- console.log(chalk.green(`✅ Task completed: "${result.task?.title || taskId}"`));
24
-
25
- if (result.allCompleted)
26
- console.log(chalk.green(' 🎉 All assignees have completed this task!'));
27
-
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
- });
35
-
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}`));
45
- }
453
+ name: 'task-submit',
454
+ description: 'Auto-test and submit a task for verification',
455
+ aliases: ['submit'],
456
+ requiresAuth: true,
457
+
458
+ async execute(args, shell) {
459
+ if (!args[0]) {
460
+ console.log(chalk.red('Usage: task-submit <task-id>'));
461
+ console.log(chalk.dim(' Alias: submit <task-id>'));
462
+ return;
463
+ }
464
+
465
+ const taskId = args[0];
466
+ const apiUrl = shell.api.apiUrl || 'https://thinkncollab.com';
467
+
468
+ // ── Fetch task ────────────────────────────────────────────────────────────
469
+ let task;
470
+ try {
471
+ task = await shell.api._request('GET', `/thinknsh/${taskId}/cli`);
472
+ } catch (err) {
473
+ if (err.message?.includes('404')) console.log(chalk.red('❌ Task not found'));
474
+ else if (err.message?.includes('403')) console.log(chalk.red('❌ Access denied'));
475
+ else console.log(chalk.red(`❌ ${err.message}`));
476
+ return;
477
+ }
478
+
479
+ if (!task.testConfig) {
480
+ console.log(chalk.yellow('⚠️ This task has no test config defined.'));
481
+ console.log(chalk.dim(' complete <task-id> to mark manually\n'));
482
+ return;
483
+ }
484
+
485
+ const { title, testConfig, webhookSecret } = task;
486
+
487
+ const testUser = (task.testuserEmail && task.testuserPassword)
488
+ ? { email: task.testuserEmail, password: task.testuserPassword }
489
+ : null;
490
+
491
+ const boardAuth = {
492
+ strategy: task.testauthStrategy || 'request',
493
+ signinUrl: task.testauthSigninUrl || '/signin',
494
+ csrfSelector: task.testauthCsrf || null,
495
+ };
496
+
497
+ // ── Print summary ─────────────────────────────────────────────────────────
498
+ console.log(chalk.cyan(`\n ── Submitting: "${title}" ──────────────────────`));
499
+ console.log(chalk.dim(` Type : ${testConfig.type}`));
500
+ const authConfig = { ...boardAuth, ...(testConfig.auth || {}) };
501
+ if (testConfig.type === 'http') {
502
+ console.log(chalk.dim(` Target : ${testConfig.method || 'GET'} ${testConfig.baseUrl}${testConfig.endpoint}`));
503
+ } else if (testConfig.type === 'browser') {
504
+ const strategy = testConfig.auth?.strategy || (testUser ? 'request' : 'skip');
505
+ console.log(chalk.dim(` Target : ${testConfig.url}`));
506
+ console.log(chalk.dim(` Auth : ${strategy}`));
507
+ if (testUser) console.log(chalk.dim(` TestUser : ${testUser.email}`));
508
+ else console.log(chalk.yellow(` ⚠️ No testUser — auth will be skipped`));
46
509
  }
510
+ console.log(chalk.dim(` Running virtual user...\n`));
511
+
512
+ // ── Run test ──────────────────────────────────────────────────────────────
513
+ let result;
514
+ try {
515
+ if (testConfig.type === 'http') {
516
+ result = await runHttpTest(testConfig, testUser, authConfig);
517
+ console.log(chalk.dim(` → Status : ${result.status}`));
518
+ console.log(chalk.dim(` → Body : ${JSON.stringify(result.body).slice(0, 80)}`));
519
+
520
+ } else if (testConfig.type === 'browser') {
521
+ console.log(chalk.dim(` → Launching headless browser...`));
522
+ result = await runBrowserTest(testConfig, testUser, authConfig);
523
+ console.log(chalk.dim(` → Page : ${result.url}`));
524
+ console.log(chalk.dim(` → Element: ${result.elementExists ? 'found ✓' : 'not found ✗'}`));
525
+ if (result.elementText)
526
+ console.log(chalk.dim(` → Text : "${result.elementText.trim().slice(0, 60)}"`));
527
+
528
+ } else {
529
+ console.log(chalk.red(`❌ Unsupported test type: ${testConfig.type}`));
530
+ return;
531
+ }
532
+ } catch (err) {
533
+ console.log(chalk.red(`\n ❌ Virtual user failed: ${err.message}\n`));
534
+ return;
535
+ }
536
+
537
+ // ── Judge ─────────────────────────────────────────────────────────────────
538
+ const verdict = judgeLocally(testConfig, result);
539
+ const border = verdict.passed ? chalk.green('─'.repeat(50)) : chalk.red('─'.repeat(50));
540
+ const badge = verdict.passed ? chalk.bgGreen.black(' PASS ') : chalk.bgRed.white(' FAIL ');
541
+
542
+ console.log('\n' + border);
543
+ console.log(` ${badge} ${chalk.bold(title)}`);
544
+ console.log(` ${chalk.dim('Result :')} ${verdict.passed ? chalk.green(verdict.reason) : chalk.red(verdict.reason)}`);
545
+ console.log(border);
546
+
547
+ // ── Webhook ───────────────────────────────────────────────────────────────
548
+ try {
549
+ console.log(chalk.dim('\n Syncing with ThinkNCollab...'));
550
+ await hitWebhook(apiUrl, taskId, webhookSecret, result);
551
+ console.log(chalk.green(' ✓ Verdict saved — task updated\n'));
552
+ } catch (err) {
553
+ console.log(chalk.yellow(` ⚠️ Webhook sync failed: ${err.message}`));
554
+ console.log(chalk.dim(' Local result shown above is accurate.\n'));
555
+ }
556
+
557
+ // ── Notification ──────────────────────────────────────────────────────────
558
+ shell.pushNotification({
559
+ type: 'verdict',
560
+ passed: verdict.passed,
561
+ title,
562
+ reason: verdict.reason,
563
+ taskId,
564
+ at: new Date().toISOString(),
565
+ });
566
+ },
47
567
  };
@@ -16,7 +16,7 @@ module.exports = {
16
16
 
17
17
  try {
18
18
  // Fetch columns of current room's board
19
- const { columns } = await shell.api._request('GET', `/rooms/${roomId}/cli/columns`);
19
+ const { columns } = await shell.api._request('GET', `/thinknsh/${roomId}/cli/columns`);
20
20
  if (!columns?.length) {
21
21
  console.log(chalk.red('❌ No columns found in this room\'s board'));
22
22
  return;
@@ -78,7 +78,7 @@ module.exports = {
78
78
  source: 'cli',
79
79
  };
80
80
 
81
- const result = await shell.api._request('POST', `/${roomId}/cli/tasks`, payload);
81
+ const result = await shell.api._request('POST', `/thinknsh/${roomId}/cli/tasks`, payload);
82
82
 
83
83
  console.log(chalk.green(`\n✅ Task created: "${result.task.title}"`));
84
84
  console.log(chalk.dim(` ID: ${result.task._id}`));