Skip to content

Commit

Permalink
chore: add initial intercept files
Browse files Browse the repository at this point in the history
  • Loading branch information
tmsns committed Dec 5, 2023
1 parent 26963f6 commit 27e98ea
Show file tree
Hide file tree
Showing 15 changed files with 397 additions and 0 deletions.
5 changes: 5 additions & 0 deletions .changeset/loud-zebras-joke.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@web/test-runner-intercept': patch
---

initial commit of @web/test-runner-intercept
78 changes: 78 additions & 0 deletions packages/test-runner-intercept/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
## Usage

Setup

```js
// web-test-runner.config.mjs
import { interceptModulePlugin } from '@web/test-runner-intercept/plugin';

export default {
plugins: [interceptModulePlugin()],
};
```

```js
// src/getTimeOfDay.js
import { getCurrentHour } from 'time-library';

export function getTimeOfDay() {
const hour = getCurrentHour();
if (hour < 6 || hour > 18) {
return 'night';
}
return 'day';
}
```

Simple test scenario:

```js
// test/getTimeOfDay.test.js
import { interceptModule } from '@web/test-runner-intercept';

const timeLibrary = await interceptModule('time-library');
const { getTimeOfDay } = await import('../src/getTimeOfDay.js');

describe('getTimeOfDay', () => {
it('returns night at 2', () => {
timeLibrary.getCurrentHour = () => 2;
const result = getTimeOfDay();
if (result !== 'night') {
throw;
}
});
});
```

More extended test scenario with common helper libraries:

```js
// test/getTimeOfDay.test.js
import { stub } from 'sinon';
import { expect } from 'chai';
import { interceptModule } from '@web/test-runner-intercept';

const timeLibrary = await interceptModule('time-library');
const { getTimeOfDay } = await import('../src/getTimeOfDay.js');

describe('getTimeOfDay', () => {
it('returns night at 2', () => {
const stubGetCurrentHour = stub(timeLibrary, 'getCurrentHour').returns(2);
try {
const result = getTimeOfDay();
expect(result).to.equal('night');
} finally {
stubGetCurrentHour.restore();
}
});
it('returns day at 14', () => {
const stubGetCurrentHour = stub(timeLibrary, 'getCurrentHour').returns(14);
try {
const result = getTimeOfDay();
expect(result).to.equal('day');
} finally {
stubGetCurrentHour.restore();
}
});
});
```
7 changes: 7 additions & 0 deletions packages/test-runner-intercept/browser/index.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/**
* Intercept a module in order to change its implementation.
* @param moduleName The name of the module to intercept. When intercepting the import of a module, the name should exactly match the name of the import.
* @returns Writable object with every named export of the intercepted module as a property.
* If the module to be intercepted contains a default export, the export will be available in the `default` property. Only function expressions are supported when intercepting default exports.
*/
export function interceptModule(moduleName: string): Promise<Record<string, func> | void>;
20 changes: 20 additions & 0 deletions packages/test-runner-intercept/browser/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/**
* Intercept a module in order to change its implementation.
* @param {string} moduleName The name of the module to intercept. When intercepting the import of a module, the name should exactly match the name of the import.
* @returns {Promise<Record<string, func> | void >} Writable object with every named export of the intercepted module as a property.
* If the module to be intercepted contains a default export, the export will be available in the `default` property. Only function expressions are supported when intercepting default exports.
*/
export async function interceptModule(moduleName) {
let module;
try {
module = await import(`/__intercept-module__/${moduleName}`);
} catch (e) {
throw new Error(
`Module interception is not active. Make sure the \`interceptModulePlugin\` of \`@web/test-runner-intercept\` is added to the Test Runner config.`,
);
}
if (module.__wtr_error__) {
throw new Error(module.__wtr_error__);
}
return module.__wtr_intercepted_module__;
}
2 changes: 2 additions & 0 deletions packages/test-runner-intercept/index.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// this file is autogenerated with the generate-mjs-dts-entrypoints script
export * from './browser/index';
63 changes: 63 additions & 0 deletions packages/test-runner-intercept/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
{
"name": "@web/test-runner-intercept",
"version": "0.1.0",
"publishConfig": {
"access": "public"
},
"description": "Plugin for intercepting modules in @web/test-runner",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/modernweb-dev/web.git",
"directory": "packages/test-runner-intercept"
},
"author": "modern-web",
"homepage": "https://github.com/modernweb-dev/web/tree/master/packages/test-runner-intercept",
"main": "browser/index.js",
"exports": {
".": {
"types": "./browser/index.d.ts",
"default": "./browser/index.js"
},
"./plugin": {
"types": "./plugin.d.ts",
"import": "./plugin.mjs",
"require": "./dist/interceptModulePlugin.js"
}
},
"engines": {
"node": ">=16.0.0"
},
"scripts": {
"build": "tsc",
"test": "mocha \"test/**/*.test.ts\" --require ts-node/register && npm run test:ci",
"test:browser": "node ../test-runner/dist/bin.js test-browser/test/**/*.test.{js,html} --config test-browser/web-test-runner.config.mjs",
"test:ci": "npm run test:browser",
"test:watch": "mocha \"test/**/*.test.ts\" --require ts-node/register --watch --watch-files src,test"
},
"files": [
"*.d.ts",
"*.js",
"*.mjs",
"dist",
"src"
],
"keywords": [
"web",
"dev",
"server",
"test",
"runner",
"testrunner",
"module",
"interception",
"intercept"
],
"dependencies": {
"@web/dev-server-core": "^0.6.2",
"es-module-lexer": "^1.3.1"
},
"devDependencies": {
"@web/test-runner": "^0.17.0"
}
}
2 changes: 2 additions & 0 deletions packages/test-runner-intercept/plugin.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// this file is autogenerated with the generate-mjs-dts-entrypoints script
export * from './dist/interceptModulePlugin';
6 changes: 6 additions & 0 deletions packages/test-runner-intercept/plugin.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// this file is autogenerated with the generate-mjs-dts-entrypoints script
import cjsEntrypoint from './dist/index.js';

