Skip to content
126 changes: 101 additions & 25 deletions nightwatch/globals.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,21 @@ const nightwatchRerun = process.env.NIGHTWATCH_RERUN_FAILED;
const nightwatchRerunFile = process.env.NIGHTWATCH_RERUN_REPORT_FILE;
const _tests = {};
const _testCasesData = {};
// Per-attempt testCaseStartedId of every TestCaseFinished already handled, so a
// duplicate/out-of-order finish for the same attempt is a no-op instead of
// re-emitting a phantom TestRunFinished. Bounded by attempt count per worker
// process (same bound as _testCasesData), so it cannot grow unbounded.
const _finishedTestCaseIds = new Set();
let currentTestUUID = '';
let workerList = {};
let testRunner = '';
let testEventPromises = [];

eventHelper.eventEmitter.on(EVENTS.LOG_INIT, (loggingData) => {
const testCaseStartedId = loggingData.message.replace('TEST-OBSERVABILITY-PID-TESTCASE-MAPPING-', '').slice(1, -1);
const testCaseId = _testCasesData[testCaseStartedId]?.testCaseId;
currentTestUUID = _tests[testCaseId]?.uuid;
// _tests is keyed by the unique per-attempt testCaseStartedId so reruns and
// retries of the same scenario do not clobber each other's uuid.
currentTestUUID = _tests[testCaseStartedId]?.uuid;
});

