Skip to content

Commit 140294b

Browse files
xxshubhamxxclaudekamal-kaur04
authored
Release 3.11.1 (#58)
* fix(observability): correlate cucumber finishes by unique attempt id and sweep open entities at teardown Key the cucumber _tests map by the unique testCaseStartedId instead of the non-unique testCaseId, so reruns/retries/parallel-interleaved attempts of the same scenario no longer clobber each other and every attempt emits its own TestRunFinished. Harden TestCaseFinished to reconstruct a minimal payload and still emit a terminal finish when the map entry is missing. Add a teardown sweep (run before queue drain / build stop in both the main and worker teardown paths) that emits terminal failed finishes for any scenario, hook, or native run left open, so nothing is left running to be reaped as a timeout. Make getCucumberHookType always return a known hook_type, and let TestMap.getUUID fall back to the most recent unfinished run. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(observability): make cucumber TestCaseFinished idempotent and drop fresh-uuid reconstruct A duplicate/out-of-order TestCaseFinished for the same attempt used to find the entry already deleted, hit the reconstruct branch, mint a fresh uuid, and emit a phantom TestRunFinished for a run the backend never saw started. And the reconstruct branch was dead code for the genuine start-never-recorded orphan: the unguarded _testCasesData read threw before it ran (and the reconstruct itself threw on undefined), all silently swallowed. Track handled attempt ids in a Set and early-return on a duplicate finish, guard the _testCasesData read so an unknown finish no-ops cleanly, and remove the fresh-uuid reconstruct branch entirely. True orphans (started, never finished) are owned solely by the teardown sweep, which holds the real stored uuid. The Set is per-process and bounded by attempt count (same bound as _testCasesData). Also: per-hook try/catch in sweepOpenHooks so one bad upload no longer aborts the remaining hook sweeps; fix the synthetic native finish lang to 'nightwatch'; and document the single-open-run-per-process assumption behind the TestMap.getUUID fallback. Add tests for the duplicate-finish and never-started-finish cases. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(accessibility): patch prototype command for class-based Nightwatch commands commandWrapper() patched `originalCommand.command` directly, which only works for web-element commands that export a plain object (`module.exports.command = fn`). executeScript/executeAsyncScript (and the protocol/client/appium commands) export a class with `command` on the prototype, so the patch landed on a non-existent static method and the real prototype command Nightwatch invokes stayed unpatched. As a result `performScan` never ran for `browser.execute('mobile:scroll', ...)` (and all other class-based commands listed in commands.json) on App Accessibility sessions — no accessibility scan was captured for those interactions. Detect whether `command` lives as an own property (object export) or on the prototype (class export) and patch the correct target, mirroring how the `protocolAction` branch already handles class-based commands. The existing `shouldPatchExecuteScript` recursion guard now becomes effective and prevents the plugin's own `browserstack_executor` scan scripts from re-triggering a scan. Verified on a real-device Android App Accessibility session: `mobile:scroll` now triggers exactly one performScan, with no recursion, and the test passes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(accessibility): scan function-form execute/executeAsyncScript user scripts shouldPatchExecuteScript() treated any non-string script as a script to skip (`!script || typeof script !== 'string'` -> return true), so user scripts passed as functions — `browser.execute(function(){...})` / `executeAsyncScript(fn)` — never triggered performScan, even though they can mutate page/screen state just like a string script. The plugin's own scan scripts are always strings carrying the `browserstack_executor` token (verified: every internal execute* call in the SDK passes a string), so a non-string script is always a user script and is safe to scan — the string-based recursion guard is unaffected. Treat an empty/undefined script as skip (unchanged) and a function script as a user script to scan. Verified on a real-device Android App Accessibility session: executeAsyncScript(fn) now triggers exactly one performScan; the 18 internal browserstack_executor scan scripts are still skipped (no recursion); test passes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(accessibility): cover commandWrapper prototype patching + executeScript guard Adds unit tests for the two accessibility command-wrapping fixes: - shouldPatchExecuteScript: empty -> skip, internal browserstack_executor / accessibility scripts -> skip, user string scripts -> scan, and (regression) function-form user scripts -> scan. - commandWrapper: wraps the own `command` of object-export (web-element) commands and the prototype `command` of class-export commands (executeScript) without creating a phantom static; verifies the wrapped command triggers performScan, delegates to the original, honours the recursion guard, and scans function-form scripts. Drives commandWrapper deterministically by stubbing require.resolve('nightwatch') and injecting fake command modules via require.cache. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Kamalpreet Kaur <kmlkaur73@gmail.com>
1 parent 0b9f0e5 commit 140294b

7 files changed

Lines changed: 665 additions & 34 deletions

File tree

nightwatch/globals.js

Lines changed: 101 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,21 @@ const nightwatchRerun = process.env.NIGHTWATCH_RERUN_FAILED;
2121
const nightwatchRerunFile = process.env.NIGHTWATCH_RERUN_REPORT_FILE;
2222
const _tests = {};
2323
const _testCasesData = {};
24+
// Per-attempt testCaseStartedId of every TestCaseFinished already handled, so a
25+
// duplicate/out-of-order finish for the same attempt is a no-op instead of
26+
// re-emitting a phantom TestRunFinished. Bounded by attempt count per worker
27+
// process (same bound as _testCasesData), so it cannot grow unbounded.
28+
const _finishedTestCaseIds = new Set();
2429
let currentTestUUID = '';
2530
let workerList = {};
2631
let testRunner = '';
2732
let testEventPromises = [];
2833

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

3541
eventHelper.eventEmitter.on(EVENTS.LOG, (loggingData) => {
@@ -115,8 +121,7 @@ module.exports = {
115121
}
116122
if (data.eventType === EVENTS.LOG_INIT) {
117123
const testCaseStartedId = data.loggingData.message.replace('TEST-OBSERVABILITY-PID-TESTCASE-MAPPING-', '').slice(1, -1);
118-
const testCaseId = _testCasesData[testCaseStartedId]?.testCaseId;
119-
const uuid = _tests[testCaseId]?.uuid;
124+
const uuid = _tests[testCaseStartedId]?.uuid;
120125
await worker.process.send({testCaseStartedId, uuid});
121126
}
122127
});
@@ -139,7 +144,10 @@ module.exports = {
139144
description: featureData.description
140145
};
141146
}
142-
_tests[testCaseId] = testMetaData;
147+
// Key by the unique per-attempt id (envelope.id === testCaseStartedId)
148+
// instead of the non-unique testCaseId, so a rerun/retry/parallel second
149+
// attempt of the same scenario does not overwrite the first one's entry.
150+
_tests[args.envelope.id] = testMetaData;
143151
await testObservability.sendTestRunEventForCucumber(reportData, gherkinDocument, pickleData, 'TestRunStarted', testMetaData, args);
144152
} catch (error) {
145153
CrashReporter.uploadCrashReport(error.message, error.stack);
@@ -153,18 +161,33 @@ module.exports = {
153161
}
154162
try {
155163
const reportData = args.report;
156-
const testCaseId = _testCasesData[args.envelope.testCaseStartedId].testCaseId;
164+
const uniqueId = args.envelope.testCaseStartedId;
165+
// A duplicate/out-of-order finish for an attempt we have already finished
166+
// is a no-op: re-emitting would mint a phantom TestRunFinished for a run the
167+
// backend never saw started.
168+
if (_finishedTestCaseIds.has(uniqueId)) {
169+
return;
170+
}
171+
const testCaseId = _testCasesData[uniqueId]?.testCaseId;
172+
const testMetaData = _tests[uniqueId];
173+
// A finish whose start was never recorded has no stored metadata to finish
174+
// and no real backend run to terminate; no-op here. The teardown sweep is the
175+
// single owner of true orphans (it holds the real stored uuid for any entry
176+
// that started but never finished).
177+
if (!testCaseId || !testMetaData) {
178+
_finishedTestCaseIds.add(uniqueId);
179+
180+
return;
181+
}
157182

158183
const pickleId = reportData.testCases.find((testCase) => testCase.id === testCaseId).pickleId;
159184
const pickleData = reportData.pickle.find((pickle) => pickle.id === pickleId);
160185
const gherkinDocument = reportData?.gherkinDocument.find((document) => document.uri === pickleData.uri);
161-
const testMetaData = _tests[testCaseId];
162-
if (testMetaData) {
163-
delete _tests[testCaseId];
164-
testMetaData.finishedAt = new Date().toISOString();
165-
CustomTagManager.drainPendingTestTags(testMetaData.uuid);
166-
await testObservability.sendTestRunEventForCucumber(reportData, gherkinDocument, pickleData, 'TestRunFinished', testMetaData, args);
167-
}
186+
delete _tests[uniqueId];
187+
_finishedTestCaseIds.add(uniqueId);
188+
testMetaData.finishedAt = new Date().toISOString();
189+
CustomTagManager.drainPendingTestTags(testMetaData.uuid);
190+
await testObservability.sendTestRunEventForCucumber(reportData, gherkinDocument, pickleData, 'TestRunFinished', testMetaData, args);
168191
} catch (error) {
169192
CrashReporter.uploadCrashReport(error.message, error.stack);
170193
Logger.error(`Something went wrong in processing report file for test reporting and analytics - ${error.message} with stacktrace ${error.stack}`);
@@ -177,17 +200,18 @@ module.exports = {
177200
}
178201
try {
179202
const reportData = args.report;
180-
const testCaseId = _testCasesData[args.envelope.testCaseStartedId].testCaseId;
203+
const uniqueId = args.envelope.testCaseStartedId;
204+
const testCaseId = _testCasesData[uniqueId].testCaseId;
181205
const pickleId = reportData.testCases.find((testCase) => testCase.id === testCaseId).pickleId;
182206
const pickleData = reportData.pickle.find((pickle) => pickle.id === pickleId);
183207
const testSteps = reportData.testCases.find((testCase) => testCase.id === testCaseId).testSteps;
184208
const testStepId = reportData.testStepStarted[args.envelope.testCaseStartedId].testStepId;
185-
await testObservability.sendHook(args, 'HookRunStarted', testSteps, testStepId, _tests[testCaseId]);
209+
await testObservability.sendHook(args, 'HookRunStarted', testSteps, testStepId, _tests[uniqueId]);
186210
const pickleStepId = testSteps.find((testStep) => testStep.id === testStepId).pickleStepId;
187-
if (pickleStepId && _tests[testCaseId]?.['testStepId'] !== testStepId) {
188-
_tests[testCaseId]['testStepId'] = testStepId;
211+
if (pickleStepId && _tests[uniqueId]?.['testStepId'] !== testStepId) {
212+
_tests[uniqueId]['testStepId'] = testStepId;
189213
const pickleStepData = pickleData.steps.find((pickle) => pickle.id === pickleStepId);
190-
const testMetaData = _tests[testCaseId] || {steps: []};
214+
const testMetaData = _tests[uniqueId] || {steps: []};
191215
if (testMetaData && !testMetaData.steps) {
192216
testMetaData.steps = [];
193217
}
@@ -196,7 +220,7 @@ module.exports = {
196220
text: pickleStepData.text,
197221
started_at: new Date().toISOString()
198222
});
199-
_tests[testCaseId] = testMetaData;
223+
_tests[uniqueId] = testMetaData;
200224
}
201225
} catch (error) {
202226
CrashReporter.uploadCrashReport(error.message, error.stack);
@@ -211,13 +235,14 @@ module.exports = {
211235
try {
212236
const reportData = args.report;
213237
helper.storeSessionsData(args);
214-
const testCaseId = _testCasesData[args.envelope.testCaseStartedId].testCaseId;
238+
const uniqueId = args.envelope.testCaseStartedId;
239+
const testCaseId = _testCasesData[uniqueId].testCaseId;
215240
const testStepFinished = reportData.testStepFinished[args.envelope.testCaseStartedId];
216241
const pickleId = reportData.testCases.find((testCase) => testCase.id === testCaseId).pickleId;
217242
const pickleData = reportData.pickle.find((pickle) => pickle.id === pickleId);
218243
const testSteps = reportData.testCases.find((testCase) => testCase.id === testCaseId).testSteps;
219244
const testStepId = reportData.testStepFinished[args.envelope.testCaseStartedId].testStepId;
220-
await testObservability.sendHook(args, 'HookRunFinished', testSteps, testStepId, _tests[testCaseId]);
245+
await testObservability.sendHook(args, 'HookRunFinished', testSteps, testStepId, _tests[uniqueId]);
221246
const pickleStepId = testSteps.find((testStep) => testStep.id === testStepId).pickleStepId;
222247
let failure;
223248
let failureType;
@@ -226,9 +251,9 @@ module.exports = {
226251
failureType = (testStepFinished.testStepResult?.exception === undefined) ? 'UnhandledError' : testStepFinished.testStepResult?.message;
227252
}
228253

229-
if (pickleStepId && _tests[testCaseId]['testStepId']) {
254+
if (pickleStepId && _tests[uniqueId]['testStepId']) {
230255
const pickleStepData = pickleData.steps.find((pickle) => pickle.id === pickleStepId);
231-
const testMetaData = _tests[testCaseId] || {steps: []};
256+
const testMetaData = _tests[uniqueId] || {steps: []};
232257
if (!testMetaData.steps) {
233258
testMetaData.steps = [{
234259
id: pickleStepData.id,
@@ -250,8 +275,8 @@ module.exports = {
250275
}
251276
});
252277
}
253-
_tests[testCaseId] = testMetaData;
254-
delete _tests[testCaseId]['testStepId'];
278+
_tests[uniqueId] = testMetaData;
279+
delete _tests[uniqueId]['testStepId'];
255280
if (testStepFinished.httpOutput && testStepFinished.httpOutput.length > 0) {
256281
for (const [index, output] of testStepFinished.httpOutput.entries()) {
257282
if (index % 2 === 0) {
@@ -561,6 +586,10 @@ module.exports = {
561586
Logger.debug(`Error aggregating build-level tags from workers: ${err}`);
562587
}
563588

589+
// Sweep any still-open scenarios/hooks/native runs to terminal finishes
590+
// BEFORE the queue is drained and the build is stopped, so they flush in
591+
// this run instead of being left open for the reaper to time out.
592+
await performTeardownSweep();
564593
await testObservability.stopBuildUpstream();
565594
if (process.env.BROWSERSTACK_TESTHUB_UUID) {
566595
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`);
@@ -659,6 +688,9 @@ module.exports = {
659688
Logger.debug(`Error sending build-level tags from worker: ${err}`);
660689
}
661690

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

702+
const performTeardownSweep = async () => {
703+
try {
704+
// Cucumber: a scenario still present in _tests never received its
705+
// TestCaseFinished. Emit a terminal finish, then drop the entry (idempotent).
706+
for (const uniqueId of Object.keys(_tests)) {
707+
const testMetaData = _tests[uniqueId];
708+
delete _tests[uniqueId];
709+
try {
710+
await testObservability.sendSyntheticTestRunFinishedForCucumber(testMetaData);
711+
} catch (err) {
712+
Logger.debug(`Error sweeping open scenario ${uniqueId}: ${err && err.message}`);
713+
}
714+
}
715+
716+
// Cucumber: hooks that started but never finished.
717+
try {
718+
await testObservability.sweepOpenHooks();
719+
} catch (err) {
720+
Logger.debug(`Error sweeping open hooks: ${err && err.message}`);
721+
}
722+
723+
// Native: test runs still marked unfinished in the TestMap.
724+
try {
725+
const openRuns = TestMap.getOpenRuns();
726+
for (const run of openRuns) {
727+
try {
728+
await testObservability.sendSyntheticTestRunFinished(run.uuid, run);
729+
} catch (err) {
730+
Logger.debug(`Error sweeping open run ${run.uuid}: ${err && err.message}`);
731+
}
732+
TestMap.markTestFinished(run.uuid);
733+
}
734+
} catch (err) {
735+
Logger.debug(`Error sweeping open native runs: ${err && err.message}`);
736+
}
737+
} catch (error) {
738+
CrashReporter.uploadCrashReport(error.message, error.stack);
739+
}
740+
};
741+
// Attached as a named export (rather than folded into the module.exports object
742+
// literal above) so unit tests can drive the sweep directly without relocating the
743+
// const, which is referenced by the teardown closures earlier in the literal.
744+
module.exports.performTeardownSweep = performTeardownSweep;
745+
670746
const cucumberPatcher = () => {
671747
try {
672748
const Coordinator = helper.requireModule('@cucumber/cucumber/lib/runtime/parallel/coordinator.js');

src/accessibilityAutomation.js

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -437,9 +437,26 @@ class AccessibilityAutomation {
437437
try {
438438
const webElementCommandPath = path.join(nightwatchDir, `${commandJson[commandKey].path}`, `${commandName}.js`);
439439
const originalCommand = require(webElementCommandPath);
440-
const originalCommandFn = originalCommand.command;
441440

442-
originalCommand.command = async function(...args) {
441+
// Nightwatch commands are exported in two shapes: web-element commands
442+
// export a plain object with an own `command` function, while client-commands,
443+
// protocol and document commands (e.g. executeScript) export a class with
444+
// `command` on the prototype. Patch wherever the command function actually lives.
445+
const commandTarget = typeof originalCommand.command === 'function'
446+
? originalCommand
447+
: (originalCommand.prototype && typeof originalCommand.prototype.command === 'function'
448+
? originalCommand.prototype
449+
: null);
450+
451+
if (!commandTarget) {
452+
Logger.debug(`Failed to patch command ${commandName}: no command function found`);
453+
454+
return;
455+
}
456+
457+
const originalCommandFn = commandTarget.command;
458+
459+
commandTarget.command = async function(...args) {
443460
if (
444461
!commandName.includes('execute') ||
445462
!accessibilityInstance.shouldPatchExecuteScript(args.length ? args[0] : null)
@@ -458,10 +475,17 @@ class AccessibilityAutomation {
458475
}
459476

460477
shouldPatchExecuteScript(script) {
461-
if (!script || typeof script !== 'string') {
478+
if (!script) {
462479
return true;
463480
}
464481

482+
// A non-string script (a function passed to .execute()/.executeAsyncScript())
483+
// is always a user script — the plugin's own scan scripts are always strings
484+
// carrying the browserstack_executor token. Scan it.
485+
if (typeof script !== 'string') {
486+
return false;
487+
}
488+
465489
return (
466490
script.toLowerCase().indexOf('browserstack_executor') !== -1 ||
467491
script.toLowerCase().indexOf('browserstack_accessibility_automation_script') !== -1

src/testObservability.js

Lines changed: 97 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -871,17 +871,111 @@ class TestObservability {
871871
};
872872
}
873873

874-
// BEFORE_ALL and AFTER_ALL are not implemented for TO
874+
// Classify the cucumber hook so an emitted hook never carries a null hook_type.
875+
// A hook found alongside scenario steps is a per-scenario hook (BEFORE_EACH /
876+
// AFTER_EACH depending on whether any scenario step preceded it). A hook not
877+
// found among the scenario steps is a suite-level hook, classified as
878+
// BEFORE_ALL / AFTER_ALL by the same step-seen heuristic.
875879
getCucumberHookType(testSteps, hookData) {
876880
let isStep = false;
877-
for (const step of testSteps) {
881+
for (const step of testSteps || []) {
878882
if (step.pickleStepId) {
879883
isStep = true;
880884
}
881-
if (hookData.id === step.id) {
885+
if (hookData && hookData.id === step.id) {
882886
return (isStep) ? 'AFTER_EACH' : 'BEFORE_EACH';
883887
}
884888
}
889+
890+
return isStep ? 'AFTER_ALL' : 'BEFORE_ALL';
891+
}
892+
893+
// Emit a terminal TestRunFinished for a cucumber scenario left open at teardown.
894+
// Builds a minimal payload from the stored metadata and marks it failed so the
895+
// backend treats the run as terminal and clears the running state.
896+
async sendSyntheticTestRunFinishedForCucumber(testMetaData) {
897+
const {feature, scenario, steps, uuid, startedAt} = testMetaData || {};
898+
if (!uuid) {
899+
return;
900+
}
901+
const featurePath = feature && feature.path;
902+
const testData = {
903+
uuid: uuid,
904+
started_at: startedAt,
905+
finished_at: new Date().toISOString(),
906+
type: 'test',
907+
body: {
908+
lang: 'nightwatch',
909+
code: null
910+
},
911+
name: scenario && scenario.name,
912+
scope: scenario && scenario.name,
913+
scopes: [feature && feature.name ? feature.name : ''],
914+
identifier: scenario && scenario.name,
915+
file_name: featurePath ? path.relative(process.cwd(), featurePath) : undefined,
916+
location: featurePath ? path.relative(process.cwd(), featurePath) : undefined,
917+
framework: 'nightwatch',
918+
result: 'failed',
919+
meta: {
920+
feature: feature,
921+
scenario: scenario,
922+
steps: steps
923+
}
924+
};
925+
await helper.uploadEventData({event_type: 'TestRunFinished', test_run: testData});
926+
}
927+
928+
// Emit a terminal TestRunFinished for a native (non-cucumber) run left open at
929+
// teardown, built from the TestMap run info and marked failed so the backend
930+
// clears the running state instead of letting the reaper time it out.
931+
async sendSyntheticTestRunFinished(uuid, runInfo = {}) {
932+
if (!uuid) {
933+
return;
934+
}
935+
const identifier = runInfo.identifier || '';
936+
const separatorIndex = identifier.indexOf('::');
937+
const moduleName = separatorIndex === -1 ? identifier : identifier.slice(0, separatorIndex);
938+
const testName = separatorIndex === -1 ? identifier : identifier.slice(separatorIndex + 2);
939+
const testData = {
940+
uuid: uuid,
941+
type: 'test',
942+
name: testName || 'unknown',
943+
body: {
944+
lang: 'nightwatch',
945+
code: null
946+
},
947+
scope: identifier,
948+
scopes: [moduleName || ''],
949+
started_at: runInfo.startedAt,
950+
finished_at: new Date().toISOString(),
951+
result: 'failed',
952+
framework: 'nightwatch'
953+
};
954+
await helper.uploadEventData({event_type: 'TestRunFinished', test_run: testData});
955+
}
956+
957+
// Emit a terminal HookRunFinished for every hook that started but never finished
958+
// (no finished_at). Idempotent: a finished hook is skipped, so re-running the
959+
// sweep never double-finishes a hook.
960+
async sweepOpenHooks() {
961+
for (const testCaseStartedId of Object.keys(hooksMap)) {
962+
const hookList = hooksMap[testCaseStartedId];
963+
if (!(hookList instanceof Array)) {
964+
continue;
965+
}
966+
for (const hookEventData of hookList) {
967+
if (hookEventData.finished_at) {
968+
continue;
969+
}
970+
try {
971+
hookEventData.result = 'failed';
972+
hookEventData.finished_at = new Date().toISOString();
973+
await helper.uploadEventData({event_type: 'HookRunFinished', hook_run: hookEventData});
974+
} catch (err) {
975+
Logger.debug(`Error sweeping open hook ${hookEventData.uuid}: ${err && err.message}`);
976+
}
977+
}
978+
}
885979
}
886980

887981
async appendTestItemLog (log, testUuid) {

0 commit comments

Comments
 (0)