Skip to content

Commit f31d6f4

Browse files
fix(observability): don't mark suppressNotFoundErrors commands as test failures
Nightwatch records every isVisible / isPresent timeout with status:'fail' internally, including calls where the caller explicitly opted into "not found is fine" via suppressNotFoundErrors:true. sendTestRunEvent and the setSessionStatus path in globals.js both scanned commands[] for any status:'fail' and flipped the test/session to "failed" without checking the opt-out or the testcase envelope's pass/fail rollup, so tests Nightwatch and Automate considered passed were reported as failed in Test Observability. - Add helper.isSuppressedFailure(cmd) — handles args[0] as object or JSON string. - sendTestRunEvent: filter suppressed-failure commands and trust the envelope rollup (status:'pass' && failed:0 && errors:0) over a stray command-level fail status. - globals.js setSessionStatus: same fix, keeps Automate session status consistent with TRA. - Add 4 regression tests for sendTestRunEvent (previously uncovered). Linked: BrowserStack SDK-5914
1 parent 2dbe174 commit f31d6f4

4 files changed

Lines changed: 182 additions & 5 deletions

File tree

nightwatch/globals.js

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -307,12 +307,19 @@ module.exports = {
307307
try {
308308
const testName = test?.testcase;
309309
const eventData = (testName && test?.envelope?.[testName]?.testcase) || null;
310+
// Skip commands the caller opted out of via suppressNotFoundErrors:true
311+
// and trust Nightwatch's envelope-level rollup over a stray command-level
312+
// fail status. Same defect shape as testObservability.js sendTestRunEvent.
310313
const failedCommand = eventData?.commands && Array.isArray(eventData.commands)
311-
? eventData.commands.find(cmd => cmd.status === 'fail')
314+
? eventData.commands.find(cmd => cmd.status === 'fail' && !helper.isSuppressedFailure(cmd))
312315
: null;
313-
const status = failedCommand ? 'failed' : 'passed';
316+
const envelopePassed = !!eventData
317+
&& (eventData.status === 'pass')
318+
&& ((eventData.failed || 0) === 0)
319+
&& ((eventData.errors || 0) === 0);
320+
const status = (failedCommand && !envelopePassed) ? 'failed' : 'passed';
314321
let reason = '';
315-
if (failedCommand && failedCommand.result) {
322+
if (status === 'failed' && failedCommand && failedCommand.result) {
316323
reason = (failedCommand.result.message || failedCommand.result.stack || 'Test failed').toString().slice(0, 280);
317324
}
318325
const payload = JSON.stringify({status, reason});

src/testObservability.js

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -537,8 +537,12 @@ class TestObservability {
537537
testData.finished_at = eventData.endTimestamp ? new Date(eventData.endTimestamp).toISOString() : new Date(startTimestamp).toISOString();
538538
testData.result = 'passed';
539539
if (eventData && eventData.commands && Array.isArray(eventData.commands)) {
540-
const failedCommand = eventData.commands.find(cmd => cmd.status === 'fail');
541-
if (failedCommand) {
540+
const failedCommand = eventData.commands.find(cmd => cmd.status === 'fail' && !helper.isSuppressedFailure(cmd));
541+
// Envelope-level rollup: when Nightwatch itself reports the testcase
542+
// as passed (no failed assertions / errors), trust the rollup over a
543+
// stray command-level fail status that did not propagate.
544+
const envelopePassed = (eventData.status === 'pass') && ((eventData.failed || 0) === 0) && ((eventData.errors || 0) === 0);
545+
if (failedCommand && !envelopePassed) {
542546
testData.result = 'failed';
543547
if (failedCommand.result) {
544548
testData.failure = [

src/utils/helper.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,21 @@ exports.isUndefined = value => (value === undefined || value === null);
6262

6363
exports.isObject = value => (!this.isUndefined(value) && value.constructor === Object);
6464

65+
// A Nightwatch command is recorded with status:'fail' even when the caller
66+
// explicitly opted into "not found is fine" via `suppressNotFoundErrors:true`.
67+
// Those commands carry no failure semantics for the test and must not flip
68+
// test/session status. `args[0]` is the command options object — Nightwatch
69+
// serializes it to a JSON string in some reporter paths, so accept both shapes.
70+
exports.isSuppressedFailure = (cmd) => {
71+
if (!cmd || cmd.status !== 'fail' || !Array.isArray(cmd.args) || cmd.args.length === 0) {return false}
72+
const first = cmd.args[0];
73+
let opts = first;
74+
if (typeof first === 'string') {
75+
try {opts = JSON.parse(first)} catch (e) {return false}
76+
}
77+
return !!(opts && typeof opts === 'object' && opts.suppressNotFoundErrors === true);
78+
};
79+
6580
exports.isTestObservabilitySession = () => {
6681
return process.env.BROWSERSTACK_TEST_OBSERVABILITY === 'true' ||
6782
process.env.BROWSERSTACK_TEST_REPORTING === 'true';
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
const assert = require('assert');
2+
const sinon = require('sinon');
3+
4+
const helper = require('../../../src/utils/helper');
5+
const TestMap = require('../../../src/utils/testMap');
6+
const TestObservability = require('../../../src/testObservability');
7+
8+
// Regression coverage for SDK-5914 / suppressNotFoundErrors.
9+
//
10+
// Before this fix, sendTestRunEvent walked eventData.commands for any
11+
// status:'fail' record and flipped testData.result to 'failed' even when
12+
// the failing command was a Nightwatch `isVisible({suppressNotFoundErrors:true})`
13+
// lookup whose absence is an expected, callsite-opted-into outcome.
14+
// The bug shipped because this function had no unit coverage at all.
15+
describe('TestObservability - sendTestRunEvent (suppressNotFoundErrors)', function () {
16+
const buildTest = (commands, envelopeRollup = {status: 'pass', failed: 0, errors: 0}) => ({
17+
metadata: {
18+
name: 'Conditional Suite',
19+
tags: [],
20+
modulePath: '/tmp/observabilityBugRepro.js',
21+
host: 'hub-cloud.browserstack.com',
22+
sessionId: 'session-id-stub',
23+
sessionCapabilities: {}
24+
},
25+
testcase: 'conditional test',
26+
testCaseData: () => '',
27+
settings: {desiredCapabilities: {'bstack:options': {osVersion: '11'}}},
28+
envelope: {
29+
'conditional test': {
30+
startTimestamp: 1700000000000,
31+
testcase: {
32+
endTimestamp: 1700000001000,
33+
commands,
34+
...envelopeRollup
35+
}
36+
}
37+
}
38+
});
39+
40+
beforeEach(() => {
41+
this.sandbox = sinon.createSandbox();
42+
this.testObservability = new TestObservability();
43+
44+
this.sandbox.stub(this.testObservability, 'getTestBody').returns('');
45+
this.sandbox.stub(this.testObservability, 'processTestRunData').resolves();
46+
this.sandbox.stub(helper, 'getCloudProvider').returns('automate');
47+
this.sandbox.stub(helper, 'getIntegrationsObject').returns({});
48+
this.sandbox.stub(helper, 'isTestObservabilitySession').returns(true);
49+
this.sandbox.stub(helper, 'isAccessibilitySession').returns(false);
50+
this.sandbox.stub(TestMap, 'getSessionSnapshot').returns(null);
51+
52+
this.uploaded = null;
53+
this.uploadStub = this.sandbox.stub(helper, 'uploadEventData').callsFake(async (payload) => {
54+
this.uploaded = payload;
55+
});
56+
});
57+
58+
afterEach(() => {
59+
this.sandbox.restore();
60+
});
61+
62+
it('marks the test passed when the only failing command opted into suppressNotFoundErrors (args as object)', async () => {
63+
const commands = [
64+
{name: 'url', args: ['https://www.google.com'], status: 'pass'},
65+
{
66+
name: 'isVisible',
67+
args: [{selector: '#may-or-may-not-exist', suppressNotFoundErrors: true, timeout: 2000}, null],
68+
status: 'fail',
69+
result: {message: 'Element not found', stack: '', name: 'Error'}
70+
}
71+
];
72+
73+
await this.testObservability.sendTestRunEvent('TestRunFinished', buildTest(commands), 'uuid-1');
74+
75+
sinon.assert.calledOnce(this.uploadStub);
76+
assert.strictEqual(this.uploaded.event_type, 'TestRunFinished');
77+
assert.strictEqual(this.uploaded.test_run.result, 'passed');
78+
assert.ok(!('failure' in this.uploaded.test_run), 'expected no failure field on passed test');
79+
assert.ok(!('failure_reason' in this.uploaded.test_run), 'expected no failure_reason field on passed test');
80+
});
81+
82+
it('marks the test passed when args[0] is a JSON-encoded string carrying suppressNotFoundErrors', async () => {
83+
// Some Nightwatch reporter paths serialize the options object to a JSON
84+
// string in command.args[0] — the customer's CHROME_148__observabilityBugRepro.json
85+
// is the canonical example. The fix must handle both shapes.
86+
const commands = [
87+
{
88+
name: 'isVisible',
89+
args: ['{"selector":"#may-or-may-not-exist","suppressNotFoundErrors":true,"timeout":2000}', null],
90+
status: 'fail',
91+
result: {message: 'Element not found', stack: ''}
92+
}
93+
];
94+
95+
await this.testObservability.sendTestRunEvent('TestRunFinished', buildTest(commands), 'uuid-2');
96+
97+
assert.strictEqual(this.uploaded.test_run.result, 'passed');
98+
});
99+
100+
it('still marks the test failed when a real assertion failure is present', async () => {
101+
// Envelope rollup says failed:1 — a real failure happened. The fix must
102+
// NOT suppress that. This is the contrast case that prevents the patch
103+
// from silently downgrading every failing test to passed.
104+
const commands = [
105+
{
106+
name: 'assert.titleContains',
107+
args: ['Google'],
108+
status: 'fail',
109+
result: {message: 'Expected title to contain "Google"', stack: 'AssertionError', name: 'AssertionError'}
110+
}
111+
];
112+
113+
await this.testObservability.sendTestRunEvent(
114+
'TestRunFinished',
115+
buildTest(commands, {status: 'fail', failed: 1, errors: 0}),
116+
'uuid-3'
117+
);
118+
119+
assert.strictEqual(this.uploaded.test_run.result, 'failed');
120+
assert.strictEqual(this.uploaded.test_run.failure_type, 'AssertionError');
121+
});
122+
123+
it('still marks the test failed when a non-suppressed command failed alongside a suppressed one', async () => {
124+
// Mixed case: one suppressed isVisible + one real failure. Envelope rollup
125+
// disagrees with "all passed", so we must propagate the real failure.
126+
const commands = [
127+
{
128+
name: 'isVisible',
129+
args: [{selector: '#optional', suppressNotFoundErrors: true}, null],
130+
status: 'fail',
131+
result: {message: 'Element not found'}
132+
},
133+
{
134+
name: 'click',
135+
args: ['#mandatory'],
136+
status: 'fail',
137+
result: {message: 'Element #mandatory not found', stack: 'NoSuchElementError', name: 'NoSuchElementError'}
138+
}
139+
];
140+
141+
await this.testObservability.sendTestRunEvent(
142+
'TestRunFinished',
143+
buildTest(commands, {status: 'fail', failed: 0, errors: 1}),
144+
'uuid-4'
145+
);
146+
147+
assert.strictEqual(this.uploaded.test_run.result, 'failed');
148+
// failedCommand must skip the suppressed one and pick the real one.
149+
assert.strictEqual(this.uploaded.test_run.failure_type, 'UnhandledError');
150+
});
151+
});

0 commit comments

Comments
 (0)