@testspectra/cli 1.2.23 → 1.3.0

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,12 @@
1
+ /**
2
+ * `spectra diagnose --spec <id>`: tells a timing failure from a real one. It runs the spec; on a
3
+ * failure it reruns it with a wait after one step at a time (`TESTSPECTRA_DELAY_STEPS`), closest to
4
+ * the failure first. The answer names the step the test must wait after, for the state the app
5
+ * reaches there: a `should*` assertion, never a sleep.
6
+ */
7
+ export interface DiagnoseOptions {
8
+ spec?: string;
9
+ delay?: string;
10
+ target?: string;
11
+ }
12
+ export declare function diagnoseCommand(options: DiagnoseOptions): Promise<void>;
@@ -0,0 +1,95 @@
1
+ /**
2
+ * `spectra diagnose --spec <id>`: tells a timing failure from a real one. It runs the spec; on a
3
+ * failure it reruns it with a wait after one step at a time (`TESTSPECTRA_DELAY_STEPS`), closest to
4
+ * the failure first. The answer names the step the test must wait after, for the state the app
5
+ * reaches there: a `should*` assertion, never a sleep.
6
+ */
7
+ import { spawnSync } from 'node:child_process';
8
+ function describeTarget(target) {
9
+ if (target === undefined || target === null)
10
+ return '';
11
+ if (typeof target === 'string')
12
+ return target;
13
+ const scoped = target;
14
+ return scoped.parent ? `${scoped.selector} within ${describeTarget(scoped.parent)}` : String(scoped.selector ?? '');
15
+ }
16
+ function runOnce(spec, target, delayMs, delaySteps) {
17
+ const args = [process.argv[1] ?? '', 'run', '--spec', spec, '--target', target, '--no-video'];
18
+ if (delayMs > 0)
19
+ args.push('--delay', String(delayMs));
20
+ const result = spawnSync(process.argv[0] ?? 'node', args, {
21
+ encoding: 'utf8',
22
+ env: { ...process.env, ...(delaySteps ? { TESTSPECTRA_DELAY_STEPS: delaySteps } : {}), CI: '1' },
23
+ maxBuffer: 256 * 1024 * 1024,
24
+ });
25
+ const output = `${result.stdout ?? ''}\n${result.stderr ?? ''}`.replace(/\x1b\[[0-9;]*m/g, '');
26
+ const steps = new Map();
27
+ let error = null;
28
+ for (const line of output.split('\n')) {
29
+ const step = /^\[TESTSPECTRA_STEP_(?:PASS|FAIL)\] (\{.*\}) \(\d+ms\)$/.exec(line.trim());
30
+ if (step) {
31
+ try {
32
+ const payload = JSON.parse(step[1] ?? '{}');
33
+ // What the step acted on, as the page saw it (`<button aria-label="Close dialog">`), else its selector.
34
+ const target = payload.resolved ?? describeTarget(payload.target);
35
+ if (typeof payload.step === 'number')
36
+ steps.set(payload.step, `${payload.key} ${target}`.trim());
37
+ }
38
+ catch { }
39
+ }
40
+ if (!error && line.startsWith('[TESTSPECTRA_TEST_ERROR]'))
41
+ error = line.replace('[TESTSPECTRA_TEST_ERROR]', '').trim();
42
+ }
43
+ const passed = /\[TESTSPECTRA_TEST_PASS\]/.test(output) && !/\[TESTSPECTRA_TEST_FAIL\]/.test(output);
44
+ return { passed, steps, error };
45
+ }
46
+ export async function diagnoseCommand(options) {
47
+ const spec = options.spec;
48
+ if (!spec) {
49
+ console.error('spectra diagnose needs --spec <id>');
50
+ process.exitCode = 1;
51
+ return;
52
+ }
53
+ const target = options.target ?? 'web';
54
+ const delay = Number(options.delay ?? 1000);
55
+ const say = (text) => console.log(`[diagnose] ${text}`);
56
+ const label = (outcome, step) => outcome.steps.get(step) ?? `step ${step}`;
57
+ say(`running ${spec} as is…`);
58
+ const plain = runOnce(spec, target, 0);
59
+ if (plain.passed) {
60
+ say('passes without a delay: nothing to diagnose (run it a few times if it fails only sometimes).');
61
+ return;
62
+ }
63
+ say(`fails: ${plain.error ?? 'see its output'}`);
64
+ const failing = Math.max(0, ...plain.steps.keys());
65
+ if (failing < 2) {
66
+ say('fails before its second step: not a timing failure between steps.');
67
+ process.exitCode = 1;
68
+ return;
69
+ }
70
+ // A delay after every step also outwaits the short states a spec checks on purpose ("Checking
71
+ // availability…"), so the steps are tried one at a time, closest to the failure first.
72
+ const earliest = Math.max(1, failing - 10);
73
+ say(`last step before the failure: #${failing} ${label(plain, failing)}. Trying a ${delay}ms wait after one step at a time…`);
74
+ for (let step = failing - 1; step >= earliest; step -= 1) {
75
+ const outcome = runOnce(spec, target, delay, String(step));
76
+ say(` wait after #${step} ${label(plain, step)}: ${outcome.passed ? 'passes' : 'fails'}`);
77
+ if (outcome.passed) {
78
+ say(`the test needs time after step #${step}: ${label(plain, step)}`);
79
+ if (plain.steps.get(step + 1))
80
+ say(`before step #${step + 1}: ${label(plain, step + 1)}`);
81
+ say('wait there for the state the app reaches (a pending check ending, a submit settling, a dialog closing) ' +
82
+ 'with a should* assertion — never a sleep.');
83
+ process.exitCode = 1;
84
+ return;
85
+ }
86
+ }
87
+ const range = runOnce(spec, target, delay, `${earliest}-${failing - 1}`);
88
+ if (range.passed) {
89
+ say(`no single wait is enough, but waits after steps #${earliest}-#${failing - 1} are: more than one step races there.`);
90
+ }
91
+ else {
92
+ say(`waits before the failing step do not help: not a timing failure. Read the failure context of: spectra run --spec ${spec}`);
93
+ }
94
+ process.exitCode = 1;
95
+ }
@@ -8,6 +8,7 @@ import { TypeGenerator } from '../types/generator.js';
8
8
  import { DemoServer, DEFAULT_DEMO_PORT } from '../utils/demo-server.js';
9
9
  import { DemoApiServer } from '../utils/api-server.js';
10
10
  import { DevServerManager } from '../utils/dev-server-manager.js';
11
+ import { claimRun, concurrentRunWarning, findActiveRun } from '../utils/run-lock.js';
11
12
  import { DemoStateManager } from '../utils/demo-state.js';
12
13
  function renderSummaryCard(options) {
13
14
  const width = 64;
@@ -286,6 +287,12 @@ export async function runCommand(specPaths, options = {}) {
286
287
  console.log('\x1b[36m[TestSpectra Grid]\x1b[0m Multi-device grid enabled (auto-detecting connected devices)');
287
288
  }
288
289
  console.log(`\x1b[36m[TestSpectra]\x1b[0m Base URL: \x1b[33m${config.webConfig.baseUrl}\x1b[0m\n`);
290
+ if (platform === 'web' && config.webConfig.baseUrl) {
291
+ const active = findActiveRun(config.webConfig.baseUrl);
292
+ if (active)
293
+ console.log(concurrentRunWarning(active));
294
+ claimRun(config.webConfig.baseUrl);
295
+ }
289
296
  const reporter = new Reporter();
290
297
  const targetDevice = options.devices || options.device || (options.allDevices || options.multiDevice ? 'auto' : undefined);
291
298
  const result = await RustCoreBridge.run({
@@ -142,6 +142,14 @@ export interface WebConfig {
142
142
  * @default "5000"
143
143
  */
144
144
  implicitWait: string;
145
+ /**
146
+ * What a test case inherits from the one before it in the same worker.
147
+ * - `'suite'` (default): one browser session per batch, so a suite `before` hook can log in once;
148
+ * cookies, localStorage and sessionStorage carry over, and the runner names what carried over.
149
+ * - `'testcase'`: each case starts with empty cookies and storage.
150
+ * @default "suite"
151
+ */
152
+ isolation?: 'suite' | 'testcase';
145
153
  /**
146
154
  * Delay between consecutive actions and commands in milliseconds (slow motion).
147
155
  * @default 0
package/dist/index.js CHANGED
@@ -4,6 +4,7 @@ import { demoCommand } from './commands/demo.js';
4
4
  import { DEFAULT_DEMO_PORT } from './utils/demo-server.js';
5
5
  import { devicesCommand } from './commands/devices.js';
6
6
  import { doctorCommand } from './commands/doctor.js';
7
+ import { diagnoseCommand } from './commands/diagnose.js';
7
8
  import { initCommand } from './commands/init.js';
8
9
  import { installCommand } from './commands/install.js';
9
10
  import { runCommand } from './commands/run.js';
@@ -87,6 +88,13 @@ Examples:
87
88
  .option('--strict', 'Treat warnings as errors and exit with code 1')
88
89
  .option('--json', 'Output diagnostic results as JSON')
89
90
  .action(lintCommand);
91
+ program
92
+ .command('diagnose')
93
+ .description('Tell a timing failure from a real one: rerun a failing spec with a step delay and bisect the step it must wait after')
94
+ .option('-s, --spec <path>', 'Spec to diagnose')
95
+ .option('-t, --target <target>', 'Target platform', 'web')
96
+ .option('--delay <ms>', 'Wait to try after a step', '1000')
97
+ .action(diagnoseCommand);
90
98
  program
91
99
  .command('doctor')
92
100
  .description('Verify local environment prerequisites (Bun, Git, ADB, Chrome, Spectra Android Agent, workspace packages)')
@@ -165,7 +165,16 @@ export class RustCoreBridge {
165
165
  // via a pipe) — in that case this raw line is the *only* live signal a wrapping process
166
166
  // can see, so it must still reach stdout verbatim.
167
167
  // Always forward inspector websocket URL so IDE tools can attach directly.
168
- if (line.startsWith('[TESTSPECTRA_DEBUG_INSPECTOR]') || !reporter.isInteractiveMode()) {
168
+ // Network entries are one JSON line per request: in a plain log they bury the steps
169
+ // (an agent reading the tail sees only them). They always reach network-resources.json;
170
+ // a wrapper that streams them (the VS Code extension) asks with TESTSPECTRA_STREAM_NETWORK=1.
171
+ const isNetwork = line.startsWith('[TESTSPECTRA_NETWORK]');
172
+ const streamNetwork = process.env.TESTSPECTRA_STREAM_NETWORK === '1';
173
+ // Warnings name a cause (an input storm, a busy page, a mock that matched nothing): shown in every mode.
174
+ const isWarning = line.startsWith('[TESTSPECTRA_WARNING]');
175
+ if (line.startsWith('[TESTSPECTRA_DEBUG_INSPECTOR]') ||
176
+ isWarning ||
177
+ (!reporter.isInteractiveMode() && (!isNetwork || streamNetwork))) {
169
178
  console.log(line);
170
179
  }
171
180
  continue;
@@ -0,0 +1,18 @@
1
+ /**
2
+ * One run per server: two `spectra run`s against the same base URL share its data (one resets the
3
+ * mock while the other is mid-test) and fail in ways neither run explains. Each run records itself
4
+ * in a lock file named after its base URL; a second run warns, naming the first.
5
+ */
6
+ interface LockInfo {
7
+ pid: number;
8
+ cwd: string;
9
+ startedAt: string;
10
+ baseUrl: string;
11
+ }
12
+ /** The run already holding `baseUrl`, if one is still alive. */
13
+ export declare function findActiveRun(baseUrl: string): LockInfo | null;
14
+ /** Claims `baseUrl` for this run; the returned function releases it (also on exit). */
15
+ export declare function claimRun(baseUrl: string): () => void;
16
+ /** The warning a second run prints. */
17
+ export declare function concurrentRunWarning(active: LockInfo): string;
18
+ export {};
@@ -0,0 +1,59 @@
1
+ /**
2
+ * One run per server: two `spectra run`s against the same base URL share its data (one resets the
3
+ * mock while the other is mid-test) and fail in ways neither run explains. Each run records itself
4
+ * in a lock file named after its base URL; a second run warns, naming the first.
5
+ */
6
+ import { createHash } from 'node:crypto';
7
+ import fs from 'node:fs';
8
+ import os from 'node:os';
9
+ import path from 'node:path';
10
+ function lockPath(baseUrl) {
11
+ const id = createHash('sha1').update(baseUrl.replace(/\/+$/, '').toLowerCase()).digest('hex').slice(0, 16);
12
+ return path.join(os.tmpdir(), 'testspectra-locks', `${id}.json`);
13
+ }
14
+ function isAlive(pid) {
15
+ try {
16
+ process.kill(pid, 0);
17
+ return true;
18
+ }
19
+ catch (error) {
20
+ return error.code === 'EPERM';
21
+ }
22
+ }
23
+ /** The run already holding `baseUrl`, if one is still alive. */
24
+ export function findActiveRun(baseUrl) {
25
+ try {
26
+ const info = JSON.parse(fs.readFileSync(lockPath(baseUrl), 'utf8'));
27
+ return info.pid !== process.pid && isAlive(info.pid) ? info : null;
28
+ }
29
+ catch {
30
+ return null;
31
+ }
32
+ }
33
+ /** Claims `baseUrl` for this run; the returned function releases it (also on exit). */
34
+ export function claimRun(baseUrl) {
35
+ const file = lockPath(baseUrl);
36
+ const info = { pid: process.pid, cwd: process.cwd(), startedAt: new Date().toISOString(), baseUrl };
37
+ try {
38
+ fs.mkdirSync(path.dirname(file), { recursive: true });
39
+ fs.writeFileSync(file, JSON.stringify(info));
40
+ }
41
+ catch {
42
+ return () => undefined;
43
+ }
44
+ const release = () => {
45
+ try {
46
+ const current = JSON.parse(fs.readFileSync(file, 'utf8'));
47
+ if (current.pid === process.pid)
48
+ fs.unlinkSync(file);
49
+ }
50
+ catch { }
51
+ };
52
+ process.once('exit', release);
53
+ return release;
54
+ }
55
+ /** The warning a second run prints. */
56
+ export function concurrentRunWarning(active) {
57
+ return (`[TESTSPECTRA_WARNING] ⚠ another spectra run (pid ${active.pid}, ${active.cwd}, since ${active.startedAt}) ` +
58
+ `is testing ${active.baseUrl}: runs sharing one server reset and change each other's data — run one at a time`);
59
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@testspectra/cli",
3
- "version": "1.2.23",
3
+ "version": "1.3.0",
4
4
  "description": "TestSpectra Cross-Platform Test Runner CLI",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -25,11 +25,11 @@
25
25
  ],
26
26
  "dependencies": {
27
27
  "@clack/prompts": "^1.7.0",
28
- "@testspectra/matchers": "^1.2.23",
29
- "@testspectra/react": "^1.2.23",
30
- "@testspectra/react-native": "^1.2.23",
31
- "@testspectra/reporter": "^1.2.23",
32
- "@testspectra/skills": "^1.2.23",
28
+ "@testspectra/matchers": "^1.3.0",
29
+ "@testspectra/react": "^1.3.0",
30
+ "@testspectra/react-native": "^1.3.0",
31
+ "@testspectra/reporter": "^1.3.0",
32
+ "@testspectra/skills": "^1.3.0",
33
33
  "chalk": "^5.3.0",
34
34
  "commander": "^12.1.0",
35
35
  "cross-spawn": "^7.0.6",
@@ -40,9 +40,9 @@
40
40
  "zod": "^3.23.8"
41
41
  },
42
42
  "optionalDependencies": {
43
- "@testspectra/cli-darwin-arm64": "1.2.23",
44
- "@testspectra/cli-linux-x64": "1.2.23",
45
- "@testspectra/cli-win32-x64": "1.2.23"
43
+ "@testspectra/cli-darwin-arm64": "1.3.0",
44
+ "@testspectra/cli-linux-x64": "1.3.0",
45
+ "@testspectra/cli-win32-x64": "1.3.0"
46
46
  },
47
47
  "devDependencies": {
48
48
  "@types/cross-spawn": "^6.0.6",
@@ -17,9 +17,9 @@
17
17
  "postinstall": "spectra sync-types"
18
18
  },
19
19
  "devDependencies": {
20
- "@testspectra/cli": "^1.2.23",
21
- "@testspectra/matchers": "^1.2.23",
22
- "@testspectra/plugin-demo": "^1.2.23",
20
+ "@testspectra/cli": "^1.3.0",
21
+ "@testspectra/matchers": "^1.3.0",
22
+ "@testspectra/plugin-demo": "^1.3.0",
23
23
  "@types/node": "^20.14.0",
24
24
  "@wdio/cli": "^9.2.8",
25
25
  "@wdio/local-runner": "^9.2.8",
@@ -12,8 +12,8 @@
12
12
  "postinstall": "spectra sync-types"
13
13
  },
14
14
  "devDependencies": {
15
- "@testspectra/cli": "^1.2.23",
16
- "@testspectra/matchers": "^1.2.23",
15
+ "@testspectra/cli": "^1.3.0",
16
+ "@testspectra/matchers": "^1.3.0",
17
17
  "@types/node": "^20.14.0",
18
18
  "@wdio/cli": "^9.2.8",
19
19
  "@wdio/local-runner": "^9.2.8",
@@ -14,9 +14,9 @@
14
14
  "postinstall": "spectra sync-types"
15
15
  },
16
16
  "devDependencies": {
17
- "@testspectra/cli": "^1.2.23",
18
- "@testspectra/matchers": "^1.2.23",
19
- "@testspectra/react": "^1.2.23",
17
+ "@testspectra/cli": "^1.3.0",
18
+ "@testspectra/matchers": "^1.3.0",
19
+ "@testspectra/react": "^1.3.0",
20
20
  "@types/node": "^20.14.0",
21
21
  "@types/react": "^18.3.3",
22
22
  "@types/react-dom": "^18.3.0",
@@ -12,9 +12,9 @@
12
12
  "postinstall": "spectra sync-types"
13
13
  },
14
14
  "devDependencies": {
15
- "@testspectra/cli": "^1.2.23",
16
- "@testspectra/matchers": "^1.2.23",
17
- "@testspectra/react-native": "^1.2.23",
15
+ "@testspectra/cli": "^1.3.0",
16
+ "@testspectra/matchers": "^1.3.0",
17
+ "@testspectra/react-native": "^1.3.0",
18
18
  "@types/node": "^20.14.0",
19
19
  "@types/react": "^18.3.12",
20
20
  "@types/react-native": "^0.73.0",