// CI unit-test wrapper.
//
// Same Angular `@angular/build` (esbuild) non-exit issue as
// scripts/build-web.mjs, but for `ng test`: the test bundle builds, vitest
// runs and prints its summary, but the process then fails to terminate (a
// lingering esbuild service keeps the event loop alive). In CI that hangs the
// test step until the job timeout even though every test passed.
//
// This wrapper runs `ng test` and:
//   - forwards ng's exit code if it exits on its own;
//   - reads vitest's final "Test Files" summary — any "failed" -> exit 1;
//   - on an all-passed summary that does not terminate within a grace window,
//     force-exits 0;
//   - fails fast on an esbuild/angular-compiler build error (e.g. a TypeScript
//     error in a spec): the build prints the error but then never emits a
//     vitest summary and the esbuild service lingers, so the run would otherwise
//     hang to the hard timeout instead of reporting the compile failure.
// It only exits 0 after seeing a passing summary, so a crash with no summary
// still fails (via the build-error detection, or the hard timeout). Used by
// `npm run test:ci`; plain `npm test` is left as the normal (watch) command for
// local development.
//
// Extra arguments are passed straight through to `ng test`, so the exit-safe
// behaviour is also available when filtering to one spec — which is what
// red->green verifying a regression test needs:
//   npm run test:ci -- --include "src/app/.../foo.spec.ts"

import { spawn } from 'node:child_process';
import { createRequire } from 'node:module';

const require = createRequire(import.meta.url);
const ngBin = require.resolve('@angular/cli/bin/ng.js');
const NG_ARGS = ['test', ...process.argv.slice(2)];

// Matches vitest's final summary line, e.g. "Test Files  3 passed (3)" or
// "Test Files  1 failed | 2 passed (3)".
const SUMMARY = /Test Files\s+\d+\s+(?:failed|passed)/;
// esbuild's fatal build-error header (used by the Angular compiler plugin too),
// e.g. "✘ [ERROR] TS2339: ...". A build error means the test bundle never ran,
// so no vitest summary is coming — fail fast instead of riding the hard timeout.
const BUILD_ERROR = /✘ \[ERROR\]/;
const GRACE_MS = 10_000; // let ng exit on its own before we force it
// Backstop if no summary ever prints. The armv7 (Cerbo GX) CI leg runs the whole
// suite under QEMU emulation, where the test phase costs ~800s against ~60s
// native and grows with every spec file added, so it needs a budget the other
// platforms don't. Only 32-bit ARM gets it (same detection as
// vitest-base.config.ts) — native keeps the tighter one so real hangs surface
// quickly.
//
// 18min is derived, not a round number. The shared plugin-ci armv7 job is capped
// at 30min and spends ~600s reaching the test step (npm ci, npm rebuild,
// build:all, plus checkout and QEMU setup), leaving ~1200s. A budget at that
// ceiling could never fire — GitHub would kill the job first, with none of this
// wrapper's diagnostics — so it sits below it, with room for a cold npm cache.
const HARD_TIMEOUT_MS = (process.arch === 'arm' ? 18 : 15) * 60 * 1000;

const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*m/g, '');

const child = spawn(process.execPath, [ngBin, ...NG_ARGS], {
  stdio: ['inherit', 'pipe', 'pipe']
});

let settled = false;
let graceTimer = null;
let hardTimer = null;

const finish = (code, reason) => {
  if (settled) return;
  settled = true;
  clearTimeout(graceTimer);
  clearTimeout(hardTimer);
  if (reason) console.log(`\n[test-ci] ${reason}`);
  child.kill('SIGTERM');
  setTimeout(() => process.exit(code), 500);
};

const scan = (chunk) => {
  if (settled) return;
  for (const line of stripAnsi(chunk.toString()).split('\n')) {
    if (BUILD_ERROR.test(line)) {
      finish(1, 'Build error — tests could not run (likely a compile error).');
      return;
    }
    if (!SUMMARY.test(line)) continue;
    if (/failed/.test(line)) {
      finish(1, 'Tests failed.');
    } else {
      graceTimer = setTimeout(
        () => finish(0, 'Tests passed; ng test did not exit, forcing success.'),
        GRACE_MS
      );
    }
    return;
  }
};

child.stdout.on('data', (b) => {
  process.stdout.write(b);
  scan(b);
});
child.stderr.on('data', (b) => {
  process.stderr.write(b);
  scan(b);
});

child.on('exit', (code, signal) => {
  if (settled) return;
  if (signal) finish(1, `ng test terminated by signal ${signal}.`);
  else finish(code ?? 1, `ng test exited with code ${code}.`);
});
child.on('error', (err) =>
  finish(1, `Failed to start ng test: ${err.message}`)
);

hardTimer = setTimeout(
  () =>
    finish(
      1,
      `Hard timeout (${HARD_TIMEOUT_MS / 1000}s) — tests never completed.`
    ),
  HARD_TIMEOUT_MS
);
