diff --git a/packages/browserstack-service/src/cli/frameworks/wdioMochaTestFramework.ts b/packages/browserstack-service/src/cli/frameworks/wdioMochaTestFramework.ts index 3e9ce59..4c384b1 100644 --- a/packages/browserstack-service/src/cli/frameworks/wdioMochaTestFramework.ts +++ b/packages/browserstack-service/src/cli/frameworks/wdioMochaTestFramework.ts @@ -44,7 +44,10 @@ export default class WdioMochaTestFramework extends TestFramework { } try { - if (CLIUtils.matchHookRegex(testFrameworkState.toString()) && hookState === HookState.PRE) { + // HOOK_REGEX is anchored (^BEFORE_|^AFTER_) — match the short state name; the + // fully-qualified `TestFrameworkState.BEFORE_ALL` never matches, so hook ids were + // never minted and hooks reached TRA with an empty uuid (dropped at ingestion). + if (CLIUtils.matchHookRegex(testFrameworkState.toString().split('.')[1]) && hookState === HookState.PRE) { instance.updateMultipleEntries({ [TestFrameworkConstants.KEY_HOOK_ID]: uuidv4(), }) @@ -93,13 +96,49 @@ export default class WdioMochaTestFramework extends TestFramework { * @returns {TestFrameworkInstance} */ resolveInstance(testFrameworkState: State, hookState: State, args: Record = {}): TestFrameworkInstance|null { - let instance = null logger.info(`resolveInstance: resolving instance for testFrameworkState=${testFrameworkState} hookState=${hookState}`) - if (testFrameworkState === TestFrameworkState.INIT_TEST || testFrameworkState === TestFrameworkState.NONE) { + const shortState = testFrameworkState.toString().split('.')[1] + const isHook = CLIUtils.matchHookRegex(shortState) + let instance = TestFramework.getTrackedInstance() + + // Whether the current tracked instance has already run its test body. + const hasRunTest = !!(instance && TestFramework.getState(instance, TestFrameworkConstants.KEY_TEST_ID)) + + // New-test boundary (ports Junit5Framework.resolveInstance): the previous method was a + // test / after-hook (POST) and the current is a before-hook / init (PRE) — the next test + // is starting. At entry, getCurrentTestState()/getCurrentHookState() still hold the PREVIOUS + // method's state (updateInstanceState has not run yet). + const prevState = instance ? instance.getCurrentTestState().toString() : '' + const prevWasTerminal = instance ? (instance.getCurrentTestState() === TestFrameworkState.TEST || prevState.includes('AFTER')) : false + const isNewTestBoundary = instance ? (prevWasTerminal && instance.getCurrentHookState() === HookState.POST && hookState === HookState.PRE) : false + + if (testFrameworkState === TestFrameworkState.NONE) { this.trackWdioMochaInstance(testFrameworkState, args) + } else if (testFrameworkState === TestFrameworkState.INIT_TEST) { + // WDIO fires `before each` BEFORE `beforeTest` (INIT_TEST). Reuse the instance a + // preceding before-hook already opened for this same upcoming test; only start a new + // one if the current instance has already run a test (or none exists). + if (!instance || hasRunTest) { + this.trackWdioMochaInstance(testFrameworkState, args) + } + } else if (isHook && hookState === HookState.PRE) { + // Suite-level (`before all`) and the first `before each` fire before any INIT_TEST, so + // no instance exists yet — create one. Also, a BEFORE hook right after a completed test + // (new-test boundary) starts a new test. The boundary restriction must be limited to + // BEFORE hooks: `after each`/`after all` also fire at a POST->PRE boundary (afterTest + // emits TEST POST just before `after each`), but they belong to the just-finished test. + // Creating a fresh (uuid-less) instance for them would drop test_run_id and orphan the + // hook from TestRunFinished.hooks — so let after-hooks reuse the finished test's instance. + if (!instance || (isNewTestBoundary && shortState.startsWith('BEFORE_'))) { + this.trackWdioMochaInstance(testFrameworkState, args) + } } instance = TestFramework.getTrackedInstance() + if (!instance) { + logger.error(`resolveInstance: unable to resolve/create instance for testFrameworkState=${testFrameworkState} hookState=${hookState}`) + return null + } this.updateInstanceState(instance, testFrameworkState, hookState) return instance @@ -167,14 +206,18 @@ export default class WdioMochaTestFramework extends TestFramework { } loadTestResult(instance: TestFrameworkInstance, args: Record) { - const results = args.result as Frameworks.TestResult - const { error, passed } = results + const results = args.result as Frameworks.TestResult & { skipped?: boolean } + const { error, passed, skipped } = results let result = 'passed' let failure: Array|null = null let failureReason: string|null = null let failureType: string|null = null if (!passed) { - result = (error && error.message && error.message.includes('sync skip; aborting execution')) ? 'ignore' : 'failed' + if (skipped) { + result = 'skipped' + } else { + result = (error && error.message && error.message.includes('sync skip; aborting execution')) ? 'ignore' : 'failed' + } if (error && result !== 'skipped') { failure = [{ backtrace: [removeAnsiColors(error.message), removeAnsiColors(error.stack || '')] }] // add all errors here failureReason = removeAnsiColors(error.message) @@ -319,7 +362,11 @@ export default class WdioMochaTestFramework extends TestFramework { ) { const testResult = args.result as Frameworks.TestResult const test = args.test as Frameworks.Test - const key = testFrameworkState.toString() + // Key hooks by the short state name (e.g. BEFORE_ALL), matching how the binary looks + // them up via `event.test_hooks_started[request.testFrameworkState]`. `toString()` yields + // the fully-qualified `TestFrameworkState.BEFORE_ALL`, which never matches — hook finishes + // were dropped with "unable to determine hook-finished". + const key = testFrameworkState.toString().split('.')[1] const hooksStarted = TestFramework.getState(instance, TestFrameworkConstants.KEY_HOOKS_STARTED) as Map if (!hooksStarted.has(key)) { @@ -358,7 +405,19 @@ export default class WdioMochaTestFramework extends TestFramework { if (hooksList.length > 0) { const hook = hooksList.pop() as Record - const result = testResult.status + // Frameworks.TestResult carries `passed`/`skipped`, not `status` — reading `.status` + // left hook_result at 'pending', which the binary coerces to 'passed' for any + // finished hook, so failed before-hooks showed green builds while CI exited 1. + // A this.skip() hook arrives as {passed: false, skipped: true} — a deliberate + // skip, not a failure. + // wdio v8's TestResult type does not declare `skipped`, but the runtime carries it + // wdio v8 does not set `skipped` for a this.skip() before-all — it surfaces as an + // error carrying mocha's sync-skip marker; treat both shapes as a deliberate skip + const skippedHook = (testResult as Frameworks.TestResult & { skipped?: boolean })?.skipped + || !!testResult?.error?.message?.includes('sync skip; aborting execution') + const result = testResult + ? (testResult.passed ? 'passed' : (skippedHook ? 'skipped' : 'failed')) + : TestFrameworkConstants.DEFAULT_HOOK_RESULT if (result !== TestFrameworkConstants.DEFAULT_HOOK_RESULT) { hook[TestFrameworkConstants.KEY_HOOK_RESULT] = result } diff --git a/packages/browserstack-service/src/cli/modules/testHubModule.ts b/packages/browserstack-service/src/cli/modules/testHubModule.ts index cb1f13c..d3e34a7 100644 --- a/packages/browserstack-service/src/cli/modules/testHubModule.ts +++ b/packages/browserstack-service/src/cli/modules/testHubModule.ts @@ -113,7 +113,10 @@ export default class TestHubModule extends BaseModule { this.logger.debug(`sendTestFrameworkEvent for testState: ${testFrameworkState} hookState: ${testHookState}`) const platformIndex = process.env.WDIO_WORKER_ID ? parseInt(process.env.WDIO_WORKER_ID.split('-')[0]) : 0 const uuid = TestFramework.getState(instance, TestFrameworkConstants.KEY_TEST_UUID) || instance.getRef() - const eventJson = Buffer.from(JSON.stringify(Object.fromEntries(testData))) + // Nested values such as test_hooks_started/test_hooks_finished are JS Maps, which + // JSON.stringify would serialise to `{}` and strip the hook data. Convert any Map to + // a plain object so the binary receives populated hook maps. + const eventJson = Buffer.from(JSON.stringify(Object.fromEntries(testData), (_key, value) => value instanceof Map ? Object.fromEntries(value) : value)) const executionContext = { hash: trackedContext.getId(), threadId: trackedContext.getThreadId().toString(), processId: trackedContext.getProcessId().toString() } const payload: Omit = { platformIndex, diff --git a/packages/browserstack-service/src/cli/skipReporter.ts b/packages/browserstack-service/src/cli/skipReporter.ts new file mode 100644 index 0000000..14bdc56 --- /dev/null +++ b/packages/browserstack-service/src/cli/skipReporter.ts @@ -0,0 +1,96 @@ +import path from 'node:path' + +import type { Frameworks } from '@wdio/types' + +import type TestFramework from './frameworks/testFramework.js' +import { TestFrameworkState } from './states/testFrameworkState.js' +import { HookState } from './states/hookState.js' +import { BStackLogger } from '../bstackLogger.js' + +/** + * Reports tests that never reach the beforeTest/afterTest lifecycle (static `it.skip`, + * `this.skip()` inside before hooks, suites aborted by a failed before hook) through the + * CLI gRPC tracker, so they land on the dashboard and attribute their Automate session. + * Without this, such tests emit no events at all in the CLI flow (the legacy + * Listener -> api/v1/batch path is dead here) and their sessions surface as + * `session_linking_issue_build` in TRA stability. + */ + +interface MochaRuntimeTest { + title: string + state?: string + body?: string + file?: string + parent?: { title?: string, parent?: unknown, tests?: unknown[], suites?: unknown[] } +} + +// tests that entered the CLI beforeTest lifecycle — those report their own finish +// (including runtime `this.skip()` inside a test body) and must not be re-reported +const startedTests = new Set() +const reportedSkips = new Set() +// wdio does not await reporter hooks, so back-to-back skips would interleave on the +// tracker's single mutable per-worker instance — serialize every report through one chain +let reportChain: Promise = Promise.resolve() + +export function markTestStarted(identifier: string) { + startedTests.add(identifier) +} + +export function reportSkippedTest(framework: TestFramework, identifier: string, test: Frameworks.Test, suiteTitle?: string): Promise { + if (startedTests.has(identifier) || reportedSkips.has(identifier)) { + return reportChain + } + reportedSkips.add(identifier) + const result = { passed: false, skipped: true } as unknown as Frameworks.TestResult + reportChain = reportChain.then(async () => { + // LOG_REPORT/POST is what loads the result into the instance (loadTestResult is + // gated on it, not on TEST/POST) — same sequence afterTest uses + await framework.trackEvent(TestFrameworkState.INIT_TEST, HookState.PRE, { test }) + await framework.trackEvent(TestFrameworkState.TEST, HookState.PRE, { test, suiteTitle }) + await framework.trackEvent(TestFrameworkState.LOG_REPORT, HookState.POST, { test, result }) + await framework.trackEvent(TestFrameworkState.TEST, HookState.POST, { test, result, suiteTitle }) + }).catch((err: unknown) => { + BStackLogger.debug(`Failed reporting skipped test '${identifier}': ${err}`) + }) + return reportChain +} + +/** + * Port of the legacy insights-handler skip propagation: when a BEFORE_ALL/BEFORE_EACH/ + * AFTER_EACH hook fails (or skips), mocha silently drops the remaining tests in the + * suite — report each state-undefined test as skipped, recursing into nested describes. + */ +export async function reportSuiteSkipped(framework: TestFramework, suite: { tests?: unknown[], suites?: unknown[] }): Promise { + for (const t of (suite.tests || []) as MochaRuntimeTest[]) { + if (t.state !== undefined) { + continue + } + const parentTitle = t.parent?.title ?? '' + const identifier = `${parentTitle} - ${t.title}` + // keep `parent` a string (the Automate session name interpolates it) and pass the + // real suite chain via ctx for scope/hierarchy extraction; `file` must resolve or + // the binary drops the event on path.relative(cwd, undefined) + const synthetic = { + title: t.title, + parent: parentTitle, + body: t.body || '', + file: t.file, + ctx: { test: { parent: t.parent } } + } as unknown as Frameworks.Test + await reportSkippedTest(framework, identifier, synthetic, parentTitle) + } + for (const sub of (suite.suites || []) as { tests?: unknown[], suites?: unknown[] }[]) { + await reportSuiteSkipped(framework, sub) + } +} + +/** Resolve a spec file path usable by the binary (it rejects events with no location). */ +export function resolveSpecFile(candidate: string | undefined, runnerSpec: string | undefined): string | undefined { + if (candidate) { + return candidate + } + if (runnerSpec) { + return runnerSpec.startsWith('file://') ? runnerSpec.replace(/^file:\/\//, '') : path.resolve(runnerSpec) + } + return undefined +} diff --git a/packages/browserstack-service/src/reporter.ts b/packages/browserstack-service/src/reporter.ts index 736a1a6..903cf33 100644 --- a/packages/browserstack-service/src/reporter.ts +++ b/packages/browserstack-service/src/reporter.ts @@ -2,7 +2,9 @@ import path from 'node:path' import type { SuiteStats, TestStats, RunnerStats, HookStats } from '@wdio/reporter' import WDIOReporter from '@wdio/reporter' -import type { Options } from '@wdio/types' +import type { Options, Frameworks } from '@wdio/types' +import { BrowserstackCLI } from './cli/index.js' +import { reportSkippedTest, resolveSpecFile } from './cli/skipReporter.js' import * as url from 'node:url' import { v4 as uuidv4 } from 'uuid' @@ -35,9 +37,11 @@ class _TestReporter extends WDIOReporter { public static currentTest: CurrentRunInfo = {} private _userCaps?: Capabilities.RemoteCapability = {} private listener = Listener.getInstance() + private _runnerSpec?: string async onRunnerStart (runnerStats: RunnerStats) { this._capabilities = runnerStats.capabilities as WebdriverIO.Capabilities + this._runnerSpec = runnerStats.specs?.[0] this._userCaps = this.getUserCaps(runnerStats) this._config = runnerStats.config as BrowserstackConfig & Options.Testrunner this._sessionId = runnerStats.sessionId @@ -211,6 +215,30 @@ class _TestReporter extends WDIOReporter { return } + // CLI flow: the legacy Listener -> api/v1/batch path below is dead here, so route the + // skipped test through the gRPC tracker instead (static `it.skip` and hook-level skips + // never reach beforeTest/afterTest; without this they are invisible on the dashboard + // and their session is never linked to the build) + if (BrowserstackCLI.getInstance().isRunning()) { + const framework = BrowserstackCLI.getInstance().getTestFramework() + if (framework) { + const stats = testStats as TestStats & { parent?: string, file?: string, body?: string, ctx?: unknown } + const identifier = `${stats.parent} - ${stats.title}` + stats.file = resolveSpecFile(stats.file, this._runnerSpec) + // hierarchy travels via ctx (getMochaTestHierarchy); `parent` stays a string — + // the Automate session name interpolates it directly + let parentChain: { title: string, parent: unknown } | null = null + for (const s of this._suites) { + parentChain = { title: s.title, parent: parentChain } + } + if (parentChain) { + stats.ctx = { test: { parent: parentChain } } + } + await reportSkippedTest(framework, identifier, stats as unknown as Frameworks.Test, this._suites.at(-1)?.title) + } + return + } + testStats.start ||= new Date() testStats.end ||= new Date() this.listener.testFinished(await this.getRunData(testStats, 'TestRunSkipped')) diff --git a/packages/browserstack-service/src/service.ts b/packages/browserstack-service/src/service.ts index 4a85c19..b906a6e 100644 --- a/packages/browserstack-service/src/service.ts +++ b/packages/browserstack-service/src/service.ts @@ -12,7 +12,9 @@ import { shouldAddServiceVersion, isTrue, normalizeTestReportingConfig, - normalizeTestReportingEnvVariables + normalizeTestReportingEnvVariables, + getUniqueIdentifier, + getHookType } from './util.js' import type { BrowserstackConfig, BrowserstackOptions, MultiRemoteAction, SessionResponse, TurboScaleSessionResponse } from './types.js' import type { Pickle, Feature, ITestCaseHookParameter, CucumberHook } from './cucumber-types.js' @@ -34,6 +36,7 @@ import { EVENTS } from './instrumentation/performance/constants.js' import { BrowserstackCLI } from './cli/index.js' import { TestFrameworkState } from './cli/states/testFrameworkState.js' import { HookState } from './cli/states/hookState.js' +import { markTestStarted, reportSuiteSkipped } from './cli/skipReporter.js' import { AutomationFrameworkState } from './cli/states/automationFrameworkState.js' import TestFramework from './cli/frameworks/testFramework.js' import { TestFrameworkConstants } from './cli/frameworks/constants/testFrameworkConstants.js' @@ -368,6 +371,22 @@ export default class BrowserstackService implements Services.ServiceInstance { if (this._config.framework !== 'cucumber') { this._currentTest = test as Frameworks.Test // not update currentTest when this is called for cucumber step } + + // CLI flow: route hook lifecycle to the binary via the TestFramework tracker (gRPC), + // mirroring beforeTest/afterTest. Without this, hook events fall through to the legacy + // Listener -> api/v1/batch path, which is not functional in the CLI pipeline, so + // HookRunStarted/HookRunFinished never reach the dashboard. + if (BrowserstackCLI.getInstance().isRunning()) { + const framework = BrowserstackCLI.getInstance().getTestFramework() + if (framework) { + const hookFrameworkState = TestFrameworkState[getHookType((test as Frameworks.Test).title) as keyof typeof TestFrameworkState] + if (hookFrameworkState) { + await framework.trackEvent(hookFrameworkState, HookState.PRE, { test }) + } + } + return + } + await this._insightsHandler?.beforeHook(test, context) } @@ -383,6 +402,27 @@ export default class BrowserstackService implements Services.ServiceInstance { this._failReasons.push(hookError) } } + + // CLI flow: mirror beforeHook — close the hook via the TestFramework tracker (gRPC). + if (BrowserstackCLI.getInstance().isRunning()) { + const framework = BrowserstackCLI.getInstance().getTestFramework() + if (framework) { + const hookFrameworkState = TestFrameworkState[getHookType((test as Frameworks.Test).title) as keyof typeof TestFrameworkState] + if (hookFrameworkState) { + await framework.trackEvent(hookFrameworkState, HookState.POST, { test, result }) + } + // a failed (or skipping) before/each hook silently drops the suite's remaining + // tests in mocha — report them as skipped so they surface on the dashboard and + // attribute their Automate session (port of the legacy insights-handler cascade) + const hookType = getHookType((test as Frameworks.Test).title) + const suite = (test as Frameworks.Test).ctx?.test?.parent + if (result && !result.passed && ['BEFORE_ALL', 'BEFORE_EACH', 'AFTER_EACH'].includes(hookType) && suite) { + await reportSuiteSkipped(framework, suite) + } + } + return + } + await this._insightsHandler?.afterHook(test, result) } @@ -407,6 +447,9 @@ export default class BrowserstackService implements Services.ServiceInstance { if (BrowserstackCLI.getInstance().isRunning()) { await BrowserstackCLI.getInstance().getTestFramework()!.trackEvent(TestFrameworkState.INIT_TEST, HookState.PRE, { test }) const uuid = TestFramework.getState(TestFramework.getTrackedInstance(), TestFrameworkConstants.KEY_TEST_UUID) + // this test reports its own finish (incl. runtime `this.skip()`), so the + // skip reporter must never re-report it from onTestSkip + markTestStarted(getUniqueIdentifier(test, this._config.framework)) this._insightsHandler?.setTestData(test, uuid) await BrowserstackCLI.getInstance().getTestFramework()!.trackEvent(TestFrameworkState.TEST, HookState.PRE, { test, suiteTitle }) return diff --git a/packages/browserstack-service/src/testOps/listener.ts b/packages/browserstack-service/src/testOps/listener.ts index d010b3b..9402fb6 100644 --- a/packages/browserstack-service/src/testOps/listener.ts +++ b/packages/browserstack-service/src/testOps/listener.ts @@ -225,7 +225,7 @@ class Listener { BStackLogger.debug('callback: marking events success ' + data.length) this.eventsSuccess(data) } catch (e) { - BStackLogger.debug('callback: marking events failed ' + data.length) + BStackLogger.debug(`callback: marking events failed ${data.length} reason: ${e}`) this.eventsFailed(data) } finally { this.pendingUploads -= 1 diff --git a/packages/browserstack-service/src/util.ts b/packages/browserstack-service/src/util.ts index 900b720..fa9bc80 100644 --- a/packages/browserstack-service/src/util.ts +++ b/packages/browserstack-service/src/util.ts @@ -1248,8 +1248,10 @@ export async function batchAndPostEvents (eventUrl: string, kind: string, data: limit: 3, methods: ['GET', 'POST'] } - }).json() - BStackLogger.debug(`[${kind}] Success response: ${JSON.stringify(response)}`) + }) + // .json() threw a misleading "Unexpected end of JSON input" on empty 2xx bodies; + // non-2xx already surfaces as got's HTTPError with the status code + BStackLogger.debug(`[${kind}] Success response: ${response.body}`) } catch (error) { BStackLogger.debug(`[${kind}] EXCEPTION IN ${kind} REQUEST TO TEST Reporting and Analytics : ${error}`) throw new Error('Exception in request ' + error) diff --git a/packages/browserstack-service/tests/cli/skipReporter.test.ts b/packages/browserstack-service/tests/cli/skipReporter.test.ts new file mode 100644 index 0000000..12aea7e --- /dev/null +++ b/packages/browserstack-service/tests/cli/skipReporter.test.ts @@ -0,0 +1,93 @@ +import path from 'node:path' + +import { describe, expect, it, vi, beforeEach } from 'vitest' +import type { Frameworks } from '@wdio/types' + +vi.mock('@wdio/logger', () => import(path.join(process.cwd(), '__mocks__', '@wdio/logger'))) + +import { markTestStarted, reportSkippedTest, reportSuiteSkipped, resolveSpecFile } from '../../src/cli/skipReporter.js' +import { TestFrameworkState } from '../../src/cli/states/testFrameworkState.js' +import { HookState } from '../../src/cli/states/hookState.js' +import type TestFramework from '../../src/cli/frameworks/testFramework.js' + +const makeFramework = () => ({ trackEvent: vi.fn().mockResolvedValue(undefined) }) as unknown as TestFramework + +const makeTest = (title: string, parent = 'suite') => ({ title, parent }) as unknown as Frameworks.Test + +describe('skipReporter', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('reports a skipped test through the INIT_TEST/TEST/LOG_REPORT sequence', async () => { + const framework = makeFramework() + await reportSkippedTest(framework, 'suite - reports once', makeTest('reports once'), 'suite') + + const calls = vi.mocked(framework.trackEvent).mock.calls + expect(calls.map(([state, hook]) => [state, hook])).toEqual([ + [TestFrameworkState.INIT_TEST, HookState.PRE], + [TestFrameworkState.TEST, HookState.PRE], + [TestFrameworkState.LOG_REPORT, HookState.POST], + [TestFrameworkState.TEST, HookState.POST], + ]) + expect(calls[2][2]).toMatchObject({ result: { passed: false, skipped: true } }) + }) + + it('does not re-report the same identifier', async () => { + const framework = makeFramework() + await reportSkippedTest(framework, 'suite - dedup', makeTest('dedup'), 'suite') + await reportSkippedTest(framework, 'suite - dedup', makeTest('dedup'), 'suite') + expect(framework.trackEvent).toHaveBeenCalledTimes(4) + }) + + it('does not report tests that entered the beforeTest lifecycle', async () => { + const framework = makeFramework() + markTestStarted('suite - runtime skip') + await reportSkippedTest(framework, 'suite - runtime skip', makeTest('runtime skip'), 'suite') + expect(framework.trackEvent).not.toHaveBeenCalled() + }) + + it('serializes concurrent reports through one chain', async () => { + const order: string[] = [] + const framework = { + trackEvent: vi.fn().mockImplementation(async (_s: unknown, _h: unknown, args: { test: { title: string } }) => { + order.push(args.test.title) + await new Promise(resolve => setTimeout(resolve, 1)) + }) + } as unknown as TestFramework + + await Promise.all([ + reportSkippedTest(framework, 'suite - first', makeTest('first'), 'suite'), + reportSkippedTest(framework, 'suite - second', makeTest('second'), 'suite'), + ]) + expect(order).toEqual(['first', 'first', 'first', 'first', 'second', 'second', 'second', 'second']) + }) + + it('walks nested suites and skips already-determined tests', async () => { + const framework = makeFramework() + const parent = { title: 'outer' } + const suite = { + tests: [ + { title: 'ran already', state: 'passed', parent }, + { title: 'undetermined', parent, file: '/spec/a.spec.js' }, + ], + suites: [{ + tests: [{ title: 'nested undetermined', parent: { title: 'inner' } }], + suites: [], + }], + } + await reportSuiteSkipped(framework, suite) + // 2 undetermined tests x 4 tracker events + expect(framework.trackEvent).toHaveBeenCalledTimes(8) + const reported = vi.mocked(framework.trackEvent).mock.calls + .filter(([state]) => state === TestFrameworkState.INIT_TEST) + .map(([, , args]) => (args as { test: { title: string } }).test.title) + expect(reported).toEqual(['undetermined', 'nested undetermined']) + }) + + it('resolves spec file from the runner spec when the test has none', () => { + expect(resolveSpecFile('/abs/spec.js', 'file:///runner/spec.js')).toBe('/abs/spec.js') + expect(resolveSpecFile(undefined, 'file:///runner/spec.js')).toBe('/runner/spec.js') + expect(resolveSpecFile(undefined, undefined)).toBeUndefined() + }) +})