Skip to content

Commit 656829c

Browse files
committed
fix: sync device logs
1 parent f2742d9 commit 656829c

File tree

9 files changed

+201
-26
lines changed

9 files changed

+201
-26
lines changed

package-e2e/test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@ assertType<DetoxAllure2AdapterDeviceLogsOptions>({
2828
android: () => true,
2929
override: true,
3030
saveAll: true,
31+
syncDelay: 0,
32+
});
33+
34+
assertType<DetoxAllure2AdapterDeviceLogsOptions>({
35+
syncDelay: { ios: 0, android: 1000 },
3136
});
3237

3338
assertType<DetoxAllure2AdapterDeviceScreenshotOptions>({

src/listener.ts

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -79,20 +79,16 @@ export const listener: EnvironmentListenerFn = (
7979
logs?.attachBefore(allure);
8080
})
8181
.on('hook_failure', async () => {
82-
await screenshots?.attachFailure(allure);
83-
logs?.attachAfterFailure(allure);
82+
await Promise.all([logs?.attachAfterFailure(allure), screenshots?.attachFailure(allure)]);
8483
})
8584
.on('hook_success', async () => {
86-
await screenshots?.attachSuccess(allure);
87-
logs?.attachAfterSuccess(allure);
85+
await Promise.all([logs?.attachAfterSuccess(allure), screenshots?.attachSuccess(allure)]);
8886
})
8987
.on('test_fn_failure', async () => {
90-
await screenshots?.attachFailure(allure);
91-
logs?.attachAfterFailure(allure);
88+
await Promise.all([logs?.attachAfterFailure(allure), screenshots?.attachFailure(allure)]);
9289
})
9390
.on('test_fn_success', async () => {
94-
await screenshots?.attachSuccess(allure);
95-
logs?.attachAfterSuccess(allure);
91+
await Promise.all([logs?.attachAfterSuccess(allure), screenshots?.attachSuccess(allure)]);
9692
})
9793
.on('test_done', async () => {
9894
$test = undefined;

src/logs/log-buffer.ts

Lines changed: 60 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { Emitter, AndroidEntry, IosEntry } from 'logkitten';
55
import { Level, logkitten } from 'logkitten';
66
import type { DetoxAllure2AdapterDeviceLogsOptions } from '../types';
77
import type { DeviceWrapper } from '../utils';
8+
import { Deferred } from '../utils';
89

910
import { PIDEntryCollection } from './pid-entry-collection';
1011

@@ -18,26 +19,30 @@ export interface LogBufferOptions {
1819

1920
export interface StepLogRecorder {
2021
attachBefore(allure: AllureRuntime): void;
21-
attachAfter(allure: AllureRuntime, failed: boolean): void;
22-
attachAfterSuccess(allure: AllureRuntime): void;
23-
attachAfterFailure(allure: AllureRuntime): void;
22+
attachAfter(allure: AllureRuntime, failed: boolean): Promise<void>;
23+
attachAfterSuccess(allure: AllureRuntime): Promise<void>;
24+
attachAfterFailure(allure: AllureRuntime): Promise<void>;
2425
setPid(pid: number): void;
2526
close(): Promise<void>;
2627
}
2728

2829
const noop = () => {};
30+
const DEFAULT_SYNC_DELAY = 500;
2931

3032
export class LogBuffer implements StepLogRecorder {
3133
private readonly _emitter: Emitter;
3234
private readonly _appEntries = new PIDEntryCollection();
3335
private readonly _detoxEntries = new PIDEntryCollection();
34-
private _options: DetoxAllure2AdapterDeviceLogsOptions;
36+
private readonly _options: DetoxAllure2AdapterDeviceLogsOptions;
37+
private readonly _deferreds = new Set<Deferred<number>>();
38+
private readonly _syncDelay: number;
3539

3640
constructor(readonly _config: LogBufferOptions) {
3741
const deviceId = this._config.device.id;
3842
const platform = this._config.device.platform;
3943

4044
this._options = typeof this._config.options === 'boolean' ? {} : this._config.options;
45+
this._syncDelay = this._inferSyncDelay(platform, this._config.options);
4146
this._emitter =
4247
platform === 'android'
4348
? logkitten({
@@ -70,18 +75,37 @@ export class LogBuffer implements StepLogRecorder {
7075
return this._attachLogs(allure, false, false);
7176
}
7277

73-
public attachAfter(allure: AllureRuntime, failed: boolean) {
78+
public async attachAfter(allure: AllureRuntime, failed: boolean) {
79+
await this._synchronize();
7480
return this._attachLogs(allure, failed, true);
7581
}
7682

77-
public attachAfterSuccess(allure: AllureRuntime) {
83+
public async attachAfterSuccess(allure: AllureRuntime) {
84+
await this._synchronize();
7885
return this._attachLogs(allure, false, true);
7986
}
8087

81-
public attachAfterFailure(allure: AllureRuntime) {
88+
public async attachAfterFailure(allure: AllureRuntime) {
89+
await this._synchronize();
8290
return this._attachLogs(allure, true, true);
8391
}
8492

93+
private _synchronize(reference = Date.now()): Promise<void> {
94+
if (this._syncDelay < 1) {
95+
return Promise.resolve();
96+
}
97+
98+
const deferred = new Deferred<number>({
99+
timeoutMs: this._syncDelay,
100+
predicate: (ts) => ts > reference,
101+
cleanup: () => {
102+
this._deferreds.delete(deferred);
103+
},
104+
});
105+
this._deferreds.add(deferred);
106+
return deferred.promise.then(() => {}); // resolve to void
107+
}
108+
85109
private readonly _attachLogs = (allure: AllureRuntime, failed: boolean, after: boolean) => {
86110
// Check if we should save logs based on failure status
87111
const saveAll = this._options.saveAll ?? false;
@@ -102,11 +126,19 @@ export class LogBuffer implements StepLogRecorder {
102126
}
103127
};
104128

129+
private _updateDeferreds(ts: number) {
130+
for (const deferred of this._deferreds) {
131+
deferred.update(ts);
132+
}
133+
}
134+
105135
private readonly _onEntry = (entry: AnyEntry) => {
106136
this._appEntries.push(entry);
107137
};
108138

109139
private _iosFilter(entry: IosEntry): boolean {
140+
this._updateDeferreds(entry.ts);
141+
110142
if (entry.subsystem === 'com.wix.Detox') {
111143
this._detoxEntries.push(entry);
112144

@@ -126,6 +158,8 @@ export class LogBuffer implements StepLogRecorder {
126158
}
127159

128160
private _androidFilter(entry: AndroidEntry): boolean {
161+
this._updateDeferreds(entry.ts);
162+
129163
if (entry.tag && entry.tag.startsWith('Detox')) {
130164
this._detoxEntries.push(entry);
131165

@@ -177,4 +211,23 @@ export class LogBuffer implements StepLogRecorder {
177211

178212
return false;
179213
};
214+
215+
/**
216+
* Infers the sync delay (ms) for the given platform and options.
217+
*/
218+
private _inferSyncDelay(
219+
platform: string,
220+
options: DetoxAllure2AdapterDeviceLogsOptions | boolean,
221+
): number {
222+
if (typeof options !== 'boolean') {
223+
const syncDelay = options.syncDelay;
224+
if (typeof syncDelay === 'number') {
225+
return syncDelay;
226+
} else if (typeof syncDelay === 'object' && syncDelay !== null) {
227+
return syncDelay[platform as keyof typeof syncDelay] ?? DEFAULT_SYNC_DELAY;
228+
}
229+
}
230+
231+
return DEFAULT_SYNC_DELAY;
232+
}
180233
}

src/screenshots/helpers.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,14 @@ export class ScreenshotHelper {
5050
}
5151
}
5252

53-
async extractFromResult(allure: AllureRuntime, result: unknown) {
53+
async attachFromResultOrFailure(allure: AllureRuntime, result: unknown) {
54+
const attached = await this.attachFromResult(allure, result);
55+
if (!attached) {
56+
await this.attachFailure(allure);
57+
}
58+
}
59+
60+
async attachFromResult(allure: AllureRuntime, result: unknown) {
5461
if (!result) {
5562
return false;
5663
}

src/steps/wrapWithSteps.ts

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -38,18 +38,18 @@ export function wrapWithSteps(options: WrapWithStepsOptions) {
3838
const ws = worker._client._asyncWebSocket;
3939
const send = ws.send.bind(ws) as (...args: any[]) => Promise<{ type?: string }>;
4040
const onActionSuccess = async () => {
41-
logs?.attachAfterSuccess(allure);
41+
await logs?.attachAfterSuccess(allure);
4242
};
43+
4344
const onActionFailure = async (shouldSetStatus: boolean, result?: unknown) => {
4445
if (shouldSetStatus) {
4546
allure.status('failed');
4647
}
4748

48-
const attached = await screenshots?.extractFromResult(allure, result);
49-
if (!attached) {
50-
await screenshots?.attachFailure(allure);
51-
}
52-
logs?.attachAfterFailure(allure);
49+
await Promise.all([
50+
logs?.attachAfterFailure(allure),
51+
screenshots?.attachFromResultOrFailure(allure, result),
52+
]);
5353
};
5454
ws.send = async (...args: any[]) => {
5555
const desc = descriptionMaker(args[0]);
@@ -97,13 +97,11 @@ function wrapDeviceMethod(
9797
try {
9898
logs?.attachBefore(allure);
9999
const result = await originalMethod.apply(device, args);
100-
await screenshots?.attach(allure, false);
101-
logs?.attachAfterSuccess(allure);
100+
await Promise.all([logs?.attachAfterSuccess(allure), screenshots?.attach(allure, false)]);
102101

103102
return result;
104103
} catch (error) {
105-
await screenshots?.attachFailure(allure);
106-
logs?.attachAfterFailure(allure);
104+
await Promise.all([logs?.attachAfterFailure(allure), screenshots?.attachFailure(allure)]);
107105

108106
throw error; // Re-throw the error
109107
}

src/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@ export interface DetoxAllure2AdapterDeviceLogsOptions {
2525
android?: (entry: AndroidEntry) => boolean;
2626
override?: boolean;
2727
saveAll?: boolean;
28+
/**
29+
* Synchronization delay (ms) for log collection. 0 disables, number for both, or { ios, android } for per-platform.
30+
* @default 500
31+
*/
32+
syncDelay?: number | { ios?: number; android?: number };
2833
}
2934

3035
export interface DetoxAllure2AdapterDeviceScreenshotOptions {

src/utils/deferred.test.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { Deferred } from './deferred';
2+
3+
describe('Deferred', () => {
4+
const timeoutMs = 100;
5+
const cleanup = jest.fn();
6+
7+
afterEach(() => {
8+
jest.clearAllTimers();
9+
jest.useRealTimers();
10+
cleanup.mockClear();
11+
});
12+
13+
it('resolves when predicate is satisfied', async () => {
14+
jest.useFakeTimers();
15+
const deferred = new Deferred<number>({
16+
timeoutMs,
17+
predicate: (v) => v > 5,
18+
cleanup,
19+
});
20+
let resolved = false;
21+
deferred.promise.then(() => {
22+
resolved = true;
23+
});
24+
deferred.update(3);
25+
expect(resolved).toBe(false);
26+
deferred.update(7);
27+
jest.runAllTimers();
28+
await Promise.resolve();
29+
expect(resolved).toBe(true);
30+
expect(cleanup).toHaveBeenCalled();
31+
});
32+
33+
it('resolves on timeout if predicate is never satisfied', async () => {
34+
jest.useFakeTimers();
35+
const deferred = new Deferred<number>({
36+
timeoutMs,
37+
predicate: () => false,
38+
cleanup,
39+
});
40+
let resolved = false;
41+
deferred.promise.then(() => {
42+
resolved = true;
43+
});
44+
deferred.update(1);
45+
deferred.update(2);
46+
jest.advanceTimersByTime(timeoutMs + 1);
47+
await Promise.resolve();
48+
expect(resolved).toBe(true);
49+
expect(cleanup).toHaveBeenCalled();
50+
});
51+
52+
it('does not resolve more than once and cleanup is called once', async () => {
53+
jest.useFakeTimers();
54+
const deferred = new Deferred<number>({
55+
timeoutMs,
56+
predicate: (v) => v === 1,
57+
cleanup,
58+
});
59+
const onResolve = jest.fn();
60+
deferred.promise.then(onResolve);
61+
deferred.update(1);
62+
deferred.update(2);
63+
deferred.update(1);
64+
jest.runAllTimers();
65+
await Promise.resolve();
66+
expect(onResolve).toHaveBeenCalledTimes(1);
67+
expect(cleanup).toHaveBeenCalledTimes(1);
68+
});
69+
});

src/utils/deferred.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
export class Deferred<T> {
2+
public promise: Promise<void>;
3+
private _resolve!: () => void;
4+
private _settled = false;
5+
private _timeout: NodeJS.Timeout;
6+
private _cleanup: () => void;
7+
private _predicate: (value: T) => boolean;
8+
9+
constructor({
10+
timeoutMs,
11+
predicate,
12+
cleanup,
13+
}: {
14+
timeoutMs: number;
15+
predicate: (value: T) => boolean;
16+
cleanup: () => void;
17+
}) {
18+
this._predicate = predicate;
19+
this._cleanup = cleanup;
20+
this.promise = new Promise<void>((resolve) => {
21+
this._resolve = () => {
22+
if (!this._settled) {
23+
this._settled = true;
24+
clearTimeout(this._timeout);
25+
this._cleanup();
26+
resolve();
27+
}
28+
};
29+
});
30+
this._timeout = setTimeout(() => {
31+
this._resolve();
32+
}, timeoutMs);
33+
}
34+
35+
update(value: T) {
36+
if (this._settled) return;
37+
if (this._predicate(value)) {
38+
this._resolve();
39+
}
40+
}
41+
}

src/utils/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
1+
export * from './deferred';
12
export * from './device-wrapper';
23
export * from './worker-wrapper';

0 commit comments

Comments
 (0)