Skip to content

Commit

Permalink
feat(test): jest 29 support
Browse files Browse the repository at this point in the history
add support for jest 29 in stencil.

this commit is a fast-follow to #4979 (d3aa539),
which adds support for jest 28. as such, there will be many
similarlities between the two pieces of code.

STENCIL-956
  • Loading branch information
rwaskiewicz committed Oct 26, 2023
1 parent d3aa539 commit 19d85de
Show file tree
Hide file tree
Showing 29 changed files with 5,535 additions and 4 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/test-component-starter.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ jobs:
strategy:
fail-fast: false
matrix:
jest: ['24', '25', '26', '27', '28']
jest: ['24', '25', '26', '27', '28', '29']
node: ['16', '18', '20']
os: ['ubuntu-latest', 'windows-latest']
runs-on: ${{ matrix.os }}
Expand Down
5 changes: 5 additions & 0 deletions renovate.json5
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,11 @@
matchPackageNames: ['@types/jest', 'jest'],
allowedVersions: '<=28'
},
{
"matchFileNames": ["src/testing/jest/jest-29/package.json"],
matchPackageNames: ['@types/jest', 'jest'],
allowedVersions: '<=29'
},
{
// We intentionally run the karma tests against the oldest LTS of Node we support.
// Prevent renovate from trying to bump node
Expand Down
6 changes: 3 additions & 3 deletions src/sys/node/node-sys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -664,9 +664,9 @@ export function createNodeSys(c: { process?: any; logger?: Logger } = {}): Compi
const nodeResolve = new NodeResolveModule();

sys.lazyRequire = new NodeLazyRequire(nodeResolve, {
'@types/jest': { minVersion: '24.9.1', recommendedVersion: '28', maxVersion: '28.0.0' },
jest: { minVersion: '24.9.0', recommendedVersion: '28', maxVersion: '28.0.0' },
'jest-cli': { minVersion: '24.9.0', recommendedVersion: '28', maxVersion: '28.0.0' },
'@types/jest': { minVersion: '24.9.1', recommendedVersion: '29', maxVersion: '29.0.0' },
jest: { minVersion: '24.9.0', recommendedVersion: '29', maxVersion: '29.0.0' },
'jest-cli': { minVersion: '24.9.0', recommendedVersion: '29', maxVersion: '29.0.0' },
puppeteer: { minVersion: '10.0.0', recommendedVersion: '20' },
'puppeteer-core': { minVersion: '10.0.0', recommendedVersion: '20' },
'workbox-build': { minVersion: '4.3.1', recommendedVersion: '4.3.1' },
Expand Down
125 changes: 125 additions & 0 deletions src/testing/jest/jest-29/jest-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import type { Config } from '@jest/types';
import type * as d from '@stencil/core/internal';
import { isString } from '@utils';

import { Jest29Stencil } from './jest-facade';

/**
* Builds the `argv` to be used when programmatically invoking the Jest CLI
* @param config the Stencil config to use while generating Jest CLI arguments
* @returns the arguments to pass to the Jest CLI, wrapped in an object
*/
export function buildJestArgv(config: d.ValidatedConfig): Config.Argv {
const yargs = require('yargs');

const knownArgs = config.flags.knownArgs.slice();

if (!knownArgs.some((a) => a.startsWith('--max-workers') || a.startsWith('--maxWorkers'))) {
knownArgs.push(`--max-workers=${config.maxConcurrentWorkers}`);
}

if (config.flags.devtools) {
knownArgs.push('--runInBand');
}

// we combine the modified args and the unknown args here and declare the
// result read only, providing some type system-level assurance that we won't
// mutate it after this point.
//
// We want that assurance because Jest likes to have any filepath match
// patterns at the end of the args it receives. Those args are going to be
// found in our `unknownArgs`, so while we want to do some stuff in this
// function that adds to `knownArgs` we need a guarantee that all of the
// `unknownArgs` are _after_ all the `knownArgs` in the array we end up
// generating the Jest configuration from.
const args: ReadonlyArray<string> = [...knownArgs, ...config.flags.unknownArgs];

config.logger.info(config.logger.magenta(`jest args: ${args.join(' ')}`));

const jestArgv = yargs(args).argv as Config.Argv;
jestArgv.config = buildJestConfig(config);

if (typeof jestArgv.maxWorkers === 'string') {
try {
jestArgv.maxWorkers = parseInt(jestArgv.maxWorkers, 10);
} catch (e) {}
}

if (typeof jestArgv.ci === 'string') {
jestArgv.ci = jestArgv.ci === 'true' || jestArgv.ci === '';
}

return jestArgv;
}

/**
* Generate a Jest run configuration to be used as a part of the `argv` passed to the Jest CLI when it is invoked
* programmatically
* @param config the Stencil config to use while generating Jest CLI arguments
* @returns the Jest Config to attach to the `argv` argument
*/
export function buildJestConfig(config: d.ValidatedConfig): string {
const stencilConfigTesting = config.testing;
const jestDefaults: Config.DefaultOptions = require('jest-config').defaults;

const validJestConfigKeys = Object.keys(jestDefaults);

const jestConfig: d.JestConfig = {};

Object.keys(stencilConfigTesting).forEach((key) => {
if (validJestConfigKeys.includes(key)) {
(jestConfig as any)[key] = (stencilConfigTesting as any)[key];
}
});

jestConfig.rootDir = config.rootDir;

if (isString(stencilConfigTesting.collectCoverage)) {
jestConfig.collectCoverage = stencilConfigTesting.collectCoverage;
}
if (Array.isArray(stencilConfigTesting.collectCoverageFrom)) {
jestConfig.collectCoverageFrom = stencilConfigTesting.collectCoverageFrom;
}
if (isString(stencilConfigTesting.coverageDirectory)) {
jestConfig.coverageDirectory = stencilConfigTesting.coverageDirectory;
}
if (stencilConfigTesting.coverageThreshold) {
jestConfig.coverageThreshold = stencilConfigTesting.coverageThreshold;
}
if (isString(stencilConfigTesting.globalSetup)) {
jestConfig.globalSetup = stencilConfigTesting.globalSetup;
}
if (isString(stencilConfigTesting.globalTeardown)) {
jestConfig.globalTeardown = stencilConfigTesting.globalTeardown;
}
if (isString(stencilConfigTesting.preset)) {
jestConfig.preset = stencilConfigTesting.preset;
}
if (stencilConfigTesting.projects) {
jestConfig.projects = stencilConfigTesting.projects;
}
if (Array.isArray(stencilConfigTesting.reporters)) {
jestConfig.reporters = stencilConfigTesting.reporters;
}
if (isString(stencilConfigTesting.testResultsProcessor)) {
jestConfig.testResultsProcessor = stencilConfigTesting.testResultsProcessor;
}
if (stencilConfigTesting.transform) {
jestConfig.transform = stencilConfigTesting.transform;
}
if (stencilConfigTesting.verbose) {
jestConfig.verbose = stencilConfigTesting.verbose;
}

jestConfig.testRunner = new Jest29Stencil().getDefaultJestRunner();

return JSON.stringify(jestConfig);
}

export function getProjectListFromCLIArgs(config: d.ValidatedConfig, argv: Config.Argv): string[] {
const projects = argv.projects ? argv.projects : [];

projects.push(config.rootDir);

return projects;
}
98 changes: 98 additions & 0 deletions src/testing/jest/jest-29/jest-environment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import type { Circus } from '@jest/types';
import type { E2EProcessEnv, JestEnvironmentGlobal } from '@stencil/core/internal';

import { connectBrowser, disconnectBrowser, newBrowserPage } from '../../puppeteer/puppeteer-browser';

export function createJestPuppeteerEnvironment() {
const NodeEnvironment = require('jest-environment-node').TestEnvironment;
const JestEnvironment = class extends NodeEnvironment {
global: JestEnvironmentGlobal;
browser: any = null;
pages: any[] = [];
testPath: string | null = null;

constructor(config: any, context: any) {
super(config, context);
this.testPath = context.testPath;
}

async setup() {
if ((process.env as E2EProcessEnv).__STENCIL_E2E_TESTS__ === 'true') {
this.global.__NEW_TEST_PAGE__ = this.newPuppeteerPage.bind(this);
this.global.__CLOSE_OPEN_PAGES__ = this.closeOpenPages.bind(this);
}
}

/**
* Jest Circus hook for capturing events.
*
* We use this lifecycle hook to capture information about the currently running test in the event that it is a
* Jest-Stencil screenshot test, so that we may accurately report on it.
*
* @param event the captured runtime event
*/
async handleTestEvent(event: Circus.AsyncEvent): Promise<void> {
// The 'parent' of a top-level describe block in a Jest block has one more 'parent', which is this string.
// It is not exported by Jest, and is therefore copied here to exclude it from the fully qualified test name.
const ROOT_DESCRIBE_BLOCK = 'ROOT_DESCRIBE_BLOCK';
if (event.name === 'test_start') {
const eventTest = event.test;

/**
* We need to build the full name of the test for screenshot tests.
* We do this as a test name can be the same across multiple tests - e.g. `it('renders', () => {...});`.
* While this does not necessarily guarantee the generated name will be unique, it matches previous Jest-Stencil
* screenshot behavior.
*/
let fullName = eventTest.name;
let currentParent: Circus.DescribeBlock | undefined = eventTest.parent;
// For each parent block (`describe('suite description', () => {...}`), grab the suite description and prepend
// it to the running name.
while (currentParent && currentParent.name && currentParent.name != ROOT_DESCRIBE_BLOCK) {
fullName = `${currentParent.name} ${fullName}`;
currentParent = currentParent.parent;
}
// Set the current spec for us to inspect for using the default reporter in screenshot tests.
this.global.currentSpec = {
// the event's test's name is analogous to the original description in earlier versions of jest
description: eventTest.name,
fullName,
testPath: this.testPath,
};
}
}
async newPuppeteerPage() {
if (!this.browser) {
// load the browser and page on demand
this.browser = await connectBrowser();
}

const page = await newBrowserPage(this.browser);
this.pages.push(page);
// during E2E tests, we can safely assume that the current environment is a `E2EProcessEnv`
const env: E2EProcessEnv = process.env as E2EProcessEnv;
if (typeof env.__STENCIL_DEFAULT_TIMEOUT__ === 'string') {
page.setDefaultTimeout(parseInt(env.__STENCIL_DEFAULT_TIMEOUT__, 10));
}
return page;
}

async closeOpenPages() {
await Promise.all(this.pages.map((page) => page.close()));
this.pages.length = 0;
}

async teardown() {
await super.teardown();
await this.closeOpenPages();
await disconnectBrowser(this.browser);
this.browser = null;
}

getVmContext() {
return super.getVmContext();
}
};

return JestEnvironment;
}
49 changes: 49 additions & 0 deletions src/testing/jest/jest-29/jest-facade.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// @ts-ignore - without importing this, we get a TypeScript error, "TS4053".
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import type { Config } from '@jest/types';

import { JestFacade } from '../jest-facade';
import { createJestPuppeteerEnvironment } from './jest-environment';
import { jestPreprocessor } from './jest-preprocessor';
import { preset } from './jest-preset';
import { createTestRunner } from './jest-runner';
import { runJest } from './jest-runner';
import { runJestScreenshot } from './jest-screenshot';
import { jestSetupTestFramework } from './jest-setup-test-framework';

/**
* `JestFacade` implementation for communicating between this directory's version of Jest and Stencil
*/
export class Jest29Stencil implements JestFacade {
getJestCliRunner() {
return runJest;
}

getRunJestScreenshot() {
return runJestScreenshot;
}

getDefaultJestRunner() {
return 'jest-circus';
}

getCreateJestPuppeteerEnvironment() {
return createJestPuppeteerEnvironment;
}

getJestPreprocessor() {
return jestPreprocessor;
}

getCreateJestTestRunner() {
return createTestRunner;
}

getJestSetupTestFramework() {
return jestSetupTestFramework;
}

getJestPreset() {
return preset;
}
}
Loading

0 comments on commit 19d85de

Please sign in to comment.