Skip to content

Commit 2cd7b1a

Browse files
authored
feat: take device screenshots on-demand (#904)
1 parent 40e052e commit 2cd7b1a

15 files changed

Lines changed: 237 additions & 31 deletions

detox/src/artifacts/ArtifactsManager.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,8 +81,9 @@ class ArtifactsManager {
8181
deviceEmitter.on('shutdownDevice', this.onShutdownDevice.bind(this));
8282
deviceEmitter.on('beforeLaunchApp', this.onBeforeLaunchApp.bind(this));
8383
deviceEmitter.on('launchApp', this.onLaunchApp.bind(this));
84-
deviceEmitter.on('beforeTerminateApp', this.onBeforeTerminateApp.bind(this));
8584
deviceEmitter.on('beforeUninstallApp', this.onBeforeUninstallApp.bind(this));
85+
deviceEmitter.on('beforeTerminateApp', this.onBeforeTerminateApp.bind(this));
86+
deviceEmitter.on('userAction', this.onUserAction.bind(this));
8687
}
8788

8889
async onBootDevice(deviceInfo) {
@@ -113,6 +114,10 @@ class ArtifactsManager {
113114
await this._callPlugins('onLaunchApp', appLaunchInfo);
114115
}
115116

117+
async onUserAction(actionInfo) {
118+
await this._callPlugins('onUserAction', actionInfo);
119+
}
120+
116121
async onBeforeAll() {
117122
await this._callPlugins('onBeforeAll');
118123
}

detox/src/artifacts/ArtifactsManager.test.js

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,9 +91,10 @@ describe('ArtifactsManager', () => {
9191
onBeforeShutdownDevice: jest.fn(),
9292
onShutdownDevice: jest.fn(),
9393
onBeforeUninstallApp: jest.fn(),
94+
onBeforeTerminateApp: jest.fn(),
9495
onBeforeLaunchApp: jest.fn(),
9596
onLaunchApp: jest.fn(),
96-
onBeforeTerminateApp: jest.fn(),
97+
onUserAction: jest.fn(),
9798
onBeforeAll: jest.fn(),
9899
onBeforeEach: jest.fn(),
99100
onAfterEach: jest.fn(),
@@ -252,6 +253,13 @@ describe('ArtifactsManager', () => {
252253
deviceId: 'testDeviceId',
253254
}));
254255

256+
itShouldCatchErrorsOnPhase('onUserAction', () => ({
257+
type: 'takeScreenshot',
258+
options: {
259+
name: 'open app',
260+
}
261+
}));
262+
255263
itShouldCatchErrorsOnPhase('onBeforeShutdownDevice', () => ({
256264
deviceId: 'testDeviceId'
257265
}));
@@ -390,6 +398,20 @@ describe('ArtifactsManager', () => {
390398
});
391399
});
392400

401+
describe('onUserAction', () => {
402+
it('should call onUserAction in plugins', async () => {
403+
const actionInfo = {
404+
type: 'takeScreenshot',
405+
options: {
406+
name: 'open app',
407+
},
408+
};
409+
410+
await artifactsManager.onUserAction(actionInfo);
411+
expect(testPlugin.onUserAction).toHaveBeenCalledWith(actionInfo);
412+
});
413+
});
414+
393415
describe('onBeforeLaunchApp', () => {
394416
it('should call onBeforeLaunchApp in plugins', async () => {
395417
const launchInfo = {

detox/src/artifacts/__snapshots__/ArtifactsManager.test.js.snap

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,3 +169,17 @@ Array [
169169
],
170170
]
171171
`;
172+
173+
exports[`ArtifactsManager .artifactsApi hooks error handling should catch .onUserAction errors 1`] = `
174+
Array [
175+
Array [
176+
Object {
177+
"err": [Error: test onUserAction error],
178+
"event": "SUPPRESS_PLUGIN_ERROR",
179+
"methodName": "onUserAction",
180+
"plugin": "testPlugin",
181+
},
182+
"Suppressed error inside function call: testPlugin.onUserAction({ type: 'takeScreenshot', options: { name: 'open app' } })",
183+
],
184+
]
185+
`;

detox/src/artifacts/log/ios/SimulatorLogPlugin.test.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@ jest.mock('../../../utils/argparse');
22
jest.mock('../../../utils/logger');
33

44
const _ = require('lodash');
5+
const os = require('os');
56
const tempfile = require('tempfile');
67
const fs = require('fs-extra');
78
const path = require('path');
9+
const sleep = require('../../../utils/sleep');
810

911
describe('SimulatorLogPlugin', () => {
1012
async function majorWorkflow() {
@@ -88,6 +90,10 @@ describe('SimulatorLogPlugin', () => {
8890
await artifactsManager.onBeforeAll();
8991
await logToDeviceLogs('inside before all');
9092

93+
if (os.platform() !== 'darwin') {
94+
await sleep(1000); // HACK: till we replace `tail` with something less flaky
95+
}
96+
9197
await artifactsManager.onBeforeEach({ title: 'test', fullName: 'some test', status: 'running'});
9298
await logToDeviceLogs('inside before each');
9399

detox/src/artifacts/screenshot/ScreenshotArtifactPlugin.js

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
const _ = require('lodash');
12
const argparse = require('../../utils/argparse');
3+
const log = require('../../utils/logger').child({ __filename });
24
const TwoSnapshotsPerTestPlugin = require('../templates/plugin/TwoSnapshotsPerTestPlugin');
35

46
/***
@@ -12,12 +14,50 @@ class ScreenshotArtifactPlugin extends TwoSnapshotsPerTestPlugin {
1214

1315
this.enabled = takeScreenshots && takeScreenshots !== 'none';
1416
this.keepOnlyFailedTestsArtifacts = takeScreenshots === 'failing';
17+
this._warnAboutNotImplementedMode = _.once(this._warnAboutNotImplementedMode.bind(this));
1518
}
1619

17-
async preparePathForSnapshot(testSummary, index) {
18-
const pngName = index === 0 ? 'beforeEach.png' : 'afterEach.png';
19-
return this.api.preparePathForArtifact(pngName, testSummary);
20+
async onBeforeEach(testSummary) {
21+
this.context.testSummary = null;
22+
await this._flushScreenshots();
23+
24+
await super.onBeforeEach(testSummary);
25+
}
26+
27+
async onAfterEach(testSummary) {
28+
await super.onAfterEach(testSummary);
29+
await this._flushScreenshots();
30+
}
31+
32+
async onAfterAll() {
33+
await super.onAfterAll();
34+
await this._flushScreenshots();
35+
}
36+
37+
async preparePathForSnapshot(testSummary, name) {
38+
return this.api.preparePathForArtifact(`${name}.png`, testSummary);
39+
}
40+
41+
async onUserAction({ type, options }) {
42+
switch (type) {
43+
case 'takeScreenshot':
44+
return this.keepOnlyFailedTestsArtifacts
45+
? this._warnAboutNotImplementedMode()
46+
: this.takeSnapshot(options.name);
47+
}
48+
}
49+
50+
_warnAboutNotImplementedMode() {
51+
const message = 'Taking screenshots on-demand is not supported yet for `--take-screenshots failing` mode.';
52+
log.warn({ event: 'TAKE_SCREENSHOT_IGNORED' }, message);
53+
}
54+
55+
async _flushScreenshots() {
56+
for (const name of Object.keys(this.snapshots)) {
57+
this.startSavingSnapshot(name);
58+
delete this.snapshots[name];
59+
}
2060
}
2161
}
2262

23-
module.exports = ScreenshotArtifactPlugin;
63+
module.exports = ScreenshotArtifactPlugin;

detox/src/artifacts/templates/plugin/ArtifactPlugin.js

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,18 @@ class ArtifactPlugin {
159159
});
160160
}
161161

162+
/**
163+
* Hook that is called on demand by some of user actions (e.g. device.takeScreeenshot())
164+
*
165+
* @protected
166+
* @async
167+
* @param {Object} event - User action event object
168+
* @param {string} event.type - Action type
169+
* @param {string} event.options - Action options
170+
* @return {Promise<any>} - appropriate result of the action
171+
*/
172+
async onUserAction(event) {}
173+
162174
/**
163175
* Hook that is called before any test begins
164176
*
@@ -218,9 +230,10 @@ class ArtifactPlugin {
218230
this.onBootDevice = _.noop;
219231
this.onBeforeShutdownDevice = _.noop;
220232
this.onShutdownDevice = _.noop;
233+
this.onBeforeTerminateApp = _.noop;
221234
this.onBeforeLaunchApp = _.noop;
222235
this.onLaunchApp = _.noop;
223-
this.onBeforeTerminateApp = _.noop;
236+
this.onUserAction = _.noop;
224237
this.onBeforeAll = _.noop;
225238
this.onBeforeEach = _.noop;
226239
this.onAfterEach = _.noop;

detox/src/artifacts/templates/plugin/ArtifactPlugin.test.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,10 @@ describe(ArtifactPlugin, () => {
140140
expect(plugin.context).toMatchSnapshot();
141141
});
142142

143+
it('should have .onUserAction', async () => {
144+
await expect(plugin.onUserAction()).resolves.toBe(void 0);
145+
});
146+
143147
it('should have .onBeforeAll, which resets context.testSummary if called', async () => {
144148
plugin.context.testSummary = {};
145149
await plugin.onBeforeAll();
@@ -184,6 +188,7 @@ describe(ArtifactPlugin, () => {
184188
expect(plugin.onBeforeEach).toBe(plugin.onTerminate);
185189
expect(plugin.onAfterEach).toBe(plugin.onTerminate);
186190
expect(plugin.onAfterAll).toBe(plugin.onTerminate);
191+
expect(plugin.onUserAction).toBe(plugin.onTerminate);
187192
});
188193

189194
it('should not work after the first call', async () => {

detox/src/artifacts/templates/plugin/TwoSnapshotsPerTestPlugin.js

Lines changed: 35 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -6,30 +6,33 @@ const ArtifactPlugin = require('./ArtifactPlugin');
66
class TwoSnapshotsPerTestPlugin extends ArtifactPlugin {
77
constructor({ api }) {
88
super({ api });
9-
this._snapshots = [null, null];
9+
this.snapshots = {};
1010
}
1111

12-
async onBeforeEach() {
13-
await this._takeSnapshot(0);
12+
async onBeforeEach(testSummary) {
13+
await super.onBeforeEach(testSummary);
14+
await this.takeSnapshot('beforeEach');
1415
}
1516

1617
async onAfterEach(testSummary) {
18+
await super.onAfterEach(testSummary);
19+
1720
if (this.shouldKeepArtifactOfTest(testSummary)) {
18-
await this._takeSnapshot(1);
19-
this._startSavingSnapshot(testSummary, 0);
20-
this._startSavingSnapshot(testSummary, 1);
21+
await this.takeSnapshot('afterEach');
22+
this.startSavingSnapshot('beforeEach');
23+
this.startSavingSnapshot('afterEach');
2124
} else {
22-
this._startDiscardingSnapshot(0);
25+
this.startDiscardingSnapshot('beforeEach');
2326
}
2427

25-
this._clearSnapshotReferences();
28+
this._clearAutomaticSnapshotReferences();
2629
}
2730

2831
/***
2932
* @protected
3033
* @abstract
3134
*/
32-
async preparePathForSnapshot(testSummary, index) {}
35+
async preparePathForSnapshot(testSummary, snapshotName) {}
3336

3437

3538
/***
@@ -41,33 +44,48 @@ class TwoSnapshotsPerTestPlugin extends ArtifactPlugin {
4144
*/
4245
createTestArtifact() {}
4346

44-
async _takeSnapshot(index) {
47+
_clearAutomaticSnapshotReferences() {
48+
delete this.snapshots.beforeEach;
49+
delete this.snapshots.afterEach;
50+
}
51+
52+
/***
53+
* @protected
54+
*/
55+
async takeSnapshot(name) {
4556
if (!this.enabled) {
4657
return;
4758
}
4859

49-
const snapshot = this.createTestArtifact();
60+
const snapshot = this.snapshots[name] = this.createTestArtifact();
5061
await snapshot.start();
5162
await snapshot.stop();
52-
this._snapshots[index] = snapshot;
63+
5364
this.api.trackArtifact(snapshot);
5465
}
5566

56-
_startSavingSnapshot(testSummary, index) {
57-
const snapshot = this._snapshots[index];
67+
/***
68+
* @protected
69+
*/
70+
startSavingSnapshot(name) {
71+
const snapshot = this.snapshots[name];
5872
if (!snapshot) {
5973
return;
6074
}
6175

76+
const {testSummary} = this.context;
6277
this.api.requestIdleCallback(async () => {
63-
const snapshotArtifactPath = await this.preparePathForSnapshot(testSummary, index);
78+
const snapshotArtifactPath = await this.preparePathForSnapshot(testSummary, name);
6479
await snapshot.save(snapshotArtifactPath);
6580
this.api.untrackArtifact(snapshot);
6681
});
6782
}
6883

69-
_startDiscardingSnapshot(index) {
70-
const snapshot = this._snapshots[index];
84+
/***
85+
* @protected
86+
*/
87+
startDiscardingSnapshot(name) {
88+
const snapshot = this.snapshots[name];
7189
if (!snapshot) {
7290
return;
7391
}
@@ -77,11 +95,6 @@ class TwoSnapshotsPerTestPlugin extends ArtifactPlugin {
7795
this.api.untrackArtifact(snapshot);
7896
});
7997
}
80-
81-
_clearSnapshotReferences() {
82-
this._snapshots[0] = null;
83-
this._snapshots[1] = null;
84-
}
8598
}
8699

87100
module.exports = TwoSnapshotsPerTestPlugin;

detox/src/artifacts/templates/plugin/TwoSnapshotsPerTestPlugin.test.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ describe('TwoSnapshotsPerTestPlugin', () => {
110110

111111
await saveRequest();
112112

113-
expect(plugin.createdArtifacts[0].save).toBeCalledWith('test/0.png');
113+
expect(plugin.createdArtifacts[0].save).toBeCalledWith('test/beforeEach.png');
114114
expect(api.untrackArtifact).toBeCalledWith(plugin.createdArtifacts[0]);
115115
});
116116

@@ -122,7 +122,7 @@ describe('TwoSnapshotsPerTestPlugin', () => {
122122

123123
await saveRequest();
124124

125-
expect(plugin.createdArtifacts[1].save).toBeCalledWith('test/1.png');
125+
expect(plugin.createdArtifacts[1].save).toBeCalledWith('test/afterEach.png');
126126
expect(api.untrackArtifact).toBeCalledWith(plugin.createdArtifacts[1]);
127127
});
128128
});

detox/src/devices/Device.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,14 @@ class Device {
9696
}
9797
}
9898

99+
async takeScreenshot(name) {
100+
if (!name) {
101+
throw new Error('Cannot take a screenshot with an empty name.');
102+
}
103+
104+
return this.deviceDriver.takeScreenshot(name);
105+
}
106+
99107
_isAppInBackground(params, _bundleId) {
100108
return !params.delete && !params.newInstance && this._processes[_bundleId];
101109
}

0 commit comments

Comments
 (0)