eventHelper.eventEmitter.on(EVENTS.LOG, (loggingData) => {
Expand Down Expand Up @@ -115,8 +121,7 @@ module.exports = {
}
if (data.eventType === EVENTS.LOG_INIT) {
const testCaseStartedId = data.loggingData.message.replace('TEST-OBSERVABILITY-PID-TESTCASE-MAPPING-', '').slice(1, -1);
const testCaseId = _testCasesData[testCaseStartedId]?.testCaseId;
const uuid = _tests[testCaseId]?.uuid;
const uuid = _tests[testCaseStartedId]?.uuid;
await worker.process.send({testCaseStartedId, uuid});
}
});
Expand All @@ -139,7 +144,10 @@ module.exports = {
description: featureData.description
};
}
_tests[testCaseId] = testMetaData;
// Key by the unique per-attempt id (envelope.id === testCaseStartedId)
// instead of the non-unique testCaseId, so a rerun/retry/parallel second
// attempt of the same scenario does not overwrite the first one's entry.
_tests[args.envelope.id] = testMetaData;
await testObservability.sendTestRunEventForCucumber(reportData, gherkinDocument, pickleData, 'TestRunStarted', testMetaData, args);
} catch (error) {
CrashReporter.uploadCrashReport(error.message, error.stack);
Expand All @@ -153,18 +161,33 @@ module.exports = {
}
try {
const reportData = args.report;
const testCaseId = _testCasesData[args.envelope.testCaseStartedId].testCaseId;
const uniqueId = args.envelope.testCaseStartedId;
// A duplicate/out-of-order finish for an attempt we have already finished
// is a no-op: re-emitting would mint a phantom TestRunFinished for a run the
// backend never saw started.
if (_finishedTestCaseIds.has(uniqueId)) {
return;
}
const testCaseId = _testCasesData[uniqueId]?.testCaseId;
const testMetaData = _tests[uniqueId];
// A finish whose start was never recorded has no stored metadata to finish
// and no real backend run to terminate; no-op here. The teardown sweep is the
// single owner of true orphans (it holds the real stored uuid for any entry
// that started but never finished).
if (!testCaseId || !testMetaData) {
_finishedTestCaseIds.add(uniqueId);

return;
}

const pickleId = reportData.testCases.find((testCase) => testCase.id === testCaseId).pickleId;
const pickleData = reportData.pickle.find((pickle) => pickle.id === pickleId);
const gherkinDocument = reportData?.gherkinDocument.find((document) => document.uri === pickleData.uri);
const testMetaData = _tests[testCaseId];
if (testMetaData) {
delete _tests[testCaseId];
testMetaData.finishedAt = new Date().toISOString();
CustomTagManager.drainPendingTestTags(testMetaData.uuid);
await testObservability.sendTestRunEventForCucumber(reportData, gherkinDocument, pickleData, 'TestRunFinished', testMetaData, args);
}
delete _tests[uniqueId];
_finishedTestCaseIds.add(uniqueId);
testMetaData.finishedAt = new Date().toISOString();
CustomTagManager.drainPendingTestTags(testMetaData.uuid);
await testObservability.sendTestRunEventForCucumber(reportData, gherkinDocument, pickleData, 'TestRunFinished', testMetaData, args);
} catch (error) {
CrashReporter.uploadCrashReport(error.message, error.stack);
Logger.error(`Something went wrong in processing report file for test reporting and analytics - ${error.message} with stacktrace ${error.stack}`);
Expand All @@ -177,17 +200,18 @@ module.exports = {
}
try {
const reportData = args.report;
const testCaseId = _testCasesData[args.envelope.testCaseStartedId].testCaseId;
const uniqueId = args.envelope.testCaseStartedId;
const testCaseId = _testCasesData[uniqueId].testCaseId;
const pickleId = reportData.testCases.find((testCase) => testCase.id === testCaseId).pickleId;
const pickleData = reportData.pickle.find((pickle) => pickle.id === pickleId);
const testSteps = reportData.testCases.find((testCase) => testCase.id === testCaseId).testSteps;
const testStepId = reportData.testStepStarted[args.envelope.testCaseStartedId].testStepId;
await testObservability.sendHook(args, 'HookRunStarted', testSteps, testStepId, _tests[testCaseId]);
await testObservability.sendHook(args, 'HookRunStarted', testSteps, testStepId, _tests[uniqueId]);
const pickleStepId = testSteps.find((testStep) => testStep.id === testStepId).pickleStepId;
if (pickleStepId && _tests[testCaseId]?.['testStepId'] !== testStepId) {
_tests[testCaseId]['testStepId'] = testStepId;
if (pickleStepId && _tests[uniqueId]?.['testStepId'] !== testStepId) {
_tests[uniqueId]['testStepId'] = testStepId;
const pickleStepData = pickleData.steps.find((pickle) => pickle.id === pickleStepId);
const testMetaData = _tests[testCaseId] || {steps: []};
const testMetaData = _tests[uniqueId] || {steps: []};
if (testMetaData && !testMetaData.steps) {
testMetaData.steps = [];
}
Expand All @@ -196,7 +220,7 @@ module.exports = {
text: pickleStepData.text,
started_at: new Date().toISOString()
});
_tests[testCaseId] = testMetaData;
_tests[uniqueId] = testMetaData;
}
} catch (error) {
CrashReporter.uploadCrashReport(error.message, error.stack);
Expand All @@ -211,13 +235,14 @@ module.exports = {
try {
const reportData = args.report;
helper.storeSessionsData(args);
const testCaseId = _testCasesData[args.envelope.testCaseStartedId].testCaseId;
const uniqueId = args.envelope.testCaseStartedId;
const testCaseId = _testCasesData[uniqueId].testCaseId;
const testStepFinished = reportData.testStepFinished[args.envelope.testCaseStartedId];
const pickleId = reportData.testCases.find((testCase) => testCase.id === testCaseId).pickleId;
const pickleData = reportData.pickle.find((pickle) => pickle.id === pickleId);
const testSteps = reportData.testCases.find((testCase) => testCase.id === testCaseId).testSteps;
const testStepId = reportData.testStepFinished[args.envelope.testCaseStartedId].testStepId;
await testObservability.sendHook(args, 'HookRunFinished', testSteps, testStepId, _tests[testCaseId]);
await testObservability.sendHook(args, 'HookRunFinished', testSteps, testStepId, _tests[uniqueId]);
const pickleStepId = testSteps.find((testStep) => testStep.id === testStepId).pickleStepId;
let failure;
let failureType;
Expand All @@ -226,9 +251,9 @@ module.exports = {
failureType = (testStepFinished.testStepResult?.exception === undefined) ? 'UnhandledError' : testStepFinished.testStepResult?.message;
}

if (pickleStepId && _tests[testCaseId]['testStepId']) {
if (pickleStepId && _tests[uniqueId]['testStepId']) {
const pickleStepData = pickleData.steps.find((pickle) => pickle.id === pickleStepId);
const testMetaData = _tests[testCaseId] || {steps: []};
const testMetaData = _tests[uniqueId] || {steps: []};
if (!testMetaData.steps) {
testMetaData.steps = [{
id: pickleStepData.id,
Expand All @@ -250,8 +275,8 @@ module.exports = {
}
});
}
_tests[testCaseId] = testMetaData;
delete _tests[testCaseId]['testStepId'];
_tests[uniqueId] = testMetaData;
delete _tests[uniqueId]['testStepId'];
if (testStepFinished.httpOutput && testStepFinished.httpOutput.length > 0) {
for (const [index, output] of testStepFinished.httpOutput.entries()) {
if (index % 2 === 0) {
Expand Down Expand Up @@ -561,6 +586,10 @@ module.exports = {
Logger.debug(`Error aggregating build-level tags from workers: ${err}`);
}

// Sweep any still-open scenarios/hooks/native runs to terminal finishes
// BEFORE the queue is drained and the build is stopped, so they flush in
// this run instead of being left open for the reaper to time out.
await performTeardownSweep();
await testObservability.stopBuildUpstream();
if (process.env.BROWSERSTACK_TESTHUB_UUID) {
Logger.info(`\nVisit https://automation.browserstack.com/builds/${process.env.BROWSERSTACK_TESTHUB_UUID} to view build report, insights, and many more debugging information all at one place!\n`);
Expand Down Expand Up @@ -659,6 +688,9 @@ module.exports = {
Logger.debug(`Error sending build-level tags from worker: ${err}`);
}

// Sweep still-open entities to terminal finishes before the worker drains
// its request queue, so synthetic finishes are flushed for this worker.
await performTeardownSweep();
await helper.shutDownRequestHandler();
if (testEventPromises.length > 0) {
await Promise.all(testEventPromises);
Expand All @@ -667,6 +699,50 @@ module.exports = {
}
};

const performTeardownSweep = async () => {
try {
// Cucumber: a scenario still present in _tests never received its
// TestCaseFinished. Emit a terminal finish, then drop the entry (idempotent).
for (const uniqueId of Object.keys(_tests)) {
const testMetaData = _tests[uniqueId];
delete _tests[uniqueId];
try {
await testObservability.sendSyntheticTestRunFinishedForCucumber(testMetaData);
} catch (err) {
Logger.debug(`Error sweeping open scenario ${uniqueId}: ${err && err.message}`);
}
}

// Cucumber: hooks that started but never finished.
try {
await testObservability.sweepOpenHooks();
} catch (err) {
Logger.debug(`Error sweeping open hooks: ${err && err.message}`);
}

// Native: test runs still marked unfinished in the TestMap.
try {
const openRuns = TestMap.getOpenRuns();
for (const run of openRuns) {
try {
await testObservability.sendSyntheticTestRunFinished(run.uuid, run);
} catch (err) {
Logger.debug(`Error sweeping open run ${run.uuid}: ${err && err.message}`);
}
TestMap.markTestFinished(run.uuid);
}
} catch (err) {
Logger.debug(`Error sweeping open native runs: ${err && err.message}`);
}
} catch (error) {
CrashReporter.uploadCrashReport(error.message, error.stack);
}
};
// Attached as a named export (rather than folded into the module.exports object
// literal above) so unit tests can drive the sweep directly without relocating the
// const, which is referenced by the teardown closures earlier in the literal.
module.exports.performTeardownSweep = performTeardownSweep;

const cucumberPatcher = () => {
try {
const Coordinator = helper.requireModule('@cucumber/cucumber/lib/runtime/parallel/coordinator.js');
Expand Down
30 changes: 27 additions & 3 deletions src/accessibilityAutomation.js
Original file line number Diff line number Diff line change
Expand Up @@ -437,9 +437,26 @@ class AccessibilityAutomation {
try {
const webElementCommandPath = path.join(nightwatchDir, `${commandJson[commandKey].path}`, `${commandName}.js`);
const originalCommand = require(webElementCommandPath);
const originalCommandFn = originalCommand.command;

originalCommand.command = async function(...args) {
// Nightwatch commands are exported in two shapes: web-element commands
// export a plain object with an own `command` function, while client-commands,
// protocol and document commands (e.g. executeScript) export a class with
// `command` on the prototype. Patch wherever the command function actually lives.
const commandTarget = typeof originalCommand.command === 'function'
? originalCommand
: (originalCommand.prototype && typeof originalCommand.prototype.command === 'function'
? originalCommand.prototype
: null);

if (!commandTarget) {
Logger.debug(`Failed to patch command ${commandName}: no command function found`);

return;
}

const originalCommandFn = commandTarget.command;

commandTarget.command = async function(...args) {
if (
!commandName.includes('execute') ||
!accessibilityInstance.shouldPatchExecuteScript(args.length ? args[0] : null)
Expand All @@ -458,10 +475,17 @@ class AccessibilityAutomation {
}

shouldPatchExecuteScript(script) {
if (!script || typeof script !== 'string') {
if (!script) {
return true;
}

// A non-string script (a function passed to .execute()/.executeAsyncScript())
// is always a user script — the plugin's own scan scripts are always strings
// carrying the browserstack_executor token. Scan it.
if (typeof script !== 'string') {
return false;
}

return (
script.toLowerCase().indexOf('browserstack_executor') !== -1 ||
script.toLowerCase().indexOf('browserstack_accessibility_automation_script') !== -1
Expand Down
100 changes: 97 additions & 3 deletions src/testObservability.js
Original file line number Diff line number Diff line change
Expand Up @@ -871,17 +871,111 @@ class TestObservability {
};
}

// BEFORE_ALL and AFTER_ALL are not implemented for TO
// Classify the cucumber hook so an emitted hook never carries a null hook_type.
// A hook found alongside scenario steps is a per-scenario hook (BEFORE_EACH /
// AFTER_EACH depending on whether any scenario step preceded it). A hook not
// found among the scenario steps is a suite-level hook, classified as
// BEFORE_ALL / AFTER_ALL by the same step-seen heuristic.
getCucumberHookType(testSteps, hookData) {
let isStep = false;
for (const step of testSteps) {
for (const step of testSteps || []) {
if (step.pickleStepId) {
isStep = true;
}
if (hookData.id === step.id) {
if (hookData && hookData.id === step.id) {
return (isStep) ? 'AFTER_EACH' : 'BEFORE_EACH';
}
}

return isStep ? 'AFTER_ALL' : 'BEFORE_ALL';
}

// Emit a terminal TestRunFinished for a cucumber scenario left open at teardown.
// Builds a minimal payload from the stored metadata and marks it failed so the
// backend treats the run as terminal and clears the running state.
async sendSyntheticTestRunFinishedForCucumber(testMetaData) {
const {feature, scenario, steps, uuid, startedAt} = testMetaData || {};
if (!uuid) {
return;
}
const featurePath = feature && feature.path;
const testData = {
uuid: uuid,
started_at: startedAt,
finished_at: new Date().toISOString(),
type: 'test',
body: {
lang: 'nightwatch',
code: null
},
name: scenario && scenario.name,
scope: scenario && scenario.name,
scopes: [feature && feature.name ? feature.name : ''],
identifier: scenario && scenario.name,
file_name: featurePath ? path.relative(process.cwd(), featurePath) : undefined,
location: featurePath ? path.relative(process.cwd(), featurePath) : undefined,
framework: 'nightwatch',
result: 'failed',
meta: {
feature: feature,
scenario: scenario,
steps: steps
}
};
await helper.uploadEventData({event_type: 'TestRunFinished', test_run: testData});
}

// Emit a terminal TestRunFinished for a native (non-cucumber) run left open at
// teardown, built from the TestMap run info and marked failed so the backend
// clears the running state instead of letting the reaper time it out.
async sendSyntheticTestRunFinished(uuid, runInfo = {}) {
if (!uuid) {
return;
}
const identifier = runInfo.identifier || '';
const separatorIndex = identifier.indexOf('::');
const moduleName = separatorIndex === -1 ? identifier : identifier.slice(0, separatorIndex);
const testName = separatorIndex === -1 ? identifier : identifier.slice(separatorIndex + 2);
const testData = {
uuid: uuid,
type: 'test',
name: testName || 'unknown',
body: {
lang: 'nightwatch',
code: null
},
scope: identifier,
scopes: [moduleName || ''],
started_at: runInfo.startedAt,
finished_at: new Date().toISOString(),
result: 'failed',
framework: 'nightwatch'
};
await helper.uploadEventData({event_type: 'TestRunFinished', test_run: testData});
}

// Emit a terminal HookRunFinished for every hook that started but never finished
// (no finished_at). Idempotent: a finished hook is skipped, so re-running the
// sweep never double-finishes a hook.
async sweepOpenHooks() {
for (const testCaseStartedId of Object.keys(hooksMap)) {
const hookList = hooksMap[testCaseStartedId];
if (!(hookList instanceof Array)) {
continue;
}
for (const hookEventData of hookList) {
if (hookEventData.finished_at) {
continue;
}
try {
hookEventData.result = 'failed';
hookEventData.finished_at = new Date().toISOString();
await helper.uploadEventData({event_type: 'HookRunFinished', hook_run: hookEventData});
} catch (err) {
Logger.debug(`Error sweeping open hook ${hookEventData.uuid}: ${err && err.message}`);
}
}
}
}

async appendTestItemLog (log, testUuid) {
Expand Down
Loading
Loading