qualty 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/bin/qualty.js +190 -40
  2. package/package.json +1 -1
package/bin/qualty.js CHANGED
@@ -1,10 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ import { appendFileSync } from "node:fs";
3
4
  import { spawn } from "node:child_process";
4
5
  import process from "node:process";
5
6
 
6
- /** When unset, CLI talks to Qualty cloud. Override for self-hosted or local dev, e.g. QUALTY_API_URL=http://localhost:8000 */
7
- const DEFAULT_QUALTY_API_URL = "https://qualty-api-production.up.railway.app";
7
+ /** When unset, CLI uses Qualty development API. Override for production: QUALTY_API_URL=https://qualty-api-production.up.railway.app or local: http://localhost:8000 */
8
+ const DEFAULT_QUALTY_API_URL = "https://qualty-api-development.up.railway.app";
8
9
 
9
10
  function resolveApiUrl(args) {
10
11
  return String(args.api || process.env.QUALTY_API_URL || DEFAULT_QUALTY_API_URL).replace(/\/$/, "");
@@ -174,18 +175,143 @@ function sleep(ms) {
174
175
  return new Promise((resolve) => setTimeout(resolve, ms));
175
176
  }
176
177
 
178
+ function isGithubActions() {
179
+ return process.env.GITHUB_ACTIONS === "true";
180
+ }
181
+
182
+ /** Safe one-line text for a GitHub-flavored Markdown table cell. */
183
+ function mdTableCell(text, maxLen = 200) {
184
+ return String(text ?? "")
185
+ .replace(/\r\n/g, "\n")
186
+ .replace(/\n/g, " ")
187
+ .replace(/\\/g, "\\\\")
188
+ .replace(/\|/g, "\\|")
189
+ .trim()
190
+ .slice(0, maxLen) || "—";
191
+ }
192
+
193
+ function githubWorkflowRunUrl() {
194
+ const server = process.env.GITHUB_SERVER_URL || "https://github.com";
195
+ const repo = process.env.GITHUB_REPOSITORY;
196
+ const runId = process.env.GITHUB_RUN_ID;
197
+ if (!repo || !runId) return "";
198
+ return `${server.replace(/\/$/, "")}/${repo}/actions/runs/${runId}`;
199
+ }
200
+
201
+ function executionOutcome(status) {
202
+ const st = status?.status;
203
+ const failed = Number(status?.failed_tests ?? 0);
204
+ if (st === "completed" && failed === 0) return { ok: true, label: "Pass" };
205
+ if (st === "completed" && failed > 0) return { ok: false, label: "Fail" };
206
+ if (st === "cancelled") return { ok: false, label: "Cancelled" };
207
+ if (st === "failed") return { ok: false, label: "Failed" };
208
+ return { ok: false, label: mdTableCell(st || "?", 24) };
209
+ }
210
+
211
+ /**
212
+ * Rich Markdown for the job "Summary" tab (tables, links). Step logs stay plain text.
213
+ * https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#adding-a-job-summary
214
+ */
215
+ function appendGithubJobSummaryMarkdown(markdown) {
216
+ const path = process.env.GITHUB_STEP_SUMMARY;
217
+ if (!path || !markdown) return;
218
+ try {
219
+ appendFileSync(path, `${markdown}\n`, "utf8");
220
+ } catch {
221
+ // best effort — never fail the job for summary I/O
222
+ }
223
+ }
224
+
225
+ function writeQualtyGithubJobSummary({ executionJobIds, finalStatuses, passed, failed }) {
226
+ if (!isGithubActions()) return;
227
+
228
+ const total = executionJobIds.length;
229
+ const lines = [];
230
+ lines.push("## Qualty");
231
+ lines.push("");
232
+ lines.push(
233
+ `**${passed} passed**, **${failed} failed** · ${total} run${total === 1 ? "" : "s"}.`
234
+ );
235
+ lines.push("");
236
+ lines.push("| Test | Execution | Status | Failed | Result | Qualty |");
237
+ lines.push("| --- | --- | --- | ---: | --- | --- |");
238
+
239
+ for (const executionId of executionJobIds) {
240
+ const status = finalStatuses[executionId] || {};
241
+ const title = mdTableCell(status.episode_name || "—", 72);
242
+ const idCell = `\`${mdTableCell(executionId, 80)}\``;
243
+ const apiStatus = mdTableCell(status.status ?? "—", 20);
244
+ const failedN = status.failed_tests != null ? String(status.failed_tests) : "—";
245
+ const outcome = executionOutcome(status);
246
+ const resultCell = outcome.ok ? `✅ **${outcome.label}**` : `❌ **${outcome.label}**`;
247
+ const url = String(status.url || "").trim();
248
+ const linkCell = url ? `[Open run](${url})` : "—";
249
+ lines.push(
250
+ `| ${title} | ${idCell} | ${apiStatus} | ${failedN} | ${resultCell} | ${linkCell} |`
251
+ );
252
+ }
253
+
254
+ const comboRows = [];
255
+ for (const executionId of executionJobIds) {
256
+ const status = finalStatuses[executionId] || {};
257
+ const combos = Array.isArray(status.combinations) ? status.combinations : [];
258
+ const shortTitle = mdTableCell(
259
+ status.episode_name || (executionId ? String(executionId).slice(0, 8) : "—"),
260
+ 40
261
+ );
262
+ for (const c of combos) {
263
+ const device = mdTableCell(c.device ?? "?", 32);
264
+ const comboSt = mdTableCell(
265
+ c.status ?? (c.success === true ? "passed" : c.success === false ? "failed" : "?"),
266
+ 16
267
+ );
268
+ comboRows.push(`| \`${mdTableCell(executionId, 36)}\` | ${shortTitle} | ${device} | ${comboSt} |`);
269
+ }
270
+ }
271
+
272
+ if (comboRows.length > 0) {
273
+ lines.push("");
274
+ lines.push("<details>");
275
+ lines.push("<summary><strong>Per device / combination</strong></summary>");
276
+ lines.push("");
277
+ lines.push("| Execution | Test | Device | Result |");
278
+ lines.push("| --- | --- | --- | --- |");
279
+ lines.push(...comboRows);
280
+ lines.push("");
281
+ lines.push("</details>");
282
+ }
283
+
284
+ const runUrl = githubWorkflowRunUrl();
285
+ if (runUrl) {
286
+ lines.push("");
287
+ lines.push(`[This workflow run on GitHub](${runUrl})`);
288
+ }
289
+
290
+ appendGithubJobSummaryMarkdown(lines.join("\n"));
291
+ }
292
+
177
293
  function truncateForCiLog(text, maxLen) {
178
294
  const s = String(text ?? "");
179
295
  if (s.length <= maxLen) return s;
180
- return `${s.slice(0, maxLen)}\n[qualty] (truncated, ${s.length - maxLen} more chars)`;
296
+ const tail = isGithubActions()
297
+ ? `… (truncated, ${s.length - maxLen} more chars)`
298
+ : `[qualty] … (truncated, ${s.length - maxLen} more chars)`;
299
+ return `${s.slice(0, maxLen)}\n${tail}`;
300
+ }
301
+
302
+ /** One log line: `[qualty]` prefix only outside GitHub Actions (inside ::group::, plain text reads better). */
303
+ function viewOut(line) {
304
+ // eslint-disable-next-line no-console
305
+ console.log(isGithubActions() ? line : `[qualty] ${line}`);
181
306
  }
182
307
 
183
- function logPrefixedLines(prefix, text) {
308
+ function logPrefixedLines(ghaIndent, nonGhaPrefix, text) {
184
309
  const body = String(text ?? "").trimEnd();
185
310
  if (!body) return;
186
- for (const line of body.split("\n")) {
311
+ const prefix = isGithubActions() ? ghaIndent : nonGhaPrefix;
312
+ for (const ln of body.split("\n")) {
187
313
  // eslint-disable-next-line no-console
188
- console.log(`${prefix}${line}`);
314
+ console.log(`${prefix}${ln}`);
189
315
  }
190
316
  }
191
317
 
@@ -194,40 +320,51 @@ function logPrefixedLines(prefix, text) {
194
320
  * Data comes from GET status payload fields on each combination (steps_json, explanation, agent_output).
195
321
  */
196
322
  function printQualtyViewLogsReport(executionId, status) {
197
- // eslint-disable-next-line no-console
198
- console.log(`\n[qualty] ━━━ View logs: ${executionId} (${status.episode_name || "run"}) ━━━`);
199
- if (status.url) {
323
+ const gha = isGithubActions();
324
+ const title = `${status.episode_name || "run"} (${executionId})`;
325
+ if (gha) {
326
+ // Collapsible section in GitHub Actions (no rich tables — stdout is plain text).
327
+ // eslint-disable-next-line no-console
328
+ console.log(`::group::Qualty · ${title}`);
329
+ } else {
200
330
  // eslint-disable-next-line no-console
201
- console.log(`[qualty] URL: ${status.url}`);
331
+ console.log(`\n[qualty] ━━━ View logs: ${executionId} (${status.episode_name || "run"}) ━━━`);
202
332
  }
333
+ if (status.url) viewOut(`URL: ${status.url}`);
203
334
  if (status.error) {
204
- // eslint-disable-next-line no-console
205
- console.log(`[qualty] Run error: ${truncateForCiLog(status.error, 4000)}`);
335
+ viewOut("Run error:");
336
+ logPrefixedLines(" ", "[qualty] ", truncateForCiLog(status.error, 4000));
206
337
  }
207
338
  if (status.expected_behavior) {
208
- // eslint-disable-next-line no-console
209
- console.log(`[qualty] Expected behavior:\n[qualty] ${truncateForCiLog(status.expected_behavior, 6000).split("\n").join("\n[qualty] ")}`);
339
+ viewOut("Expected behavior:");
340
+ logPrefixedLines(" ", "[qualty] ", truncateForCiLog(status.expected_behavior, 6000));
210
341
  }
211
342
  const combos = Array.isArray(status.combinations) ? status.combinations : [];
212
343
  if (combos.length === 0) {
213
- // eslint-disable-next-line no-console
214
- console.log(
215
- "[qualty] (No per-device breakdown in API response yet — open this run in the Qualty dashboard for full logs.)"
216
- );
217
- // eslint-disable-next-line no-console
218
- console.log(`[qualty] ━━━ End view logs: ${executionId} ━━━\n`);
344
+ viewOut("(No per-device breakdown in API response yet — open this run in the Qualty dashboard for full logs.)");
345
+ if (gha) {
346
+ // eslint-disable-next-line no-console
347
+ console.log("::endgroup::");
348
+ } else {
349
+ // eslint-disable-next-line no-console
350
+ console.log(`[qualty] ━━━ End view logs: ${executionId} ━━━\n`);
351
+ }
219
352
  return;
220
353
  }
221
354
  for (let i = 0; i < combos.length; i += 1) {
222
355
  const c = combos[i];
223
356
  const device = c.device ?? "?";
224
357
  const comboStatus = c.status ?? (c.success === true ? "passed" : c.success === false ? "failed" : "?");
225
- // eslint-disable-next-line no-console
226
- console.log(`\n[qualty] --- Combination ${i + 1}/${combos.length} (${device} · ${comboStatus}) ---`);
358
+ if (gha) {
359
+ // eslint-disable-next-line no-console
360
+ console.log(`::group::${device} · ${comboStatus} (${i + 1}/${combos.length})`);
361
+ } else {
362
+ // eslint-disable-next-line no-console
363
+ console.log(`\n[qualty] --- Combination ${i + 1}/${combos.length} (${device} · ${comboStatus}) ---`);
364
+ }
227
365
  const steps = Array.isArray(c.steps_json) ? c.steps_json : [];
228
366
  if (steps.length > 0) {
229
- // eslint-disable-next-line no-console
230
- console.log(`[qualty] Steps (${steps.length}):`);
367
+ viewOut(`Steps (${steps.length}):`);
231
368
  for (let j = 0; j < steps.length; j += 1) {
232
369
  const s = steps[j];
233
370
  const label =
@@ -235,34 +372,38 @@ function printQualtyViewLogsReport(executionId, status) {
235
372
  (s.description && String(s.description).trim().slice(0, 100)) ||
236
373
  `Step ${j + 1}`;
237
374
  const st = s.status != null ? s.status : "?";
238
- // eslint-disable-next-line no-console
239
- console.log(`[qualty] ${j + 1}. [${st}] ${label}`);
375
+ viewOut(` ${j + 1}. [${st}] ${label}`);
240
376
  if (s.description && String(s.description).trim() && String(s.description) !== String(s.name)) {
241
- logPrefixedLines("[qualty] ", truncateForCiLog(s.description, 4000));
377
+ logPrefixedLines(" ", "[qualty] ", truncateForCiLog(s.description, 4000));
242
378
  }
243
379
  if (s.action) {
244
- // eslint-disable-next-line no-console
245
- console.log(`[qualty] action: ${truncateForCiLog(s.action, 2000)}`);
380
+ logPrefixedLines(" ", "[qualty] ", `action: ${truncateForCiLog(s.action, 2000)}`);
246
381
  }
247
382
  }
248
383
  } else if (c.total_steps) {
249
- // eslint-disable-next-line no-console
250
- console.log(`[qualty] (Step list not available yet; ${c.total_steps} step(s) reported.)`);
384
+ viewOut(`(Step list not available yet; ${c.total_steps} step(s) reported.)`);
251
385
  }
252
386
  if (c.explanation) {
253
- // eslint-disable-next-line no-console
254
- console.log(`[qualty] Explanation:\n[qualty] ${truncateForCiLog(c.explanation, 12000).split("\n").join("\n[qualty] ")}`);
387
+ viewOut("Explanation:");
388
+ logPrefixedLines(" ", "[qualty] ", truncateForCiLog(c.explanation, 12000));
255
389
  }
256
390
  const evaluator = c.agent_output ?? c.gpt_output;
257
391
  if (evaluator) {
392
+ viewOut("Final evaluator output:");
393
+ logPrefixedLines(" ", "[qualty] ", truncateForCiLog(evaluator, 16000));
394
+ }
395
+ if (gha) {
258
396
  // eslint-disable-next-line no-console
259
- console.log(
260
- `[qualty] Final evaluator output:\n[qualty] ${truncateForCiLog(evaluator, 16000).split("\n").join("\n[qualty] ")}`
261
- );
397
+ console.log("::endgroup::");
262
398
  }
263
399
  }
264
- // eslint-disable-next-line no-console
265
- console.log(`\n[qualty] ━━━ End view logs: ${executionId} ━━━\n`);
400
+ if (gha) {
401
+ // eslint-disable-next-line no-console
402
+ console.log("::endgroup::");
403
+ } else {
404
+ // eslint-disable-next-line no-console
405
+ console.log(`\n[qualty] ━━━ End view logs: ${executionId} ━━━\n`);
406
+ }
266
407
  }
267
408
 
268
409
  async function runCi(args) {
@@ -377,9 +518,18 @@ async function runCi(args) {
377
518
  );
378
519
  }
379
520
 
521
+ writeQualtyGithubJobSummary({ executionJobIds, finalStatuses, passed, failed });
522
+
380
523
  if (!noViewLogs) {
381
- // eslint-disable-next-line no-console
382
- console.log(`[qualty] Detailed run output (same fields as dashboard "View logs"):`);
524
+ if (isGithubActions()) {
525
+ // eslint-disable-next-line no-console
526
+ console.log(
527
+ "[qualty] Open the job Summary tab for a results table; expand the Qualty groups below for full step logs."
528
+ );
529
+ } else {
530
+ // eslint-disable-next-line no-console
531
+ console.log(`[qualty] Detailed run output (same fields as dashboard "View logs"):`);
532
+ }
383
533
  for (const executionId of executionJobIds) {
384
534
  printQualtyViewLogsReport(executionId, finalStatuses[executionId] || {});
385
535
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qualty",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Qualty CLI for localhost and CI test runs",
5
5
  "bin": {
6
6
  "qualty": "bin/qualty.js"