Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Expose internal events for custom reporters via config #3247

Merged
merged 18 commits into from
Nov 26, 2023
Merged
Show file tree
Hide file tree
Changes from 9 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
1 change: 1 addition & 0 deletions entrypoints/internal.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = {};
codetheweb marked this conversation as resolved.
Show resolved Hide resolved
1 change: 1 addition & 0 deletions entrypoints/internal.d.cts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./internal"
1 change: 1 addition & 0 deletions entrypoints/internal.d.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./internal"
25 changes: 25 additions & 0 deletions entrypoints/internal.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type {StateChangeEvent} from '../types/state-change-events.d.cts';

export type RunEvent = {
type: 'stateChange';
stateChange: StateChangeEvent;
} | {
type: 'run';
plan: {
bailWithoutReporting: boolean;
debug: boolean;
failFastEnabled: boolean;
filePathPrefix: string;
files: string[];
matching: boolean;
previousFailures: number;
runOnlyExclusive: boolean;
firstRun: boolean;
};
codetheweb marked this conversation as resolved.
Show resolved Hide resolved
};

export type {StateChangeEvent} from '../types/state-change-events.d.cts';

export type Run = {
codetheweb marked this conversation as resolved.
Show resolved Hide resolved
events: AsyncIterableIterator<RunEvent>;
};
1 change: 1 addition & 0 deletions entrypoints/internal.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export {};
codetheweb marked this conversation as resolved.
Show resolved Hide resolved
3 changes: 3 additions & 0 deletions internal.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// For compatibility with resolution algorithms other than Node16.

export * from './entrypoints/internal.cjs';
17 changes: 17 additions & 0 deletions lib/api-event-iterator.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import {on} from 'node:events';

export async function * asyncEventIteratorFromApi(api) {
for await (const [plan] of on(api, 'run')) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there a scenario where api will emit run multiple times? (other than watch mode maybe?)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, just in watch mode. May be helpful for the callback function to know that watch mode is active though.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 might be good to add as a future enhancement

yield {
type: 'run',
plan,
};

for await (const [stateChange] of on(plan.status, 'stateChange')) {
codetheweb marked this conversation as resolved.
Show resolved Hide resolved
yield {
type: 'stateChange',
stateChange,
};
}
}

Check warning on line 16 in lib/api-event-iterator.js

View check run for this annotation

Codecov / codecov/patch

lib/api-event-iterator.js#L16

Added line #L16 was not covered by tests
}
7 changes: 7 additions & 0 deletions lib/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import figures from 'figures';
import yargs from 'yargs';
import {hideBin} from 'yargs/helpers'; // eslint-disable-line n/file-extension-in-import

import {asyncEventIteratorFromApi} from './api-event-iterator.js';
import Api from './api.js';
import {chalk} from './chalk.js';
import validateEnvironmentVariables from './environment-variables.js';
Expand Down Expand Up @@ -471,6 +472,12 @@ export default async function loadCli() { // eslint-disable-line complexity
});
}

if (combined.observeRun && experiments.observeRunsFromConfig) {
combined.observeRun({
events: asyncEventIteratorFromApi(api),
});
}

api.on('run', plan => {
reporter.startRun(plan);

Expand Down
2 changes: 1 addition & 1 deletion lib/load-config.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {packageConfig, packageJsonPath} from 'pkg-conf';

const NO_SUCH_FILE = Symbol('no ava.config.js file');
const MISSING_DEFAULT_EXPORT = Symbol('missing default export');
const EXPERIMENTS = new Set();
const EXPERIMENTS = new Set(['observeRunsFromConfig']);

const importConfig = async ({configFile, fileForErrorMessage}) => {
const {default: config = MISSING_DEFAULT_EXPORT} = await import(url.pathToFileURL(configFile));
Expand Down
10 changes: 10 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,16 @@
"types": "./entrypoints/plugin.d.cts",
"default": "./entrypoints/plugin.cjs"
}
},
"./internal": {
"import": {
"types": "./entrypoints/internal.d.mts",
"default": "./entrypoints/internal.mjs"
},
"require": {
"types": "./entrypoints/internal.d.cts",
"default": "./entrypoints/internal.cjs"
}
}
},
"type": "module",
Expand Down
1 change: 1 addition & 0 deletions test/internal-events/fixtures/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
internal-events.json
21 changes: 21 additions & 0 deletions test/internal-events/fixtures/ava.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import fs from 'node:fs/promises';

const internalEvents = [];

