Skip to content

Commit

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

this commit is the first pass at support a new version of jest in
stencil in well over a year, and the first to use the new package-based
architecture.

this commit was generated using the steps that are outline in the README
file of the jest directory, [available here](https://github.com/ionic-team/stencil/blob/d725ac9ef73f9700feb773d4a65e3ea988db6d31/src/testing/jest/README.md?plain=1).

note: some commits of the original pr may be out of order with the
readme above. although the readme is permalinked, it did undergo some
changes between the time this pr's commits were created and the time this
pr was created. some items of note:
- the contents of `jest-27-and-under/` were copied into `jest-28/` using
  the latest available contents at the time - 6893954 (#4904).

in order to support jest 28, the following changes were made:
1. projects are now typed as `string` in v28:
   the typings for jest are causing the project to fail compilation. update
   the return value here, as `Config.Path` is now just a string
1. resolve TS4053 error in v28 facade:
   we had to resolve a typescript compilation error where we would run
   into typescript error TS4053. this was caused by this facade's
   implementation of `getJestPreset` returning a Jest preset that
   referenced Jest v28's `Config` interface. the TypeScript compiler did
   not know how to make sense of this import within the module, requiring
   us to manually import it
1. update typings of the preprocessor:
   in jest 28, preprocessors cannot return a string. rather, they must at
   minimum return `{ code : string }`. this commit resolves those
   compilation errors by using Jest's `TransformedSource` type and updating
   the return values of the function accordingly
1. migrate to jest-circus for v28:
   switch users over to jest-circus for v28, rather than jest-jasmine2.
   this makes it such that we are not required to force users to add
   jest-jasmine2 to their dependencies, since it was removed from jest in
   v28 (and published as a separate package).
1. handle updated environment export:
   the test environments export has changed in v28. update the require
   statement to handle this change.
1. remove jasmine globals:
   remove direct references to jasmine globals that were used in jest 28
   code. in some instnaces, puppeteer code that is not versioned (per
   version of jest) had to be modified to ensure that we did not attempt to
   reference a global `jasmine` that did not exist

finally, some backwards compatability code was removed from the new
`jest-28/` directory. it did not need to be executed in this new
architecture (and can be siloed to `jest-27-and-under/`)

closes: 3348

STENCIL-955
  • Loading branch information
rwaskiewicz authored Oct 26, 2023
1 parent d725ac9 commit d3aa539
Show file tree
Hide file tree
Showing 34 changed files with 5,525 additions and 26 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']
jest: ['24', '25', '26', '27', '28']
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 @@ -98,6 +98,11 @@
matchPackageNames: ['@types/jest', 'jest'],
allowedVersions: '<=27'
},
{
"matchFileNames": ["src/testing/jest/jest-28/package.json"],
matchPackageNames: ['@types/jest', 'jest'],
allowedVersions: '<=28'
},
{
// We intentionally run the karma tests against the oldest LTS of Node we support.
// Prevent renovate from trying to bump node
Expand Down
4 changes: 2 additions & 2 deletions src/declarations/stencil-private.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2097,10 +2097,10 @@ export interface JestEnvironmentGlobal {
h: any;
resourcesUrl: string;
currentSpec?: {
id: string;
id?: string;
description: string;
fullName: string;
testPath: string;
testPath: string | null;
};
env: { [prop: string]: string };
screenshotDescriptions: Set<string>;
Expand Down
12 changes: 11 additions & 1 deletion src/screenshot/screenshot-compare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,17 @@ export async function compareScreenshot(

async function getMismatchedPixels(pixelmatchModulePath: string, pixelMatchInput: d.PixelMatchInput) {
return new Promise<number>((resolve, reject) => {
const timeout = jasmine.DEFAULT_TIMEOUT_INTERVAL * 0.5;
/**
* When using screenshot functionality in a runner that is not Jasmine (e.g. Jest Circus), we need to set a default
* value for timeouts. There are runtime errors that occur if we attempt to use optional chaining + nullish
* coalescing with the `jasmine` global stating it's not defined. As a result, we use a ternary here.
*
* The '2500' value that we default to is the value of `jasmine.DEFAULT_TIMEOUT_INTERVAL` (5000) divided by 2.
*/
const timeout =
typeof jasmine !== 'undefined' && jasmine.DEFAULT_TIMEOUT_INTERVAL
? jasmine.DEFAULT_TIMEOUT_INTERVAL * 0.5
: 2500;
const tmr = setTimeout(() => {
reject(`getMismatchedPixels timeout: ${timeout}ms`);
}, timeout);
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: '27.0.3', maxVersion: '27.0.0' },
jest: { minVersion: '24.9.0', recommendedVersion: '27.0.3', maxVersion: '27.0.0' },
'jest-cli': { minVersion: '24.9.0', recommendedVersion: '27.4.5', maxVersion: '27.0.0' },
'@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' },
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-28/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 { Jest28Stencil } 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 Jest28Stencil().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-28/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-28/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 Jest28Stencil 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 d3aa539

Please sign in to comment.