Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@nightwatch/browserstack",
"version": "3.10.2",
"version": "3.11.1",
"description": "Nightwatch plugin for integration with browserstack.",
"main": "index.js",
"scripts": {
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
Loading