Skip to content

Commit

Permalink
feat(timetravel): waitForPageState import/export
Browse files Browse the repository at this point in the history
  • Loading branch information
blakebyrnes committed Oct 12, 2021
1 parent ac7bd94 commit 9ac0740
Showing 3 changed files with 124 additions and 23 deletions.
46 changes: 35 additions & 11 deletions timetravel/lib/PageStateAssertions.ts
Original file line number Diff line number Diff line change
@@ -4,7 +4,9 @@ export default class PageStateAssertions {
public iterateSessionAssertionsByFrameId(
sessionId: string,
): [frameId: string, assertion: IAssertionAndResultByQuery][] {
return Object.entries(this.assertsBySessionId[sessionId]);
this.assertsBySessionId[sessionId] ??= {};
const assertions = this.assertsBySessionId[sessionId];
return Object.entries(assertions);
}

public getSessionAssertionWithQuery(
@@ -27,32 +29,49 @@ export default class PageStateAssertions {
sessionIds: Set<string>,
startingAssertions: IFrameAssertions,
): IFrameAssertions {
let state = startingAssertions;
// clone starting point
let state = clone(startingAssertions);
for (const sessionId of sessionIds) {
// need a starting place
if (!state) {
state = this.assertsBySessionId[sessionId];
state = clone(this.assertsBySessionId[sessionId]);
continue;
}

const sessionAssertionsByFrameId = this.assertsBySessionId[sessionId] ?? {};
// now compare other sessions to "starting state"
// TODO: match frames better than just "id" (check frame loaded url/name/id/dom path)
for (const [frameId, assertions] of this.iterateSessionAssertionsByFrameId(sessionId)) {
for (const [key, value] of Object.entries(assertions)) {
for (const [frameId, sessionAssertions] of Object.entries(sessionAssertionsByFrameId)) {
for (const [key, sessionAssert] of Object.entries(sessionAssertions)) {
if (!state[frameId]) continue;
const existing = state[frameId][key];
if (!existing) continue;
const sharedAssertion = state[frameId][key];
if (!sharedAssertion) continue;

if (existing.result === value.result) {
if (sharedAssertion.result === sessionAssert.result) {
continue;
}
if (typeof existing.result === 'number' && typeof value.result === 'number') {
existing.result = Math.min(existing.result, value.result);
existing.comparison = '>=';
if (
typeof sharedAssertion.result === 'number' &&
typeof sessionAssert.result === 'number'
) {
sharedAssertion.result = Math.min(sharedAssertion.result, sessionAssert.result);
sharedAssertion.comparison = '>=';
continue;
}
delete state[frameId][key];
}
}

// remove anything in the shared state that's not in this run
for (const [frameId, sharedAssertions] of Object.entries(state)) {
for (const key of Object.keys(sharedAssertions)) {
if (!sessionAssertionsByFrameId[frameId]) continue;
const sessionFrameAssertions = sessionAssertionsByFrameId[frameId];
if (!sessionFrameAssertions[key]) {
delete state[frameId][key];
}
}
}
}
return state;
}
@@ -81,6 +100,11 @@ export default class PageStateAssertions {
}
}

function clone<T>(obj: T): T {
if (!obj) return obj;
return JSON.parse(JSON.stringify(obj));
}

export interface IFrameAssertions {
[frameId: string]: IAssertionAndResultByQuery;
}
31 changes: 21 additions & 10 deletions timetravel/lib/PageStateGenerator.ts
Original file line number Diff line number Diff line change
@@ -41,6 +41,7 @@ export default class PageStateGenerator {
this.sessionsById.set(sessionId, {
tabId,
sessionId,
needsResultsVerification: true,
mainFrameIds: sessionDb.frames.mainFrameIds(),
db: sessionDb,
dbLocation: SessionDb.databaseDir,
@@ -88,6 +89,7 @@ export default class PageStateGenerator {
this.sessionsById.set(session.sessionId, {
...session,
db,
needsResultsVerification: false,
mainFrameIds: db?.frames.mainFrameIds(session.tabId),
});
}
@@ -125,7 +127,9 @@ export default class PageStateGenerator {

public async evaluate(): Promise<void> {
for (const session of this.sessionsById.values()) {
const { db, timeRange, tabId, sessionId } = session;
const { db, timeRange, tabId, sessionId, needsResultsVerification } = session;

if (!needsResultsVerification) continue;

const [start, end] = timeRange;
const timeoutMs = end - Date.now();
@@ -137,10 +141,12 @@ export default class PageStateGenerator {
session.mainFrameIds = db.frames.mainFrameIds(tabId);

const lastNavigation = this.findLastNavigation(session);
if (!lastNavigation) continue;
if (!lastNavigation) {
continue;
}

// get all dom changes since the last navigation
const domChangeRecords = db.domChanges.getChangesSince(lastNavigation.initiatedTime);
const domChangeRecords = db.domChanges.getChangesSinceNavigation(lastNavigation.id);
session.domRecording = DomChangesTable.toDomRecording(
domChangeRecords,
session.mainFrameIds,
@@ -164,7 +170,7 @@ export default class PageStateGenerator {
}
}

await this.refreshResults();
await this.checkResultsInPage();

const states = [...this.statesByName.values()];
// 1. Only keep assert results common to all sessions in a state
@@ -179,22 +185,25 @@ export default class PageStateGenerator {
PageStateAssertions.removeAssertsSharedBetweenStates(states.map(x => x.assertsByFrameId));
}

private async refreshResults(): Promise<void> {
private async checkResultsInPage(): Promise<void> {
const context = await this.browserContext;
if (context instanceof Error) throw context;

for (const session of this.sessionsById.values()) {
const paintEvents = session.domRecording.paintEvents;
if (!session.mirrorPage) await this.createMirrorPage(context, session);
// needs to be inited before destructure
const { mirrorPage } = session;
if (!session.domRecording || !session.needsResultsVerification) continue;
const paintEvents = session.domRecording?.paintEvents;
if (!paintEvents.length) {
// no paint events for page!
log.warn('No paint events for session!!', { sessionId: session.sessionId });
continue;
}

await this.createMirrorPageIfNeeded(context, session);
// create at "loaded" state
const { mirrorPage } = session;
await mirrorPage.load();
// only need to do this once?
session.needsResultsVerification = false;

for (const [frameId, assertions] of this.sessionAssertions.iterateSessionAssertionsByFrameId(
session.sessionId,
@@ -223,10 +232,11 @@ export default class PageStateGenerator {
}
}

private async createMirrorPage(
private async createMirrorPageIfNeeded(
context: IPuppetContext,
session: IPageStateSession,
): Promise<void> {
if (session.mirrorPage) return;
const networkInterceptor = MirrorNetwork.createFromSessionDb(session.db, session.tabId, {
hasResponse: true,
isGetOrDocument: true,
@@ -377,6 +387,7 @@ interface IPageStateSession {
db: SessionDb;
dbLocation: string;
sessionId: string;
needsResultsVerification: boolean;
mainFrameIds: Set<number>;
domRecording?: IDomRecording;
mirrorPage?: MirrorPage;
70 changes: 68 additions & 2 deletions timetravel/test/PageStateGenerator.test.ts
Original file line number Diff line number Diff line change
@@ -2,6 +2,8 @@ import { createSession, ITestKoaServer } from '@ulixee/hero-testing/helpers';
import { Helpers } from '@ulixee/hero-testing';
import { LoadStatus } from '@ulixee/hero-interfaces/Location';
import Core from '@ulixee/hero-core';
import SessionDb from '@ulixee/hero-core/dbs/SessionDb';
import * as Fs from 'fs';
import PageStateGenerator from '../lib/PageStateGenerator';

let koaServer: ITestKoaServer;
@@ -292,7 +294,71 @@ describe('pageStateGenerator', () => {
expect(states2['count(//H1[text()="Page 2"])'].result).toBe(1);
}, 20e3);

test.todo('can add more sessions without re-running the old ones');
test('can export and re-import states', async () => {
let changeTitle = false;
koaServer.get('/restorePage1', ctx => {
ctx.body = `<body><h1>Title 1</h1></body>`;
});
koaServer.get('/restorePage2', ctx => {
if (changeTitle) {
ctx.body = `<body><h2>Title 3</h2></body>`;
} else {
ctx.body = `<body><h2>Title 2</h2></body>`;
}
});

async function run(page: string, pageStateGenerator: PageStateGenerator) {
await new Promise(resolve => setTimeout(resolve, Math.random() * 2e3));
const { tab, session } = await createSession();
const startTime = Date.now();
await tab.goto(`${koaServer.baseUrl}/${page}`);
await tab.waitForLoad('PaintingStable');
await tab.close();
pageStateGenerator.addSession(session.sessionState.db, tab.id, [startTime, Date.now()]);

const state = page.endsWith('1') ? '1' : '2';
pageStateGenerator.addState(state, session.id);
}

const psg1 = new PageStateGenerator('c');

await Promise.all([
run('restorePage1', psg1),
run('restorePage1', psg1),
run('restorePage2', psg1),
run('restorePage2', psg1),
]);

await psg1.evaluate();

const state1 = psg1.export('1');
expect(state1).toBeTruthy();
expect(state1.assertions.length).toBeGreaterThanOrEqual(3);
expect(state1.sessions).toHaveLength(2);

const state2 = psg1.export('2');
expect(state2).toBeTruthy();
expect(state2.assertions.length).toBeGreaterThanOrEqual(3);
expect(state2.sessions).toHaveLength(2);

const psg2 = new PageStateGenerator('c');
psg2.import(state1);
psg2.import(state2);

changeTitle = true;
// add sessions to the second round
await Promise.all([run('restorePage1', psg2), run('restorePage2', psg2)]);
await psg2.evaluate();

const state1Round2 = psg2.export('1');
const state2Round2 = psg2.export('2');

expect(state1Round2.sessions).toHaveLength(3);
expect(state2Round2.sessions).toHaveLength(3);

test.todo('can save and reload results');
expect(state1Round2.assertions).toEqual(state1.assertions);
// should take into account the new change
expect(state2Round2.assertions).not.toEqual(state2.assertions);
expect(state2Round2.assertions.filter(x => x.toString().includes('Title 2'))).toHaveLength(0);
});
});

0 comments on commit 9ac0740

Please sign in to comment.