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 8 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions entrypoints/internal.cjs
@@ -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
@@ -0,0 +1 @@
export * from "./internal"
1 change: 1 addition & 0 deletions entrypoints/internal.d.mts
@@ -0,0 +1 @@
export * from "./internal"
25 changes: 25 additions & 0 deletions entrypoints/internal.d.ts
@@ -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
@@ -0,0 +1 @@
export {};
codetheweb marked this conversation as resolved.
Show resolved Hide resolved
3 changes: 3 additions & 0 deletions internal.d.ts
@@ -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
@@ -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
}
11 changes: 9 additions & 2 deletions lib/cli.js
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,10 +472,16 @@ export default async function loadCli() { // eslint-disable-line complexity
});
}

api.on('run', plan => {
if (combined.observeRun && experiments.observeRunsFromConfig) {
combined.observeRun({
events: asyncEventIteratorFromApi(api),
});
}

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

plan.status.on('stateChange', evt => {
plan.status.on('stateChange', async evt => {
if (evt.type === 'end' || evt.type === 'interrupt') {
// Write out code coverage data when the run ends, lest a process
// interrupt causes it to be lost.
Expand Down
2 changes: 1 addition & 1 deletion lib/load-config.js
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
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
@@ -0,0 +1 @@
internal-events.json
21 changes: 21 additions & 0 deletions test/internal-events/fixtures/ava.config.js
@@ -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
@@ -0,0 +1,3 @@
{
"type": "module"
}
5 changes: 5 additions & 0 deletions test/internal-events/fixtures/test.js
@@ -0,0 +1,5 @@
import test from 'ava';

test('placeholder', t => {
t.pass();
});
38 changes: 38 additions & 0 deletions test/internal-events/test.js
@@ -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
@@ -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"
}