From d558dc81130fdd1bdc484c4275ac3d79bacb069a Mon Sep 17 00:00:00 2001 From: temariq Date: Fri, 28 Jul 2023 16:27:46 +0300 Subject: [PATCH] feat(perftool): add beforeTest callback support, add request interception --- .eslintrc | 4 + packages/perftool/lib/api/external.ts | 3 + packages/perftool/lib/api/intercept.ts | 130 ++++++++++++++++++ .../lib/client/__spec__/index.test.ts | 8 +- .../lib/client/__spec__/input.test.ts | 90 ++++++------ packages/perftool/lib/client/index.ts | 19 +-- packages/perftool/lib/client/input.ts | 38 +++-- .../perftool/lib/client/measurement/runner.ts | 9 +- .../controller/__spec__/clientScript.test.ts | 29 ++-- .../controller/__spec__/controller.test.ts | 42 ++---- .../lib/controller/__spec__/executor.test.ts | 28 ++-- .../lib/controller/__spec__/planner.test.ts | 24 ++-- .../perftool/lib/controller/clientScript.ts | 17 ++- .../perftool/lib/controller/controller.ts | 15 +- packages/perftool/lib/controller/executor.ts | 27 ++-- packages/perftool/lib/controller/index.ts | 2 +- packages/perftool/lib/controller/planner.ts | 12 +- packages/perftool/lib/index.ts | 5 +- packages/perftool/lib/statistics/index.ts | 8 +- packages/perftool/lib/typings/window.d.ts | 7 +- packages/perftool/package.json | 3 + pnpm-lock.yaml | 24 +++- 22 files changed, 334 insertions(+), 210 deletions(-) create mode 100644 packages/perftool/lib/api/external.ts create mode 100644 packages/perftool/lib/api/intercept.ts diff --git a/.eslintrc b/.eslintrc index 3d022cc..3477dd9 100644 --- a/.eslintrc +++ b/.eslintrc @@ -16,6 +16,7 @@ "@typescript-eslint/camelcase": "off", "no-bitwise": "off", "no-restricted-syntax": "off", + "no-underscore-dangle": "off", "func-names": "off", "no-return-assign": "off", "generator-star-spacing": "off", @@ -39,6 +40,7 @@ } ], "padding-line-between-statements": "off", + "default-param-last": "off", "implicit-arrow-linebreak": "off", "no-plusplus": "off", "max-classes-per-file": "off", @@ -50,6 +52,7 @@ "function-paren-newline": "off", "no-param-reassign": "off", "no-shadow": "warn", + "no-eval": "warn", "consistent-return": "off", "prettier/prettier": "error", "@typescript-eslint/explicit-function-return-type": "off", @@ -66,6 +69,7 @@ "react/sort-comp": "off", "react/no-array-index-key": "off", "react-hooks/exhaustive-deps": "warn", + "react-hooks/rules-of-hooks": "off", "import/prefer-default-export": "off", "import/order": [ "error", diff --git a/packages/perftool/lib/api/external.ts b/packages/perftool/lib/api/external.ts new file mode 100644 index 0000000..4125d64 --- /dev/null +++ b/packages/perftool/lib/api/external.ts @@ -0,0 +1,3 @@ +import type { InterceptParams } from './intercept'; + +export const intercept = async (params: InterceptParams): Promise => window._perftool_intercept?.(params); diff --git a/packages/perftool/lib/api/intercept.ts b/packages/perftool/lib/api/intercept.ts new file mode 100644 index 0000000..1eb998b --- /dev/null +++ b/packages/perftool/lib/api/intercept.ts @@ -0,0 +1,130 @@ +import { Page, HTTPRequest } from 'puppeteer'; +import { minimatch } from 'minimatch'; +import mime from 'mime'; +import fsPromises from 'fs/promises'; +import path from 'path'; + +import { JSONSerializable } from '../utils/types'; +import BaseError from '../utils/baseError'; +import { debug } from '../utils/logger'; +import CWD from '../utils/cwd'; + +type Method = string; + +export const enum FakeResponseType { + JSON = 'json', + File = 'file', + Abort = 'abort', +} + +export type InterceptParams = { + method?: Method; + source: string; +} & ( + | { responseType: FakeResponseType.Abort; response: undefined } + | { responseType: FakeResponseType.File; response: string } + | { responseType?: FakeResponseType.JSON; response: JSONSerializable } +); + +type FakeResponse = { mimeType: string; data: string | Buffer }; + +class BadExtensionError extends BaseError {} +class BadPathError extends BaseError {} + +const ANY_METHOD = 'ANY'; + +export async function useInterceptApi(page: Page): Promise { + const requestReplacementByMethodMap: Map> = new Map(); + + function getRequestReplacement(url: string, method: Method): FakeResponse | null | void { + if (!requestReplacementByMethodMap.has(method)) { + return; + } + + if (requestReplacementByMethodMap.get(method)!.has(url)) { + return requestReplacementByMethodMap.get(method)!.get(url); + } + + for (const [source, response] of requestReplacementByMethodMap.get(method)!) { + if (minimatch(url, source)) { + return response; + } + } + } + + async function setRequestReplacement({ + method = ANY_METHOD, + response, + responseType = FakeResponseType.File, + source, + }: InterceptParams) { + if (!requestReplacementByMethodMap.has(method)) { + requestReplacementByMethodMap.set(method, new Map()); + } + + if (responseType === FakeResponseType.Abort) { + debug(`[Intercept] Set up ABORT ${method} ${source}`); + requestReplacementByMethodMap.get(method)!.set(source, null); + } + + if (responseType === FakeResponseType.JSON) { + debug(`[Intercept] Set up JSON response ${method} ${source}`); + requestReplacementByMethodMap.get(method)!.set(source, { + mimeType: 'application/json', + data: JSON.stringify(response), + }); + } + + if (responseType === FakeResponseType.File) { + if (typeof response !== 'string') { + throw new BadPathError('Request replacement file path is not a string'); + } + + const mimeType = mime.getType(response); + + if (!mimeType) { + throw new BadExtensionError('Could not get a mime type from file extension'); + } + + const data = await fsPromises.readFile(path.resolve(CWD, response)); + + debug(`[Intercept] Set up FILE response ${method} ${source} -> ${response}`); + + requestReplacementByMethodMap.get(method)!.set(source, { + mimeType, + data, + }); + } + } + + async function handleInterceptedRequest(req: HTTPRequest) { + if (req.isInterceptResolutionHandled()) { + return; + } + + const response = getRequestReplacement(req.url(), req.method()) || getRequestReplacement(req.url(), ANY_METHOD); + + if (response == null) { + debug(`[Intercept] ${req.method()} ${req.url()} aborted`); + await req.abort(); + return; + } + + if (!response) { + await req.continue(); + return; + } + + await req.respond({ + contentType: response.mimeType, + body: response.data, + }); + + debug(`[Intercept] ${req.method()} ${req.url()} intercepted, respond ${response.mimeType}`); + } + + await page.exposeFunction('_perftool_intercept', setRequestReplacement); + await page.setRequestInterception(true); + + page.on('request', handleInterceptedRequest); +} diff --git a/packages/perftool/lib/client/__spec__/index.test.ts b/packages/perftool/lib/client/__spec__/index.test.ts index b355a48..e08e545 100644 --- a/packages/perftool/lib/client/__spec__/index.test.ts +++ b/packages/perftool/lib/client/__spec__/index.test.ts @@ -24,10 +24,10 @@ describe('client/createPerfToolClient', () => { })); jest.unstable_mockModule('../input', () => ({ - resolveTests: (resolveTestsMock = jest.fn(() => [fakeTest])), + resolveTest: (resolveTestsMock = jest.fn(() => fakeTest)), })); - window.finish = jest.fn() as typeof window.finish; + window._perftool_finish = jest.fn() as typeof window._perftool_finish; const config = {} as Config; const subjects = [{}] as Subject[]; @@ -48,7 +48,7 @@ describe('client/createPerfToolClient', () => { state: fakeTest.state, }); - expect(window.finish).toHaveBeenCalledTimes(1); - expect(window.finish).toHaveBeenCalledWith([fakeResult]); + expect(window._perftool_finish).toHaveBeenCalledTimes(1); + expect(window._perftool_finish).toHaveBeenCalledWith(fakeResult); }); }); diff --git a/packages/perftool/lib/client/__spec__/input.test.ts b/packages/perftool/lib/client/__spec__/input.test.ts index d84ef64..3df834a 100644 --- a/packages/perftool/lib/client/__spec__/input.test.ts +++ b/packages/perftool/lib/client/__spec__/input.test.ts @@ -3,7 +3,7 @@ import { jest } from '@jest/globals'; import type { Task } from '../measurement/types'; import type { Subject } from '../measurement/runner'; -describe('client/input/getTests', () => { +describe('client/input/getTest', () => { let assertMock = {} as jest.Mock; beforeEach(() => { jest.unstable_mockModule('../../utils/assert', () => ({ @@ -20,95 +20,88 @@ describe('client/input/getTests', () => { jest.resetModules(); }); - it('should transform raw tests with ids into tests with component and task', async () => { + it('should transform raw test with ids into test with component and task', async () => { const fakeTasks = [{ id: 'fakeTaskId1' }, { id: 'fakeTaskId2' }, { id: 'fakeTaskId3' }] as Task[]; const fakeSubjects = [ { id: 'fakeSubjectId1' }, { id: 'fakeSubjectId2' }, { id: 'fakeSubjectId3' }, ] as Subject[]; - const state1 = { state1: '' }; - const state2 = { state2: '' }; - const fakeRawTests = [ - { subjectId: fakeSubjects[0].id, taskId: fakeTasks[0].id, state: state1 }, - { subjectId: fakeSubjects[1].id, taskId: fakeTasks[2].id, state: state2 }, - ]; + const state = { state: '' }; + const fakeRawTest = { subjectId: fakeSubjects[0].id, taskId: fakeTasks[0].id, state }; - const { getTests } = await import('../input'); + const { getTest } = await import('../input'); - const result = getTests(fakeRawTests, { tasks: fakeTasks, subjects: fakeSubjects }); + const result = getTest(fakeRawTest, { tasks: fakeTasks, subjects: fakeSubjects }); - expect(result).toEqual([ - { subject: fakeSubjects[0], task: fakeTasks[0], state: state1 }, - { subject: fakeSubjects[1], task: fakeTasks[2], state: state2 }, - ]); - - expect(assertMock).toHaveBeenCalledTimes(fakeRawTests.length); + expect(result).toEqual({ subject: fakeSubjects[0], task: fakeTasks[0], state }); + expect(assertMock).toHaveBeenCalledTimes(1); }); it('should throw if task not found', async () => { const fakeTasks = [{ id: 'fakeTaskId1' }] as Task[]; const fakeSubjects = [{ id: 'fakeSubjectId1' }] as Subject[]; - const fakeRawTests = [{ subjectId: fakeSubjects[0].id, taskId: 'fakeTaskId2', state: {} }]; + const fakeRawTest = { subjectId: fakeSubjects[0].id, taskId: 'fakeTaskId2', state: {} }; - const { getTests } = await import('../input'); + const { getTest } = await import('../input'); expect(() => { - getTests(fakeRawTests, { tasks: fakeTasks, subjects: fakeSubjects }); + getTest(fakeRawTest, { tasks: fakeTasks, subjects: fakeSubjects }); }).toThrow(); - expect(assertMock).toHaveBeenCalledTimes(fakeRawTests.length); + expect(assertMock).toHaveBeenCalledTimes(1); }); it('should throw if subject not found', async () => { const fakeTasks = [{ id: 'fakeTaskId1' }] as Task[]; const fakeSubjects = [{ id: 'fakeSubjectId1' }] as Subject[]; - const fakeRawTests = [{ subjectId: 'fakeSubjectId2', taskId: fakeTasks[0].id, state: {} }]; + const fakeRawTest = { subjectId: 'fakeSubjectId2', taskId: fakeTasks[0].id, state: {} }; - const { getTests } = await import('../input'); + const { getTest } = await import('../input'); expect(() => { - getTests(fakeRawTests, { tasks: fakeTasks, subjects: fakeSubjects }); + getTest(fakeRawTest, { tasks: fakeTasks, subjects: fakeSubjects }); }).toThrow(); - expect(assertMock).toHaveBeenCalledTimes(fakeRawTests.length); + expect(assertMock).toHaveBeenCalledTimes(1); }); }); -describe('client/input/resolveTests', () => { +describe('client/input/resolveTest', () => { afterEach(() => { jest.restoreAllMocks(); jest.resetModules(); - delete window.tests; + delete window._perftool_api_ready; + delete window._perftool_test; }); - it('should call getTests if window.tests is present', async () => { - const tasks = [] as Task[]; - const subjects = [] as Subject[]; - const fakeRawTests = [] as any[]; - const fakeResult = [] as any[]; + it('should call getTests if window._perftool_test is present', async () => { + const tasks = [{ id: 'fakeTaskId' }] as any[]; + const subjects = [{ id: 'fakeSubjectId2' }] as any[]; + const fakeRawTest = { subjectId: 'fakeSubjectId2', taskId: 'fakeTaskId', state: {} }; + const fakeResult = { subject: subjects[0], task: tasks[0], state: fakeRawTest.state }; - window.tests = fakeRawTests; + window._perftool_test = fakeRawTest; - const { resolveTests } = await import('../input'); - const result = await resolveTests({ tasks, subjects }); + const { resolveTest } = await import('../input'); + const result = await resolveTest({ tasks, subjects }); expect(result).toEqual(fakeResult); - - expect(window.tests).toEqual(fakeRawTests); + expect(window._perftool_test).toEqual(fakeRawTest); }); - it("should imitate an array in window.tests if it's not present", async () => { - const tasks = [] as Task[]; - const subjects = [] as Subject[]; - const fakeRawTests = [] as any[]; - const fakeResult = [] as any[]; + it("should create window._perftool_api_ready if test isn't present", async () => { + const tasks = [{ id: 'fakeTaskId' }] as any[]; + const subjects = [{ id: 'fakeSubjectId2' }] as any[]; + const fakeRawTest = { subjectId: 'fakeSubjectId2', taskId: 'fakeTaskId', state: {} }; + const fakeResult = { subject: subjects[0], task: tasks[0], state: fakeRawTest.state }; - const { resolveTests } = await import('../input'); - const resultPromise = resolveTests({ tasks, subjects }); + const { resolveTest } = await import('../input'); + const resultPromise = resolveTest({ tasks, subjects }); - window.tests?.push(...fakeRawTests); + window._perftool_test = fakeRawTest; + window._perftool_api_ready?.(); expect(resultPromise).resolves.toEqual(fakeResult); }); @@ -116,12 +109,13 @@ describe('client/input/resolveTests', () => { it('should reject if error while transforming tests', async () => { const tasks = [] as Task[]; const subjects = [] as Subject[]; - const fakeRawTests = [{ subjectId: 'fakeSubjectId2', taskId: 'fakeTaskId', state: {} }]; + const fakeRawTest = { subjectId: 'fakeSubjectId2', taskId: 'fakeTaskId', state: {} }; - const { resolveTests } = await import('../input'); - const resultPromise = resolveTests({ tasks, subjects }); + const { resolveTest } = await import('../input'); + const resultPromise = resolveTest({ tasks, subjects }); - window.tests?.push(...fakeRawTests); + window._perftool_test = fakeRawTest; + window._perftool_api_ready?.(); expect(resultPromise).rejects.toThrow(); }); diff --git a/packages/perftool/lib/client/index.ts b/packages/perftool/lib/client/index.ts index 3a09cf8..efc724a 100644 --- a/packages/perftool/lib/client/index.ts +++ b/packages/perftool/lib/client/index.ts @@ -3,7 +3,7 @@ import { debug } from '../utils/logger'; import type { Task } from './measurement/types'; import { runTask, Subject } from './measurement/runner'; -import { resolveTests } from './input'; +import { resolveTest } from './input'; type CreatePerfToolClientParams[]> = { subjects: Subject[]; @@ -21,19 +21,10 @@ export async function createPerfToolClient[]>({ debug('Available tasks: ', tasks); debug('Config: ', config); - const tests = await resolveTests({ tasks, subjects }); - const resultPromises = []; + const { task, subject, state } = await resolveTest({ tasks, subjects }); - debug(`Running ${tests.length} tests`); - for (const { task, subject, state } of tests) { - const resultPromise = runTask({ task, subject, config, state }); + debug(`Running...`); - resultPromises.push(resultPromise); - } - - debug('Waiting for all tests to complete...'); - const results = await Promise.all(resultPromises); - - debug('All tests complete, calling window.finish'); - await window.finish(results); + const result = await runTask({ task, subject, config, state }); + await window._perftool_finish!(result); } diff --git a/packages/perftool/lib/client/input.ts b/packages/perftool/lib/client/input.ts index d8f8452..7172987 100644 --- a/packages/perftool/lib/client/input.ts +++ b/packages/perftool/lib/client/input.ts @@ -15,39 +15,35 @@ type ResolveTestsParams[]> = { subjects: Subject[]; }; -export function getTests[]>( - rawTests: RawTest[], +export function getTest[]>( + { subjectId, taskId, state }: RawTest, { tasks, subjects }: ResolveTestsParams, -): Test[] { - return rawTests.map(({ subjectId, taskId, state }) => { - const subject = subjects.find(({ id }) => subjectId === id); - const task = tasks.find(({ id }) => taskId === id); +): Test { + const subject = subjects.find(({ id }) => subjectId === id); + const task = tasks.find(({ id }) => taskId === id); - assert(subject && task); + assert(subject && task); - return { subject, task, state }; - }); + return { subject, task, state }; } -export async function resolveTests[]>( +export async function resolveTest[]>( params: ResolveTestsParams, -): Promise[]> { +): Promise> { /** * @see utils/window.d.ts */ - if (Array.isArray(window.tests)) { - return getTests(window.tests, params); + if (window._perftool_test) { + return getTest(window._perftool_test, params); } return new Promise((resolve, reject) => { - window.tests = { - push: (...items) => { - try { - resolve(getTests(items, params)); - } catch (err) { - reject(err); - } - }, + window._perftool_api_ready = () => { + try { + resolve(getTest(window._perftool_test!, params)); + } catch (err) { + reject(err); + } }; }); } diff --git a/packages/perftool/lib/client/measurement/runner.ts b/packages/perftool/lib/client/measurement/runner.ts index 93733f8..18c712f 100644 --- a/packages/perftool/lib/client/measurement/runner.ts +++ b/packages/perftool/lib/client/measurement/runner.ts @@ -13,7 +13,7 @@ class TimeoutError extends BaseError {} export type Subject = { id: string; - Component: ComponentType; + Component: ComponentType & { beforeTest?: () => Promise | void }; }; type RunTaskParams> = { @@ -32,7 +32,7 @@ export type RunTaskResult> = { state?: TaskState; } & (SuccessResult | ErrorResult); -export function runTask>({ +export async function runTask>({ task, state, subject, @@ -46,6 +46,11 @@ export function runTask>({ debug('Running test\n', `TaskId: ${meta.taskId}\n`, `SubjectId: ${meta.subjectId}`); debug('Task config: ', config); + if (typeof subject.Component.beforeTest === 'function') { + debug('Running beforeTest'); + await subject.Component.beforeTest(); + } + return Promise.race([ task .run({ Subject: subject.Component, config, container, state }) diff --git a/packages/perftool/lib/controller/__spec__/clientScript.test.ts b/packages/perftool/lib/controller/__spec__/clientScript.test.ts index 4ff74d4..50fc40f 100644 --- a/packages/perftool/lib/controller/__spec__/clientScript.test.ts +++ b/packages/perftool/lib/controller/__spec__/clientScript.test.ts @@ -1,35 +1,30 @@ -import { insertTests, createInsertionScriptContent } from '../clientScript'; +import { bootstrapTest, createInsertionScriptContent } from '../clientScript'; -const tests = [ - { subjectId: '1', taskId: '1', state: {} }, - { subjectId: '3', taskId: 'fake', state: {} }, -]; +const test = { subjectId: '3', taskId: 'fake', state: {} }; describe('controller/insertTests', () => { afterEach(() => { - delete window.tests; + delete window._perftool_test; }); - it('should parse JSON serialized tests and push in window.tests if present', () => { - window.tests = []; + it('should call window._perftool_api_ready if present', () => { + bootstrapTest(JSON.stringify(test)); - insertTests(JSON.stringify(tests)); - - expect(window.tests).toEqual(tests); + expect(window._perftool_test).toEqual(test); }); - it('should parse JSON serialized tests and push in window.tests if not present', () => { - insertTests(JSON.stringify(tests)); + it('should parse JSON serialized test and set in window._perftool_test', () => { + bootstrapTest(JSON.stringify(test)); - expect(window.tests).toEqual(tests); + expect(window._perftool_test).toEqual(test); }); }); describe('controller/createInsertionScriptContent', () => { it('should have insertTests body and call with serialized tests', () => { - const insertionScript = createInsertionScriptContent(tests); + const insertionScript = createInsertionScriptContent(test); - expect(insertionScript.includes(insertTests.toString())).toEqual(true); - expect(insertionScript.includes(`insertTests('${JSON.stringify(tests)}')`)).toEqual(true); + expect(insertionScript.includes(bootstrapTest.toString())).toEqual(true); + expect(insertionScript.includes(`bootstrapTest('${JSON.stringify(test)}')`)).toEqual(true); }); }); diff --git a/packages/perftool/lib/controller/__spec__/controller.test.ts b/packages/perftool/lib/controller/__spec__/controller.test.ts index b9c86e6..86a8e2c 100644 --- a/packages/perftool/lib/controller/__spec__/controller.test.ts +++ b/packages/perftool/lib/controller/__spec__/controller.test.ts @@ -8,18 +8,11 @@ import BaseError from '../../utils/baseError'; describe('controller/TestController', () => { it('should request a schedule from planner and execute tasks in parallel yielding the result', async () => { - const tests: Test[][] = [ - [ - { subjectId: 'fakeId1', taskId: 'fakeTask1' }, - { subjectId: 'fakeId2', taskId: 'fakeTask2' }, - { subjectId: 'fakeId3', taskId: 'fakeTask3' }, - ], - [ - { subjectId: 'fakeId2', taskId: 'fakeTask2' }, - { subjectId: 'fakeId3', taskId: 'fakeTask3' }, - ], - [{ subjectId: 'fakeId3', taskId: 'fakeTask3' }], - [{ subjectId: 'fakeId4', taskId: 'fakeTask5' }], + const tests: Test[] = [ + { subjectId: 'fakeId1', taskId: 'fakeTask1' }, + { subjectId: 'fakeId2', taskId: 'fakeTask2' }, + { subjectId: 'fakeId3', taskId: 'fakeTask3' }, + { subjectId: 'fakeId4', taskId: 'fakeTask5' }, ]; const config = { @@ -57,18 +50,11 @@ describe('controller/TestController', () => { }); it('should not pass dry runs to the result', async () => { - const tests: Test[][] = [ - [ - { subjectId: 'fakeId1', taskId: 'fakeTask1', type: 'dry' }, - { subjectId: 'fakeId2', taskId: 'fakeTask2' }, - { subjectId: 'fakeId3', taskId: 'fakeTask3' }, - ], - [ - { subjectId: 'fakeId2', taskId: 'fakeTask2', type: 'dry' }, - { subjectId: 'fakeId3', taskId: 'fakeTask3' }, - ], - [{ subjectId: 'fakeId3', taskId: 'fakeTask3', type: 'dry' }], - [{ subjectId: 'fakeId4', taskId: 'fakeTask5' }], + const tests: Test[] = [ + { subjectId: 'fakeId1', taskId: 'fakeTask1', type: 'dry' }, + { subjectId: 'fakeId2', taskId: 'fakeTask2' }, + { subjectId: 'fakeId3', taskId: 'fakeTask3', type: 'dry' }, + { subjectId: 'fakeId4', taskId: 'fakeTask5' }, ]; const config = { @@ -96,13 +82,13 @@ describe('controller/TestController', () => { generatorResult.push(result); } - expect(new Set(generatorResult)).toEqual(new Set(tests.filter((test) => test[0]?.type !== 'dry'))); + expect(new Set(generatorResult)).toEqual(new Set(tests.filter((test) => test?.type !== 'dry'))); }); it('should throw if execute returns an error', async () => { - const tests: Test[][] = [ - [{ subjectId: 'fakeId3', taskId: 'fakeTask3', type: 'dry' }], - [{ subjectId: 'fakeId4', taskId: 'fakeTask5' }], + const tests: Test[] = [ + { subjectId: 'fakeId3', taskId: 'fakeTask3', type: 'dry' }, + { subjectId: 'fakeId4', taskId: 'fakeTask5' }, ]; const config = { diff --git a/packages/perftool/lib/controller/__spec__/executor.test.ts b/packages/perftool/lib/controller/__spec__/executor.test.ts index 53939f8..8b97771 100644 --- a/packages/perftool/lib/controller/__spec__/executor.test.ts +++ b/packages/perftool/lib/controller/__spec__/executor.test.ts @@ -50,8 +50,9 @@ describe('controller/Executor', () => { let addScriptTagMock = {} as jest.Mock; let closeMock = {} as jest.Mock; let createInsertionScriptContentMock = {} as jest.Mock; - const tests: Test[] = [{ subjectId: 'fakeId3', taskId: 'fakeTask3', type: 'dry' }]; - const result = [{ ...tests[0], state: {} }]; + let useInterceptApiMock = {} as jest.Mock; + const test: Test = { subjectId: 'fakeId3', taskId: 'fakeTask3', type: 'dry' }; + const result = { ...test, state: {} }; const insertionScriptContent = 'some script content'; jest.unstable_mockModule('puppeteer', () => ({ default: { @@ -70,6 +71,9 @@ describe('controller/Executor', () => { jest.unstable_mockModule('../clientScript', () => ({ createInsertionScriptContent: (createInsertionScriptContentMock = jest.fn(() => insertionScriptContent)), })); + jest.unstable_mockModule('../../api/intercept', () => ({ + useInterceptApi: (useInterceptApiMock = jest.fn()), + })); const { default: Executor } = await import('../executor'); @@ -81,7 +85,7 @@ describe('controller/Executor', () => { const cache = createFakeCache(); const executor = await Executor.create(config, cache, port); - const execResult = await executor.execute(tests); + const execResult = await executor.execute(test); expect(newPageMock).toHaveBeenCalledTimes(1); @@ -89,25 +93,25 @@ describe('controller/Executor', () => { expect(gotoMock).toHaveBeenCalledWith(`http://localhost:${port}/`); expect(exposeFunctionMock).toHaveBeenCalledTimes(1); - expect(exposeFunctionMock.mock.calls[0][0]).toEqual('finish'); + expect(exposeFunctionMock.mock.calls[0][0]).toEqual('_perftool_finish'); + + expect(useInterceptApiMock).toHaveBeenCalledTimes(1); expect(addScriptTagMock).toHaveBeenCalledTimes(1); expect(addScriptTagMock).toHaveBeenCalledWith({ content: insertionScriptContent }); expect(createInsertionScriptContentMock).toHaveBeenCalledTimes(1); - expect(createInsertionScriptContentMock).toHaveBeenCalledWith([{ ...tests[0], state: {} }]); + expect(createInsertionScriptContentMock).toHaveBeenCalledWith({ ...test, state: {} }); expect(closeMock).toHaveBeenCalledTimes(1); expect(execResult).toEqual(result); - result[0].state = { changedState: true }; + result.state = { changedState: true }; - await executor.execute(tests); + await executor.execute(test); - expect(createInsertionScriptContentMock).toHaveBeenLastCalledWith([ - { ...tests[0], state: { changedState: true } }, - ]); + expect(createInsertionScriptContentMock).toHaveBeenLastCalledWith({ ...test, state: { changedState: true } }); }); it('should return error on page timeout', async () => { @@ -138,7 +142,7 @@ describe('controller/Executor', () => { const cache = createFakeCache(); const executor = await Executor.create(config, cache, port); - const execResult = await executor.execute([]); + const execResult = await executor.execute({ subjectId: 'fakeId3', taskId: 'fakeTask3' }); expect(closeMock).toHaveBeenCalledTimes(1); expect(execResult).toBeInstanceOf(Error); @@ -169,7 +173,7 @@ describe('controller/Executor', () => { await executor.finalize(); expect(closeMock).toHaveBeenCalledTimes(1); - expect(executor.execute([])).rejects.toThrow(); + expect(executor.execute({ subjectId: 'fakeId3', taskId: 'fakeTask3', type: 'dry' })).rejects.toThrow(); expect(newPageMock).toHaveBeenCalledTimes(0); await executor.finalize(); diff --git a/packages/perftool/lib/controller/__spec__/planner.test.ts b/packages/perftool/lib/controller/__spec__/planner.test.ts index e9120c8..14d657c 100644 --- a/packages/perftool/lib/controller/__spec__/planner.test.ts +++ b/packages/perftool/lib/controller/__spec__/planner.test.ts @@ -70,7 +70,7 @@ describe('controller/Planner', () => { jest.resetModules(); }); - it('should yield idempotent tasks once and in one group', async () => { + it('should yield idempotent tasks once', async () => { const config = { dryRunTimes: 3, retries: 10, @@ -96,13 +96,9 @@ describe('controller/Planner', () => { const planner = new Planner(config, tasks, modules); - const result = []; - - for (const testGroup of planner.plan()) { - result.push(testGroup); - } + const result = [...planner.plan()]; - expect(new Set(result[0])).toEqual(new Set(expectedResult)); + expect(new Set(result)).toEqual(new Set(expectedResult)); }); it('should yield non-idempotent tasks given number of times', async () => { @@ -120,14 +116,10 @@ describe('controller/Planner', () => { const planner = new Planner(config, tasks, modules); - const result = []; - - for (const testGroup of planner.plan()) { - result.push(testGroup); - } + const result = [...planner.plan()]; - expect(result.filter((tests) => tests[0].taskId === tasks[0].id)).toHaveLength(1); - expect(result.filter((tests) => tests[0].taskId === tasks[1].id && tests[0].type !== 'dry')).toHaveLength( + expect(result.filter((test) => test.taskId === tasks[0].id)).toHaveLength(1); + expect(result.filter((test) => test.taskId === tasks[1].id && test.type !== 'dry')).toHaveLength( config.retries, ); }); @@ -153,10 +145,10 @@ describe('controller/Planner', () => { result.push(testGroup); } - expect(result.filter((tests) => tests[0].taskId === tasks[0].id && tests[0].type !== 'dry')).toHaveLength( + expect(result.filter((test) => test.taskId === tasks[0].id && test.type !== 'dry')).toHaveLength( config.retries, ); - expect(result.filter((tests) => tests[0].taskId === tasks[0].id && tests[0].type === 'dry')).toHaveLength( + expect(result.filter((test) => test.taskId === tasks[0].id && test.type === 'dry')).toHaveLength( config.dryRunTimes, ); }); diff --git a/packages/perftool/lib/controller/clientScript.ts b/packages/perftool/lib/controller/clientScript.ts index 74c0e34..8059284 100644 --- a/packages/perftool/lib/controller/clientScript.ts +++ b/packages/perftool/lib/controller/clientScript.ts @@ -1,18 +1,21 @@ import type { RawTest } from '../client/input'; import { Task } from '../client/measurement/types'; -export function insertTests(serializedTests: string) { - const tests: RawTest[] = JSON.parse(serializedTests); +export function bootstrapTest(serializedTest: string) { + const test: RawTest = JSON.parse(serializedTest); /** * @see utils/window.d.ts */ - window.tests = window.tests || []; - window.tests.push(...tests); + window._perftool_test = test; + + if (window._perftool_api_ready) { + window._perftool_api_ready(); + } } -export function createInsertionScriptContent>(tests: RawTest[]) { - return `${insertTests.toString()} - insertTests('${JSON.stringify(tests)}'); +export function createInsertionScriptContent>(test: RawTest) { + return `${bootstrapTest.toString()} + bootstrapTest('${JSON.stringify(test)}'); `; } diff --git a/packages/perftool/lib/controller/controller.ts b/packages/perftool/lib/controller/controller.ts index 34b56ef..258a255 100644 --- a/packages/perftool/lib/controller/controller.ts +++ b/packages/perftool/lib/controller/controller.ts @@ -1,4 +1,3 @@ -import assert from '../utils/assert'; import { Config } from '../config'; import { Task } from '../client/measurement/types'; import { RunTaskResult } from '../client/measurement/runner'; @@ -21,27 +20,19 @@ export default class TestController[]> { this.planner = planner; } - async *run(): AsyncGenerator[], undefined> { + async *run(): AsyncGenerator, undefined> { const { executor } = this; const schedule = this.planner.plan(); const start = async function* start() { - while (true) { - const { done, value } = schedule.next(); - - if (done) { - return; - } - - assert(value); - + for (const value of schedule) { const result = await executor.execute(value); if (result instanceof Error) { throw result; } - if (value[0]?.type === 'dry') { + if (value.type === 'dry') { debug('[controller]', 'current run is dry, skipping'); continue; } diff --git a/packages/perftool/lib/controller/executor.ts b/packages/perftool/lib/controller/executor.ts index cd60c32..436649b 100644 --- a/packages/perftool/lib/controller/executor.ts +++ b/packages/perftool/lib/controller/executor.ts @@ -8,13 +8,14 @@ import { RunTaskResult } from '../client/measurement/runner'; import { Task, TaskState } from '../client/measurement/types'; import { debug } from '../utils/logger'; import { RawTest } from '../client/input'; +import { useInterceptApi } from '../api/intercept'; import { createInsertionScriptContent } from './clientScript'; export type Test = { taskId: string; subjectId: string; type?: 'dry' }; export type IExecutor[]> = { - execute(tests: Test[]): Promise[] | Error>; + execute(test: Test): Promise | Error>; }; const PUPPETEER_MYSTERY_ERROR_RETRIES = 5; @@ -108,8 +109,8 @@ export default class Executor[]> implements IExecu await this.browserInstance.close(); } - async execute(tests: Test[]): Promise[] | Error> { - debug('[executor]', 'running tests', tests); + async execute(test: Test): Promise | Error> { + debug('[executor]', 'running test', test); assert(this.workable); let page: Page = undefined as unknown as Page; @@ -125,21 +126,23 @@ export default class Executor[]> implements IExecu } } - const results = new Deferred[]>(); + const result = new Deferred>(); await page.goto(`http://localhost:${this.port}/`); - await page.exposeFunction('finish', (taskResults: RunTaskResult[]) => { - taskResults.forEach(this.setState); + await page.exposeFunction('_perftool_finish', (taskResult: RunTaskResult) => { + this.setState(taskResult); - results.resolve(taskResults); + result.resolve(taskResult); }); - await page.addScriptTag({ content: createInsertionScriptContent(tests.map(this.decorateWithState)) }); + await useInterceptApi(page); + await page.addScriptTag({ content: createInsertionScriptContent(this.decorateWithState(test)) }); return Promise.race([ - results.promise, - defer(this.config.runWaitTimeout).then(() => { - return new Error(`timeout ${this.config.runWaitTimeout}ms reached waiting for run to end`); - }), + result.promise, + defer( + this.config.runWaitTimeout, + () => new Error(`timeout ${this.config.runWaitTimeout}ms reached waiting for run to end`), + ), ]).finally(() => { return page.close(); }); diff --git a/packages/perftool/lib/controller/index.ts b/packages/perftool/lib/controller/index.ts index 0351a95..e808c80 100644 --- a/packages/perftool/lib/controller/index.ts +++ b/packages/perftool/lib/controller/index.ts @@ -23,7 +23,7 @@ export async function* runTests[]>({ port, tasks, testModules, -}: RunTestsParams): AsyncGenerator[], undefined> { +}: RunTestsParams): AsyncGenerator, undefined> { info('Running performance tests...'); const executor = await Executor.create(config, cache, port); diff --git a/packages/perftool/lib/controller/planner.ts b/packages/perftool/lib/controller/planner.ts index f17a3a4..3957d8a 100644 --- a/packages/perftool/lib/controller/planner.ts +++ b/packages/perftool/lib/controller/planner.ts @@ -8,7 +8,7 @@ import type { TestModule } from '../build/collect'; import type { Test } from './executor'; export type IPlanner = { - plan(): Generator; + plan(): Generator; }; export function getTests[]>( @@ -52,7 +52,7 @@ export default class Planner[]> implements IPlanner { this.testModules = testModules; } - *plan(): Generator { + *plan(): Generator { const idempotentTasks = this.tasks.filter(({ isIdempotent }) => isIdempotent); const nonIdempotentTasks = this.tasks.filter(({ isIdempotent }) => !isIdempotent); @@ -60,7 +60,9 @@ export default class Planner[]> implements IPlanner { debug('[planner]', 'running idempotent tasks'); const tests = getTests(this.config, idempotentTasks, this.testModules); - yield tests; + for (const test of tests) { + yield test; + } } if (nonIdempotentTasks.length) { @@ -74,7 +76,7 @@ export default class Planner[]> implements IPlanner { } for (let i = 0; i < this.config.dryRunTimes; ++i) { for (const test of tests) { - yield [{ ...test, type: 'dry' }]; + yield { ...test, type: 'dry' }; } } @@ -82,7 +84,7 @@ export default class Planner[]> implements IPlanner { for (let i = 0; i < this.config.retries; ++i) { for (const test of tests) { - yield [test]; + yield test; } } } diff --git a/packages/perftool/lib/index.ts b/packages/perftool/lib/index.ts index 7ced9fc..f05ec2b 100755 --- a/packages/perftool/lib/index.ts +++ b/packages/perftool/lib/index.ts @@ -16,10 +16,11 @@ import Cache from './cache'; import openBrowser from './utils/openBrowser'; import { waitForSigint } from './utils/interrupt'; -const cli = createCommand('perftool'); - +export { intercept } from './api/external'; export type { ProjectConfig as Config }; +const cli = createCommand('perftool'); + cli.addArgument(createArgument('[include...]', 'Modules to run perftest on')) .addOption(createOption('-l, --logLevel ', 'Log level').choices(['quiet', 'normal', 'verbose'])) .addOption(createOption('-c, --configPath ', 'Config path')) diff --git a/packages/perftool/lib/statistics/index.ts b/packages/perftool/lib/statistics/index.ts index 99afcc6..0beca92 100644 --- a/packages/perftool/lib/statistics/index.ts +++ b/packages/perftool/lib/statistics/index.ts @@ -63,17 +63,15 @@ export default class Statistics[]> { this.computableObservations.get(subjectId)?.get(taskId)?.push(rest.result); } - async consume(source: AsyncGenerator[], undefined>): Promise { + async consume(source: AsyncGenerator, undefined>): Promise { this.isConsuming = true; - for await (const resultGroup of source) { + for await (const result of source) { if (!this.isConsuming) { break; } - for (const result of resultGroup) { - this.addObservation(result); - } + this.addObservation(result); } this.isConsuming = false; diff --git a/packages/perftool/lib/typings/window.d.ts b/packages/perftool/lib/typings/window.d.ts index 8d3dc14..9afb819 100644 --- a/packages/perftool/lib/typings/window.d.ts +++ b/packages/perftool/lib/typings/window.d.ts @@ -1,12 +1,15 @@ import type { Task } from '../client/measurement/types'; import type { RunTaskResult } from '../client/measurement/runner'; import type { RawTest } from '../client/input'; +import { InterceptParams } from '../api/intercept'; declare global { interface Window { // TODO comment - tests?: Array>> | { push: (...args: RawTest>[]) => void }; - finish: []>(results: RunTaskResult[]) => Promise; + _perftool_test?: RawTest>; + _perftool_finish?: []>(result: RunTaskResult) => Promise; + _perftool_intercept?: (params: InterceptParams) => Promise; + _perftool_api_ready?: () => void; } } diff --git a/packages/perftool/package.json b/packages/perftool/package.json index 447a61d..736fbf2 100644 --- a/packages/perftool/package.json +++ b/packages/perftool/package.json @@ -54,6 +54,8 @@ "jstat": "1.9.6", "loglevel": "1.8.1", "loglevel-plugin-prefix": "0.8.4", + "mime": "3.0.0", + "minimatch": "9.0.3", "morgan": "1.10.0", "open": "9.1.0", "puppeteer": "19.9.1", @@ -66,6 +68,7 @@ "@jest/globals": "29.6.1", "@types/express": "4.17.17", "@types/jest": "29.5.0", + "@types/mime": "3.0.1", "@types/morgan": "1.9.4", "@types/node": "20.4.3", "@types/react": "18.2.14", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3dcf798..c84b4bd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -122,6 +122,12 @@ importers: loglevel-plugin-prefix: specifier: 0.8.4 version: 0.8.4 + mime: + specifier: 3.0.0 + version: 3.0.0 + minimatch: + specifier: 9.0.3 + version: 9.0.3 morgan: specifier: 1.10.0 version: 1.10.0 @@ -153,6 +159,9 @@ importers: '@types/jest': specifier: 29.5.0 version: 29.5.0 + '@types/mime': + specifier: 3.0.1 + version: 3.0.1 '@types/morgan': specifier: 1.9.4 version: 1.9.4 @@ -4295,7 +4304,6 @@ packages: /balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - dev: true /base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -4379,7 +4387,6 @@ packages: resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==} dependencies: balanced-match: 1.0.2 - dev: true /braces@3.0.2: resolution: {integrity: sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==} @@ -8571,6 +8578,12 @@ packages: hasBin: true dev: false + /mime@3.0.0: + resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} + engines: {node: '>=10.0.0'} + hasBin: true + dev: false + /mimic-fn@2.1.0: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} @@ -8624,6 +8637,13 @@ packages: brace-expansion: 2.0.1 dev: true + /minimatch@9.0.3: + resolution: {integrity: sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==} + engines: {node: '>=16 || 14 >=14.17'} + dependencies: + brace-expansion: 2.0.1 + dev: false + /minimist-options@4.1.0: resolution: {integrity: sha512-Q4r8ghd80yhO/0j1O3B2BjweX3fiHg9cdOwjJd2J76Q135c+NDxGCqdYKQ1SKBuFfgWbAUzBfvYjPUEeNgqN1A==} engines: {node: '>= 6'}