export default {
files: [
'test.js',
],
nonSemVerExperiments: {
observeRunsFromConfig: true,
},
async observeRun(run) {
for await (const event of run.events) {
internalEvents.push(event);

if (event.type === 'stateChange' && event.stateChange.type === 'end') {
await fs.writeFile('internal-events.json', JSON.stringify(internalEvents));
}
}
},
};
3 changes: 3 additions & 0 deletions test/internal-events/fixtures/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"type": "module"
}
5 changes: 5 additions & 0 deletions test/internal-events/fixtures/test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import test from 'ava';

test('placeholder', t => {
t.pass();
});
38 changes: 38 additions & 0 deletions test/internal-events/test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import fs from 'node:fs/promises';
import {fileURLToPath} from 'node:url';

import test from '@ava/test';

import {fixture} from '../helpers/exec.js';

test('internal events are emitted', async t => {
await fixture();

const result = JSON.parse(await fs.readFile(fileURLToPath(new URL('fixtures/internal-events.json', import.meta.url))));

t.like(result[0], {
type: 'run',
plan: {
files: [
fileURLToPath(new URL('fixtures/test.js', import.meta.url)),
],
},
});

const testPassedEvent = result.find(event => event.type === 'stateChange' && event.stateChange.type === 'test-passed');
t.like(testPassedEvent, {
type: 'stateChange',
stateChange: {
type: 'test-passed',
title: 'placeholder',
testFile: fileURLToPath(new URL('fixtures/test.js', import.meta.url)),
},
});

t.like(result[result.length - 1], {
type: 'stateChange',
stateChange: {
type: 'end',
},
});
});
143 changes: 143 additions & 0 deletions types/state-change-events.d.cts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
type ErrorSource = {
isDependency: boolean
isWithinProject: boolean
file: string
line: number
}

type SerializedErrorBase = {
message: string
name: string,
originalError: unknown,
stack: string
}

type AggregateSerializedError = SerializedErrorBase & {
type: "aggregate"
errors: SerializedError[]
}

type NativeSerializedError = SerializedErrorBase & {
type: "native"
source: ErrorSource | null
}

type AVASerializedError = SerializedErrorBase & {
type: "ava"
assertion: string
improperUsage: unknown | null
formattedCause: unknown | null
formattedDetails: unknown | unknown[]
source: ErrorSource | null
}

type SerializedError = AggregateSerializedError | NativeSerializedError | AVASerializedError

export type StateChangeEvent = {
type: "starting",
testFile: string
} | {
type: "stats",
stats: {
byFile: Map<string, {
declaredTests: number
failedHooks: number,
failedTests: number,
internalErrors: number
remainingTests: number,
passedKnownFailingTests: number,
passedTests: number,
selectedTests: number,
selectingLines: boolean,
skippedTests: number,
todoTests: number,
uncaughtExceptions: number,
unhandledRejections: number,
}>
declaredTests: number
failedHooks: number,
failedTests: number,
failedWorkers: number,
files: number,
parallelRuns: {
currentIndex: number,
totalRuns: number
} | null
finishedWorkers: number,
internalErrors: number
remainingTests: number,
passedKnownFailingTests: number,
passedTests: number,
selectedTests: number,
sharedWorkerErrors: number,
skippedTests: number,
timedOutTests: number,
timeouts: number,
todoTests: number,
uncaughtExceptions: number,
unhandledRejections: number,
}
} | {
type: "declared-test"
title: string
knownFailing: boolean
todo: boolean
testFile: string
} | {
type: "selected-test"
title: string
knownFailing: boolean
skip: boolean
todo: boolean
testFile: string
} | {
type: "test-register-log-reference"
title: string
logs: string[]
testFile: string
} | {
type: "test-passed",
title: string
duration: number
knownFailing: boolean
logs: string[]
testFile: string
} | {
type: "test-failed",
title: string
err: SerializedError,
duration: number
knownFailing: boolean
logs: string[]
testFile: string
} | {
type: "worker-finished",
forcedExit: boolean,
testFile: string
} | {
type: "worker-failed",
nonZeroExitCode?: boolean,
signal?: string,
err?: SerializedError
} | {
type: "touched-files",
files: {
changedFiles: string[],
temporaryFiles: string[]
}
} | {
type: 'worker-stdout',
chunk: Uint8Array
testFile: string
} | {
type: 'worker-stderr',
chunk: Uint8Array
testFile: string
} | {
type: "timeout",
period: number,
pendingTests: Map<string, Set<string>>
}
| {
type: "end"
}