Skip to content
Closed
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
139 changes: 135 additions & 4 deletions packages/zone.js/lib/zone-spec/fake-async-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ interface MacroTaskOptions {
callbackArgs?: any;
}

// Need this because mock clocks might be installed (other than fakeAsync!)
const originalSetImmediate = global.setImmediate;
const originalTimeout = global.setTimeout;
const OriginalDate = global.Date;
// Since when we compile this file to `es2015`, and if we define
// this `FakeDate` as `class FakeDate`, and then set `FakeDate.prototype`
Expand Down Expand Up @@ -274,6 +277,19 @@ class Scheduler {
}
}

executeNextTask(doTick?: (elapsed: number) => void): void {
const current = this._schedulerQueue.shift();
if (current === undefined) {
return;
}
doTick?.(current.endTime - this._currentTickTime);
this._currentTickTime = current.endTime;
current.func.apply(
global,
current.isRequestAnimationFrame ? [this._currentTickTime] : current.args,
);
}

flushOnlyPendingTimers(doTick?: (elapsed: number) => void): number {
if (this._schedulerQueue.length === 0) {
return 0;
Expand Down Expand Up @@ -547,6 +563,99 @@ class FakeAsyncTestZoneSpec implements ZoneSpec {
FakeAsyncTestZoneSpec.resetDate();
}

private tickMode: {counter: number; mode: 'manual' | 'automatic'} = {
counter: 0,
mode: 'manual',
};

/** @experimental */
setTickMode(mode: 'manual' | 'automatic', doTick?: (elapsed: number) => void) {
if (mode === this.tickMode.mode) {
return;
}
this.tickMode.counter++;
this.tickMode.mode = mode;
if (mode === 'automatic') {
this.advanceUntilModeChanges(doTick);
}
}

private advanceUntilModeChanges(doTick?: (elapsed: number) => void): void {
FakeAsyncTestZoneSpec.assertInZone();
const specZone = Zone.current;
const {counter} = this.tickMode;

Zone.root.run(async () => {
// autoTick with fakeAsync is a bit awkward because microtasks are
// controlled by the scheduler as well. This means that we have to
// manually flush microtasks before allowing real macrotasks to execute.
// Waiting for a macrotask would otherwise allow the browser to execute
// other macrotasks before the currently scheduled microtasks are flushed.
await safeAsync(async () => {
await void 0;
specZone.run(() => {
this.flushMicrotasks();
});
});

if (this.tickMode.counter !== counter) {
return;
}

while (true) {
await safeAsync(() => this.newMacrotask(specZone));

if (this.tickMode.counter !== counter) {
return;
}

await safeAsync(() =>
specZone.run(() => {
this._scheduler.executeNextTask(doTick);
}),
);
}
});
}

// Waits until a new macro task.
//
// Used with autoTick(), which is meant to act when the test is waiting, we
// need to insert ourselves in the macro task queue.
//
// @return {!Promise<undefined>}
private async newMacrotask(specZone: Zone) {
if (originalSetImmediate) {
// setImmediate is much faster than setTimeout in node
await new Promise((resolve) => {
originalSetImmediate(resolve);
});
} else {
// MessageChannel ensures that setTimeout is not throttled to 4ms.
// https://developer.mozilla.org/en-US/docs/Web/API/setTimeout#reasons_for_delays_longer_than_specified
// https://stackblitz.com/edit/stackblitz-starters-qtlpcc
// Note: This trick does not work in Safari, which will still throttle the
// setTimeout
const channel = new MessageChannel();
await new Promise((resolve) => {
channel.port1.onmessage = resolve;
channel.port2.postMessage(undefined);
});
channel.port1.close();
channel.port2.close();
// setTimeout ensures that we interleave with other setTimeouts.
await new Promise((resolve) => {
originalTimeout(resolve);
});
}

// flush any microtasks that were scheduled from the tasks that ran during
// the timeout.
specZone.run(() => {
this.flushMicrotasks();
});
}

tickToNext(
steps: number = 1,
doTick?: (elapsed: number) => void,
Expand Down Expand Up @@ -676,10 +785,16 @@ class FakeAsyncTestZoneSpec implements ZoneSpec {
);
break;
case 'XMLHttpRequest.send':
throw new Error(
'Cannot make XHRs from within a fake async test. Request URL: ' +
(task.data as any)['url'],
);
if (this.tickMode.mode === 'manual') {
throw new Error(
'Cannot make XHRs from within a fake async test. Request URL: ' +
(task.data as any)['url'],
);
}
// When using automatic ticking, we allow the XHR to be handled in a truly async form
// by the parent/delegate Zone because auto ticking FakeAsync is not strictly synchronous.
task = delegate.scheduleTask(target, task);
break;
case 'requestAnimationFrame':
case 'webkitRequestAnimationFrame':
case 'mozRequestAnimationFrame':
Expand Down Expand Up @@ -1034,3 +1149,19 @@ export function patchFakeAsyncTest(Zone: ZoneType): void {

Scheduler.nextId = Scheduler.getNextId();
}

async function safeAsync(fn: () => Promise<void>): Promise<void> {
try {
return await fn();
} catch (e) {
hostReportError(e);
}
}

function hostReportError(e: unknown) {
Zone.root.run(() => {
originalTimeout(() => {
throw e;
});
});
}
80 changes: 80 additions & 0 deletions packages/zone.js/test/zone-spec/fake-async-test.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1152,6 +1152,86 @@ describe('FakeAsyncTestZoneSpec', () => {
emptyRun,
),
);

describe('setTickMode', () => {
it('should execute timers automatically when mode is set to automatic', async () => {
let ran = false;
await new Promise<void>((resolve) => {
fakeAsyncTestZone.run(() => {
setTimeout(() => {
ran = true;
resolve();
}, 10);
testZoneSpec.setTickMode('automatic');
});
});

expect(ran).toBe(true);
fakeAsyncTestZone.run(() => {
testZoneSpec.setTickMode('manual');
});
});

it('should execute multiple timers automatically', async () => {
const log: string[] = [];
const promise = new Promise<void>((resolve) => {
fakeAsyncTestZone.run(() => {
setTimeout(() => {
log.push('timer A');
}, 10);
setTimeout(() => {
log.push('timer B');
resolve();
}, 20);
testZoneSpec.setTickMode('automatic');
});
});

await promise;
expect(log).toEqual(['timer A', 'timer B']);
fakeAsyncTestZone.run(() => {
testZoneSpec.setTickMode('manual');
});
});

it('should allow mutation observers to execute between timers', async () => {
if (isNode) {
return;
}
const log: string[] = [];
const el = document.createElement('div');
let observer: MutationObserver;

await new Promise<void>((resolve) => {
fakeAsyncTestZone.run(() => {
document.body.appendChild(el);
observer = new MutationObserver(() => {
log.push('mutation');
});
observer.observe(el, {attributes: true});

setTimeout(() => {
log.push('timer A');
el.style.width = '100px'; // trigger mutation observer
}, 10);
setTimeout(() => {
debugger;
log.push('timer B');
resolve();
}, 10);

testZoneSpec.setTickMode('automatic');
});
});

expect(log).toEqual(['timer A', 'mutation', 'timer B']);
fakeAsyncTestZone.run(() => {
testZoneSpec.setTickMode('manual');
observer.disconnect();
});
document.body.removeChild(el);
});
});
});

class Log<T> {
Expand Down