@testspectra/cli 1.1.8-rc.24 → 1.1.8-rc.25

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
+ }
@@ -43,11 +43,13 @@ function detectPackageManager(cwd) {
43
43
  return 'bun';
44
44
  return 'npm';
45
45
  }
46
- function buildInstallCmd(pm, pkgSpecs) {
46
+ function buildInstallCmd(pm, pkgSpecs, cwd) {
47
47
  const list = pkgSpecs.join(' ');
48
48
  switch (pm) {
49
- case 'pnpm':
50
- return `pnpm add -D ${list}`;
49
+ case 'pnpm': {
50
+ const isWorkspaceRoot = fs.existsSync(path.join(cwd, 'pnpm-workspace.yaml'));
51
+ return isWorkspaceRoot ? `pnpm add -D -w ${list}` : `pnpm add -D ${list}`;
52
+ }
51
53
  case 'bun':
52
54
  return `bun add -d ${list}`;
53
55
  case 'npm':
@@ -84,7 +86,7 @@ export async function upgradeCommand(options = {}) {
84
86
  }
85
87
  else if (toUpdate.length > 0) {
86
88
  const pkgSpecs = toUpdate.map((u) => `${u.pkg}@${u.latest}`);
87
- const installCmd = buildInstallCmd(pm, pkgSpecs);
89
+ const installCmd = buildInstallCmd(pm, pkgSpecs, cwd);
88
90
  console.log(`\x1b[36m│\x1b[0m Running: \x1b[90m${installCmd}\x1b[0m`);
89
91
  try {
90
92
  execSync(installCmd, { cwd, stdio: 'inherit' });
@@ -120,8 +122,8 @@ export async function upgradeCommand(options = {}) {
120
122
  env: {
121
123
  ...process.env,
122
124
  TEST_SPECTRA_DRIVER_CACHE: globalDriversDir,
123
- TEST_SPECTRA_FORCE_LATEST: '1'
124
- }
125
+ TEST_SPECTRA_FORCE_LATEST: '1',
126
+ },
125
127
  });
126
128
  console.log('\x1b[36m│\x1b[0m \x1b[32m✓ Drivers updated successfully.\x1b[0m');
127
129
  }
@@ -64,9 +64,7 @@ export function generateArchitectureDoc(cwd) {
64
64
  }
65
65
  const sharedScope = scopes.find((s) => s.isShared);
66
66
  const featureE2eDirs = scopes.filter((s) => !s.isShared).map((s) => s.dir);
67
- const sharedTestingRelPath = sharedScope
68
- ? path.relative(cwd, sharedScope.dir).replace(/\\/g, '/')
69
- : 'shared/testing';
67
+ const sharedTestingRelPath = sharedScope ? path.relative(cwd, sharedScope.dir).replace(/\\/g, '/') : 'shared/testing';
70
68
  const e2eFolderName = featureE2eDirs.length > 0 ? path.basename(featureE2eDirs[0]) : 'e2e';
71
69
  const content = renderArchitectureDoc(path.join(docsTemplateDir, 'ARCHITECTURE.nx.md'), {
72
70
  __SHARED_TESTING_REL_PATH__: sharedTestingRelPath,
package/dist/index.js CHANGED
@@ -25,7 +25,7 @@ export * from './types/generator.js';
25
25
  export * from '@testspectra/matchers';
26
26
  export function createCliProgram() {
27
27
  const program = new Command();
28
- program.name('spectra').description('TestSpectra Zero-Config Cross-Platform Test Runner CLI').version('1.0.0');
28
+ program.name('spectra').description('TestSpectra Cross-Platform Test Runner CLI').version('1.0.0');
29
29
  program
30
30
  .command('init')
31
31
  .description('Initialize testspectra.config.json and project directories')
@@ -16,10 +16,11 @@ function platformPackageName() {
16
16
  return '@testspectra/cli-darwin-arm64';
17
17
  if (platform === 'linux' && arch === 'x64')
18
18
  return '@testspectra/cli-linux-x64';
19
- if (platform === 'win32' && arch === 'arm64')
20
- return '@testspectra/cli-win32-arm64';
21
19
  if (platform === 'win32' && arch === 'x64')
22
20
  return '@testspectra/cli-win32-x64';
21
+ // win32/arm64 has no published native binary (scripts/release-cli-local.sh no longer builds
22
+ // it) — falls through to `pkgName === null` below, which throws a clear "not currently
23
+ // supported" error instead of pointing at an optional dependency that doesn't exist.
23
24
  return null;
24
25
  }
25
26
  export class RustCoreBridge {
@@ -43,7 +43,7 @@ async function run() {
43
43
  const cacheData = {
44
44
  timestamp: Date.now(),
45
45
  latestVersions: updates,
46
- updatesAvailable: updates // Main thread will filter this properly later or we just mock it for architecture
46
+ updatesAvailable: updates, // Main thread will filter this properly later or we just mock it for architecture
47
47
  };
48
48
  fs.writeFileSync(cachePath, JSON.stringify(cacheData, null, 2), 'utf-8');
49
49
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@testspectra/cli",
3
- "version": "1.1.8-rc.24",
4
- "description": "TestSpectra Zero-Config Cross-Platform Test Runner CLI",
3
+ "version": "1.1.8-rc.25",
4
+ "description": "TestSpectra Cross-Platform Test Runner CLI",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
7
7
  "exports": {
@@ -26,9 +26,9 @@
26
26
  ],
27
27
  "dependencies": {
28
28
  "@clack/prompts": "^1.7.0",
29
- "@testspectra/matchers": "^1.1.8-rc.24",
30
- "@testspectra/react": "^1.1.8-rc.24",
31
- "@testspectra/skills": "^1.1.8-rc.24",
29
+ "@testspectra/matchers": "^1.1.8-rc.25",
30
+ "@testspectra/react": "^1.1.8-rc.25",
31
+ "@testspectra/skills": "^1.1.8-rc.25",
32
32
  "chalk": "^5.3.0",
33
33
  "commander": "^12.1.0",
34
34
  "dotenv": "^16.4.5",
@@ -38,10 +38,9 @@
38
38
  "zod": "^3.23.8"
39
39
  },
40
40
  "optionalDependencies": {
41
- "@testspectra/cli-darwin-arm64": "1.1.8-rc.24",
42
- "@testspectra/cli-linux-x64": "1.1.8-rc.24",
43
- "@testspectra/cli-win32-arm64": "1.1.8-rc.24",
44
- "@testspectra/cli-win32-x64": "1.1.8-rc.24"
41
+ "@testspectra/cli-darwin-arm64": "1.1.8-rc.25",
42
+ "@testspectra/cli-linux-x64": "1.1.8-rc.25",
43
+ "@testspectra/cli-win32-x64": "1.1.8-rc.25"
45
44
  },
46
45
  "devDependencies": {
47
46
  "@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.24",
21
- "@testspectra/matchers": "^1.1.8-rc.24",
20
+ "@testspectra/cli": "^1.1.8-rc.25",
21
+ "@testspectra/matchers": "^1.1.8-rc.25",
22
22
  "@types/node": "^20.14.0",
23
23
  "@wdio/cli": "^9.2.8",
24
24
  "@wdio/local-runner": "^9.2.8",