@testspectra/cli 1.1.8-rc.13 → 1.1.8-rc.16

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,93 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { cloudRunCommand, parseTimeoutMs } from '../cloud-run.js';
3
+ const ORIGINAL_ENV = { ...process.env };
4
+ function mockFetchSequence(responses) {
5
+ let call = 0;
6
+ return vi.fn(async (_url, _init) => {
7
+ const res = responses[Math.min(call, responses.length - 1)];
8
+ call += 1;
9
+ return {
10
+ ok: res.ok,
11
+ status: res.status ?? (res.ok ? 200 : 500),
12
+ statusText: 'error',
13
+ json: async () => res.json,
14
+ };
15
+ });
16
+ }
17
+ describe('cloudRunCommand (docs/v2/cloud/git-spec-standards-and-workflow.md)', () => {
18
+ beforeEach(() => {
19
+ process.env = { ...ORIGINAL_ENV, SPECTRA_URL: 'https://cloud.test', SPECTRA_TOKEN: 'gitea-pat-123' };
20
+ process.exitCode = undefined;
21
+ vi.useFakeTimers();
22
+ });
23
+ afterEach(() => {
24
+ process.env = { ...ORIGINAL_ENV };
25
+ vi.restoreAllMocks();
26
+ vi.useRealTimers();
27
+ });
28
+ it('fails fast without SPECTRA_URL/SPECTRA_TOKEN', async () => {
29
+ delete process.env.SPECTRA_URL;
30
+ delete process.env.SPECTRA_TOKEN;
31
+ const fetchSpy = vi.fn();
32
+ vi.stubGlobal('fetch', fetchSpy);
33
+ await cloudRunCommand({ project: 'org/repo', branch: 'main' });
34
+ expect(process.exitCode).toBe(1);
35
+ expect(fetchSpy).not.toHaveBeenCalled();
36
+ });
37
+ it('triggers and returns immediately with --no-wait', async () => {
38
+ const fetchSpy = mockFetchSequence([{ ok: true, json: { job_id: 'job-1', status: 'queued' } }]);
39
+ vi.stubGlobal('fetch', fetchSpy);
40
+ await cloudRunCommand({ project: 'org/repo', branch: 'main', wait: false });
41
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
42
+ const [url, init] = fetchSpy.mock.calls[0];
43
+ expect(url).toBe('https://cloud.test/api/v1/runs/trigger');
44
+ const headers = init.headers;
45
+ expect(headers.Authorization).toBe('Bearer gitea-pat-123');
46
+ expect(JSON.parse(init.body)).toMatchObject({ project_id: 'org/repo', branch: 'main', platform: 'web' });
47
+ expect(process.exitCode).toBeUndefined();
48
+ });
49
+ it('exits 0 when the job reaches done', async () => {
50
+ const fetchSpy = mockFetchSequence([
51
+ { ok: true, json: { job_id: 'job-2', status: 'queued' } },
52
+ { ok: true, json: { status: 'done', run_id: 'run-2', started_at: null, completed_at: null, error_log: null } },
53
+ ]);
54
+ vi.stubGlobal('fetch', fetchSpy);
55
+ const promise = cloudRunCommand({ project: 'org/repo', branch: 'main' });
56
+ await vi.advanceTimersByTimeAsync(5000);
57
+ await promise;
58
+ expect(process.exitCode).toBe(0);
59
+ });
60
+ it('exits 1 when the job reaches failed', async () => {
61
+ const fetchSpy = mockFetchSequence([
62
+ { ok: true, json: { job_id: 'job-3', status: 'queued' } },
63
+ {
64
+ ok: true,
65
+ json: { status: 'failed', run_id: 'run-3', started_at: null, completed_at: null, error_log: 'boom' },
66
+ },
67
+ ]);
68
+ vi.stubGlobal('fetch', fetchSpy);
69
+ const promise = cloudRunCommand({ project: 'org/repo', branch: 'main' });
70
+ await vi.advanceTimersByTimeAsync(5000);
71
+ await promise;
72
+ expect(process.exitCode).toBe(1);
73
+ });
74
+ it('exits 1 when the trigger request itself fails', async () => {
75
+ const fetchSpy = mockFetchSequence([{ ok: false, status: 401, json: { error: 'Invalid PAT' } }]);
76
+ vi.stubGlobal('fetch', fetchSpy);
77
+ await cloudRunCommand({ project: 'org/repo', branch: 'main' });
78
+ expect(process.exitCode).toBe(1);
79
+ });
80
+ });
81
+ describe('parseTimeoutMs', () => {
82
+ it('parses seconds, minutes, and hours', () => {
83
+ expect(parseTimeoutMs('30s')).toBe(30_000);
84
+ expect(parseTimeoutMs('30m')).toBe(30 * 60_000);
85
+ expect(parseTimeoutMs('2h')).toBe(2 * 3_600_000);
86
+ });
87
+ it('defaults to minutes when no unit is given', () => {
88
+ expect(parseTimeoutMs('10')).toBe(10 * 60_000);
89
+ });
90
+ it('falls back to 30m for unparseable input', () => {
91
+ expect(parseTimeoutMs('nonsense')).toBe(30 * 60_000);
92
+ });
93
+ });
@@ -0,0 +1,13 @@
1
+ export interface CloudRunOptions {
2
+ project: string;
3
+ projectName?: string;
4
+ branch?: string;
5
+ commit?: string;
6
+ platform?: string;
7
+ wait?: boolean;
8
+ timeout?: string;
9
+ demo?: boolean;
10
+ baseUrl?: string;
11
+ }
12
+ export declare function parseTimeoutMs(timeout: string): number;
13
+ export declare function cloudRunCommand(options: CloudRunOptions): Promise<void>;
@@ -0,0 +1,120 @@
1
+ import { execFileSync } from 'child_process';
2
+ import chalk from 'chalk';
3
+ import ora from 'ora';
4
+ const POLL_INTERVAL_MS = 5000;
5
+ const DEFAULT_TIMEOUT = '30m';
6
+ function detectGitRef(...args) {
7
+ try {
8
+ return execFileSync('git', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() || undefined;
9
+ }
10
+ catch {
11
+ return undefined;
12
+ }
13
+ }
14
+ export function parseTimeoutMs(timeout) {
15
+ const match = /^(\d+)(s|m|h)?$/.exec(timeout.trim());
16
+ if (!match)
17
+ return 30 * 60 * 1000;
18
+ const value = Number(match[1]);
19
+ const unit = match[2] ?? 'm';
20
+ const multiplier = unit === 's' ? 1000 : unit === 'h' ? 3_600_000 : 60_000;
21
+ return value * multiplier;
22
+ }
23
+ // Same credential path as the trigger endpoint expects: a Gitea PAT, not the
24
+ // dashboard's OAuth2 session — a CI runner can't complete a browser redirect.
25
+ // See docs/v2/cloud/git-spec-standards-and-workflow.md.
26
+ function requireEnv(name) {
27
+ const value = process.env[name];
28
+ if (!value) {
29
+ console.error(chalk.red(`Missing required environment variable: ${name}`));
30
+ console.error(chalk.gray('Set it as a CI secret — see docs/v2/cloud/git-spec-standards-and-workflow.md'));
31
+ }
32
+ return value;
33
+ }
34
+ export async function cloudRunCommand(options) {
35
+ const spectraUrl = requireEnv('SPECTRA_URL')?.replace(/\/$/, '');
36
+ const spectraToken = requireEnv('SPECTRA_TOKEN');
37
+ if (!spectraUrl || !spectraToken) {
38
+ process.exitCode = 1;
39
+ return;
40
+ }
41
+ const branch = options.branch ?? detectGitRef('rev-parse', '--abbrev-ref', 'HEAD');
42
+ if (!branch) {
43
+ console.error(chalk.red('Could not determine branch: pass --branch or run inside a git repository.'));
44
+ process.exitCode = 1;
45
+ return;
46
+ }
47
+ const commit = options.commit ?? detectGitRef('rev-parse', 'HEAD');
48
+ const platform = options.platform ?? 'web';
49
+ const wait = options.wait !== false;
50
+ const timeout = options.timeout ?? DEFAULT_TIMEOUT;
51
+ const spinner = ora(`Triggering cloud run for ${chalk.bold(options.project)}@${branch}...`).start();
52
+ let trigger;
53
+ try {
54
+ const res = await fetch(`${spectraUrl}/api/v1/runs/trigger`, {
55
+ method: 'POST',
56
+ headers: {
57
+ 'Content-Type': 'application/json',
58
+ Authorization: `Bearer ${spectraToken}`,
59
+ },
60
+ body: JSON.stringify({
61
+ project_id: options.project,
62
+ project_name: options.projectName ?? options.project,
63
+ branch,
64
+ commit_hash: commit,
65
+ platform,
66
+ demo: options.demo ?? false,
67
+ base_url: options.baseUrl,
68
+ }),
69
+ });
70
+ if (!res.ok) {
71
+ const body = await res.json().catch(() => ({ error: res.statusText }));
72
+ throw new Error(body.error ?? `HTTP ${res.status}`);
73
+ }
74
+ trigger = (await res.json());
75
+ }
76
+ catch (err) {
77
+ spinner.fail(chalk.red(`Failed to trigger cloud run: ${err.message}`));
78
+ process.exitCode = 1;
79
+ return;
80
+ }
81
+ spinner.succeed(`Cloud run queued: ${chalk.cyan(trigger.job_id)}`);
82
+ if (!wait) {
83
+ console.log(chalk.gray(`Not waiting for result. Poll ${spectraUrl}/api/v1/jobs/${trigger.job_id}/status yourself.`));
84
+ return;
85
+ }
86
+ await waitForJob(spectraUrl, spectraToken, trigger.job_id, timeout);
87
+ }
88
+ async function waitForJob(spectraUrl, spectraToken, jobId, timeout) {
89
+ const spinner = ora('Waiting for run to finish...').start();
90
+ const deadline = Date.now() + parseTimeoutMs(timeout);
91
+ while (Date.now() < deadline) {
92
+ await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
93
+ let job;
94
+ try {
95
+ const res = await fetch(`${spectraUrl}/api/v1/jobs/${jobId}/status`, {
96
+ headers: { Authorization: `Bearer ${spectraToken}` },
97
+ });
98
+ if (!res.ok)
99
+ throw new Error(`HTTP ${res.status}`);
100
+ job = (await res.json());
101
+ }
102
+ catch (err) {
103
+ spinner.text = chalk.yellow(`Poll failed, retrying: ${err.message}`);
104
+ continue;
105
+ }
106
+ if (job.status === 'done') {
107
+ spinner.succeed(chalk.green(`Run passed (run_id: ${job.run_id})`));
108
+ process.exitCode = 0;
109
+ return;
110
+ }
111
+ if (job.status === 'failed') {
112
+ spinner.fail(chalk.red(`Run failed (run_id: ${job.run_id})${job.error_log ? `: ${job.error_log}` : ''}`));
113
+ process.exitCode = 1;
114
+ return;
115
+ }
116
+ spinner.text = `Run status: ${job.status}`;
117
+ }
118
+ spinner.fail(chalk.red(`Timed out after ${timeout} waiting for the run to finish.`));
119
+ process.exitCode = 1;
120
+ }
@@ -15,7 +15,7 @@ import { runSystemChecks } from './doctor.js';
15
15
  async function resolveBrowserExecutable(cwd) {
16
16
  try {
17
17
  const { dependencies } = await runSystemChecks(cwd);
18
- const chrome = dependencies.find((d) => d.category === 'web' && (d.name.includes('Chrome') || d.name.includes('Chromium')));
18
+ const chrome = dependencies.find((d) => d.category === 'web' && d.name.includes('Chrome'));
19
19
  return chrome?.installed && chrome.location ? chrome.location : null;
20
20
  }
21
21
  catch {
@@ -81,9 +81,11 @@ export async function testCommand(options = {}) {
81
81
  concurrency: concurrency !== undefined && !isNaN(concurrency) ? concurrency : undefined,
82
82
  browserExecutablePath,
83
83
  onLine: (line) => {
84
- if (!line.startsWith('[TESTSPECTRA_'))
84
+ // Strip ANSI and whitespace since Vitest might prefix or colorize console logs
85
+ const cleanLine = line.replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, '').trim();
86
+ if (!cleanLine.startsWith('[TESTSPECTRA_'))
85
87
  return;
86
- reporter.addLog({ timestamp: new Date().toLocaleTimeString(), level: 'INFO', message: line });
88
+ reporter.addLog({ timestamp: new Date().toLocaleTimeString(), level: 'INFO', message: cleanLine });
87
89
  if (!reporter.isInteractiveMode()) {
88
90
  console.log(line);
89
91
  }
@@ -41,7 +41,8 @@ describe('Platform TSConfigs & Type Isolation (docs/v2/cli/tsconfig-platform-iso
41
41
  expect(rootConfig.references).toEqual([{ path: './.testspectra/tsconfig.json' }]);
42
42
  // Verify dynamic master solution references all 5 platform solution tsconfigs in .testspectra/tsconfig/
43
43
  const solutionConfig = JSON.parse(fs.readFileSync(path.join(tmpDir, '.testspectra', 'tsconfig.json'), 'utf-8'));
44
- expect(solutionConfig.compilerOptions.noEmit).toBe(true);
44
+ expect(solutionConfig.compilerOptions.composite).toBe(true);
45
+ expect(solutionConfig.compilerOptions.noEmit).toBeUndefined();
45
46
  expect(solutionConfig.references).toEqual([
46
47
  { path: './tsconfig/web.json' },
47
48
  { path: './tsconfig/mobile.json' },
@@ -16,7 +16,7 @@ export class TsConfigGenerator {
16
16
  moduleResolution: 'NodeNext',
17
17
  skipLibCheck: true,
18
18
  strict: true,
19
- noEmit: true,
19
+ composite: true,
20
20
  },
21
21
  files: [],
22
22
  references: references || [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@testspectra/cli",
3
- "version": "1.1.8-rc.13",
3
+ "version": "1.1.8-rc.16",
4
4
  "description": "TestSpectra Zero-Config Cross-Platform Test Runner CLI",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -26,9 +26,9 @@
26
26
  ],
27
27
  "dependencies": {
28
28
  "@clack/prompts": "^1.7.0",
29
- "@testspectra/matchers": "^1.1.8-rc.13",
30
- "@testspectra/react": "^1.1.8-rc.13",
31
- "@testspectra/skills": "^1.1.8-rc.13",
29
+ "@testspectra/matchers": "^1.1.8-rc.16",
30
+ "@testspectra/react": "^1.1.8-rc.16",
31
+ "@testspectra/skills": "^1.1.8-rc.16",
32
32
  "chalk": "^5.3.0",
33
33
  "commander": "^12.1.0",
34
34
  "dotenv": "^16.4.5",
@@ -38,10 +38,10 @@
38
38
  "zod": "^3.23.8"
39
39
  },
40
40
  "optionalDependencies": {
41
- "@testspectra/cli-darwin-arm64": "1.1.8-rc.13",
42
- "@testspectra/cli-linux-x64": "1.1.8-rc.13",
43
- "@testspectra/cli-win32-arm64": "1.1.8-rc.13",
44
- "@testspectra/cli-win32-x64": "1.1.8-rc.13"
41
+ "@testspectra/cli-darwin-arm64": "1.1.8-rc.16",
42
+ "@testspectra/cli-linux-x64": "1.1.8-rc.16",
43
+ "@testspectra/cli-win32-arm64": "1.1.8-rc.16",
44
+ "@testspectra/cli-win32-x64": "1.1.8-rc.16"
45
45
  },
46
46
  "devDependencies": {
47
47
  "@types/node": "^20.14.0",
@@ -17,8 +17,8 @@
17
17
  "postinstall": "spectra sync-types"
18
18
  },
19
19
  "devDependencies": {
20
- "@testspectra/cli": "^1.1.8-rc.13",
21
- "@testspectra/matchers": "^1.1.8-rc.13",
20
+ "@testspectra/cli": "^1.1.8-rc.16",
21
+ "@testspectra/matchers": "^1.1.8-rc.16",
22
22
  "@types/node": "^20.14.0",
23
23
  "@wdio/cli": "^9.2.8",
24
24
  "@wdio/local-runner": "^9.2.8",