const { interceptModulePlugin } = cjsEntrypoint;

export { interceptModulePlugin };
41 changes: 41 additions & 0 deletions packages/test-runner-intercept/src/createResolveImport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { Plugin, ServerStartParams, ResolveOptions, Context } from '@web/dev-server-core';

// Copied from packages/dev-server-core/src/plugins/Plugin.ts as it's not exported
export type ResolveResult = void | string | { id?: string };
export interface ResolveImportArguments {
source: string;
context: Context;
code?: string;
column?: number;
line?: number;
resolveOptions?: ResolveOptions;
}
export type ResolveImport = (
args: ResolveImportArguments,
) => ResolveResult | Promise<ResolveResult>;

/**
* TODO: check if `resolveImport()` can be provied by `@web/dev-server-core`'s API
* @param args start param args
* @param thisPlugin plugin to exclude
*/
export function createResolveImport(
{ config }: ServerStartParams,
thisPlugin: Plugin,
): ResolveImport {
const resolvePlugins =
config.plugins?.filter?.(pl => pl.resolveImport && pl !== thisPlugin) ?? [];

return async function resolveImport(args: ResolveImportArguments) {
for (const plugin of resolvePlugins) {
const resolved = await plugin?.resolveImport?.(args);
if (typeof resolved === 'string') {
return resolved;
}

if (typeof resolved === 'object') {
return resolved?.id;
}
}
};
}
1 change: 1 addition & 0 deletions packages/test-runner-intercept/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { interceptModulePlugin } from './interceptModulePlugin';
87 changes: 87 additions & 0 deletions packages/test-runner-intercept/src/interceptModulePlugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { Plugin } from '@web/dev-server-core';
import { readFile } from 'fs/promises';
import { parse } from 'es-module-lexer';
import { createResolveImport, ResolveImport } from './createResolveImport';
import { stripColor } from './stripColor';

/**
* Plugin that allows the interception of modules
*/
export function interceptModulePlugin(): Plugin {
const paths: string[] = [];

let resolveImport: ResolveImport;
return {
name: 'intercept-module',

serverStart(params) {
resolveImport = createResolveImport(params, this);
},
async serve(context) {
if (context.path.startsWith('/__intercept-module__/')) {
let body;
try {
const source = context.path.replace('/__intercept-module__/', '');
paths.push(source);

const resolvedPath = await resolveImport({ context, source });
const url = new URL(resolvedPath as string, context.request.href);
const relativeFilePath = url.pathname.substring(1);

const content = await readFile(relativeFilePath, 'utf-8');
const [, exports] = await parse(content, resolvedPath as string);

const namedExports = exports.map(e => e.n).filter(n => n !== 'default');
const hasDefaultExport = exports.some(e => e.n === 'default');

body = `
import * as original from '${resolvedPath}';
const newOriginal = {...original};
${namedExports.map(x => `export let ${x} = newOriginal['${x}'];`).join('\n')}
${
hasDefaultExport
? `
function computeDefault() {
if(typeof newOriginal.default === 'function'){
return (...args) => newOriginal.default.call(undefined, ...args);
}
return newOriginal.default;
}
export default computeDefault()
`
: ''
}
export const __wtr_intercepted_module__ = new Proxy(newOriginal, {
set: function(obj, prop, value) {
${namedExports.map(x => `if (prop === '${x}') { ${x} = value;}`).join('\n')}
return Reflect.set(obj, prop, value);
},
defineProperty(target, key, descriptor) {
${namedExports.map(x => `if (key === '${x}') { ${x} = descriptor.value;}`).join('\n')}
return Reflect.defineProperty(target, key, descriptor);
},
});
`;
} catch (error) {
// Server side errors might contain ANSI color escape sequences.
// These sequences are not readable in a browser's console, so we strip them.
const errorMessage = stripColor((error as Error).message).replaceAll("'", "\\'");
body = `export const __wtr_error__ = '${errorMessage}';`;
}
return { body, type: 'text/javascript' };
}
return undefined;
},

resolveImport({ source }) {
if (paths.includes(source)) {
return `/__intercept-module__/${source}`;
}
return undefined;
},
};
}
4 changes: 4 additions & 0 deletions packages/test-runner-intercept/src/stripColor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export function stripColor(str: string): string {
// eslint-disable-next-line no-control-regex
return str.replace(/\x1B[[(?);]{0,2}(;?\d)*./g, '');
}
Loading

0 comments on commit 27e98ea

Please sign in to comment.