Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -855,6 +855,29 @@ Test aliases are used to map tests in source code to tests in Testomat.io. By de
TESTOMATIO=11111111 npx check-tests Playwright "**/*{.,_}{test,spec}.ts" --test-alias myTest,myCustomFunction
```

For Playwright, aliases are also recognized on test annotations, so `myTest.skip(...)`, `myTest.fixme(...)`, `myTest.fail(...)`, `myTest.slow(...)` and `myTest.todo(...)` are parsed the same way as the built-in `test`/`it`:

```js
import { test as base } from '@playwright/test';

const myTest = base.extend({
/* ... */
});

myTest.skip('skipped alias test', async () => {
/* ... */
});
myTest.fixme('fixme alias test', async () => {
/* ... */
});
```

> **Important:** annotated tests declared on a custom object (e.g. `myTest.skip(...)`, `myTest.fail(...)`, `myTest.fixme(...)`, `myTest.slow(...)`) are **only** parsed when that object is passed via `--test-alias`. Without it, only the built-in `test`/`it` annotations are detected and these tests are silently skipped:
>
> ```
> TESTOMATIO={token} npx check-tests Playwright "**/*{.,_}{test,spec}.ts" --test-alias myTest,myTest2
> ```

## Programmatic API

Import Analyzer from module:
Expand Down
33 changes: 33 additions & 0 deletions example/playwright/annotations-status.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { test, expect } from '@playwright/test';

test('plain test', async () => {
await expect(true).toBe(true);
});

// .fail marks a test as expected to fail, but it still runs => not skipped
test.fail('expected to fail test', async () => {
await expect(true).toBe(false);
});

// .slow triples the timeout, but the test still runs => not skipped
test.slow('slow test', async () => {
await expect(true).toBe(true);
});

// .todo => skipped test
test.todo('todo test');

// runtime forms without a title declare no separate test
test('runtime annotations have no title', async () => {
test.fail();
test.skip();
test.slow();
await expect(true).toBe(true);
});

// .fail inside a skipped suite => skipped (suite wins)
test.describe.skip('skipped suite', () => {
test.fail('fail inside skipped suite', async () => {
await expect(true).toBe(false);
});
});
29 changes: 29 additions & 0 deletions example/playwright/custom-fixture-annotations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { test as base } from '@playwright/test';

const testFixture = base.extend<{ someFixture: any }>({
someFixture: async ({}, use) => {
await use({ name: 'custom fixture name' });
},
});

testFixture('plain alias test', async ({ someFixture }) => {
console.warn(someFixture.name);
});

testFixture.skip('skipped alias test', async ({ someFixture }) => {
console.warn(someFixture.name);
});

testFixture.fixme('fixme alias test', async ({ someFixture }) => {
console.warn(someFixture.name);
});

testFixture.fail('failing alias test', async ({ someFixture }) => {
console.warn(someFixture.name);
});

testFixture.describe('alias suite', () => {
testFixture.fixme('fixme test inside alias suite', async ({ someFixture }) => {
console.warn(someFixture.name);
});
});
16 changes: 16 additions & 0 deletions example/playwright/sibling-after-skipped-suite.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { test, expect } from '@playwright/test';

test.describe.skip('skipped suite', () => {
test('inside skipped suite', async () => {
await expect(true).toBe(true);
});
});

// these are siblings declared AFTER the skipped suite closed - they must not inherit skipped
test('sibling after skipped suite', async () => {
await expect(true).toBe(true);
});

test.fail('failing sibling after skipped suite', async () => {
await expect(true).toBe(false);
});
18 changes: 17 additions & 1 deletion src/analyzer.js
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,20 @@ class Analyzer {
// this.addPlugin('@babel/plugin-transform-typescript');
}

// Build the list of glob patterns to scan. When TypeScript support is enabled, a JS-only
// glob (e.g. "**/*.test.js") would silently match nothing in a TS project, so we also scan
// the TypeScript equivalents by swapping the trailing `.js` extension for `.ts`/`.tsx`/etc.
buildPatterns(pattern) {
const patterns = [pattern];
if (this.typeScript && /\.js$/.test(pattern)) {
const base = pattern.replace(/\.js$/, '');
for (const ext of ['ts', 'tsx', 'mts', 'cts']) {
patterns.push(`${base}.${ext}`);
}
}
return patterns;
}

analyze(pattern) {
if (!this.frameworkParser) throw new Error("No test framework specified. Can't analyze");

Expand All @@ -93,7 +107,9 @@ class Analyzer {
const originalCwd = process.cwd();
process.chdir(this.workDir);

let files = glob.sync(pattern, { windowsPathsNoEscape: true });
const patterns = this.buildPatterns(pattern);
debug('Patterns:', patterns);
let files = [...new Set(patterns.flatMap(p => glob.sync(p, { windowsPathsNoEscape: true })))];

// Exclude files matching the exclude pattern if provided
if (this.opts.exclude) {
Expand Down
148 changes: 67 additions & 81 deletions src/lib/frameworks/playwright.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,52 @@ module.exports = (ast, file = '', source = '', opts = {}) => {
let beforeEachCode = '';
let afterCode = '';

// valid test identifiers: built-in `test`/`it` plus any custom fixtures/aliases
const testNames = ['test', 'it', ...(opts?.testAlias || [])];

function addSuite(path) {
currentSuite = currentSuite.filter(s => s.loc.end.line > path.loc.start.line);
path.tags = playwright.getTestProps({ parent: { expression: path } }).tags;
currentSuite.push(path);
}

// suites that actually enclose the call at `path`. `currentSuite` is only pruned when a
// new suite is added, so it can still hold sibling suites that already closed above this
// line — those must not leak their name or `skipped` flag onto a test declared after them.
function getEnclosingSuites(path) {
return currentSuite.filter(s => getEndLineNumber({ container: s }) >= getLineNumber(path));
}

// resolve the name of the test object an annotation (`.skip`, `.fixme`, `.fail`, `.slow`)
// is called on, e.g. `test`, `it`, `describe` or a custom test alias / fixture name
function getTestObjectName(path) {
if (!path.parent || !path.parent.object) return null;
return (
path.parent.object.name || path.parent.object.property?.name || path.parent.object.callee?.object?.name || null
);
}

// Register a single named test declared with an annotation call
// (`test.skip` / `test.fixme` / `test.fail` / `test.todo`, or the alias equivalents).
// `path` is the annotation identifier node; its enclosing call holds the test title.
// Calls without a string title (the runtime form `test.skip()` used inside a test body)
// declare no test and are ignored. The caller decides `skipped`.
function registerAnnotatedTest(path, { skipped }) {
if (!hasStringOrTemplateArgument(path.parentPath.container)) return;

tests.push({
name: getStringValue(path.parentPath.container),
suites: getEnclosingSuites(path).map(s => getStringValue(s)),
line: getLineNumber(path),
// `path` is the annotation identifier (`fixme`/`skip`/...); its container ends on the
// member-expression line only. The full call (and its body) is `path.parentPath.container`,
// so take the end line from there to capture the complete test code.
code: getCode(source, getLineNumber(path), getEndLineNumber(path.parentPath), isLineNumber),
file,
skipped,
});
}

traverse(ast, {
enter(path) {
if (path.isIdentifier({ name: 'describe' })) {
Expand Down Expand Up @@ -90,31 +130,16 @@ module.exports = (ast, file = '', source = '', opts = {}) => {
}
}

if (path.isIdentifier({ name: 'skip' })) {
if (!path.parent || !path.parent.object) {
return;
}
const name =
path.parent.object.name || path.parent.object.property.name || path.parent.object.callee.object.name;

if (name === 'test' || name === 'it') {
// test or it
if (!hasStringOrTemplateArgument(path.parentPath.container)) return;

const testName = getStringValue(path.parentPath.container);
tests.push({
name: testName,
suites: currentSuite
.filter(s => getEndLineNumber({ container: s }) >= getLineNumber(path))
.map(s => getStringValue(s)),
line: getLineNumber(path),
code: getCode(source, getLineNumber(path), getEndLineNumber(path), isLineNumber),
file,
skipped: true,
});
}
// `.skip` / `.fixme` mark a test (or every test in a suite) as skipped,
// supporting `test`, `it`, `describe` and any custom test alias / fixture
if (path.isIdentifier({ name: 'skip' }) || path.isIdentifier({ name: 'fixme' })) {
const name = getTestObjectName(path);
if (!name) return;

if (name === 'describe') {
if (testNames.includes(name)) {
// test or it (or alias), e.g. `myFixture.fixme('...', ...)`
registerAnnotatedTest(path, { skipped: true });
} else if (name === 'describe') {
// suite
if (!hasStringOrTemplateArgument(path.parentPath.container)) return;
const suite = path.parentPath.container;
Expand All @@ -125,61 +150,24 @@ module.exports = (ast, file = '', source = '', opts = {}) => {
// todo: handle "context"
}

if (path.isIdentifier({ name: 'fixme' })) {
if (!path.parent || !path.parent.object) {
return;
}
const name =
path.parent.object.name || path.parent.object.property.name || path.parent.object.callee.object.name;

if (name === 'test' || name === 'it') {
// test or it
if (!hasStringOrTemplateArgument(path.parentPath.container)) return;
// `.fail` (expected to fail) and `.slow` (extended timeout) still run, so they are not
// skipped on their own — only inherited skip from an enclosing suite applies
if (path.isIdentifier({ name: 'fail' }) || path.isIdentifier({ name: 'slow' })) {
const name = getTestObjectName(path);
if (!name) return;

const testName = getStringValue(path.parentPath.container);
tests.push({
name: testName,
suites: currentSuite
.filter(s => getEndLineNumber({ container: s }) >= getLineNumber(path))
.map(s => getStringValue(s)),
line: getLineNumber(path),
code: getCode(source, getLineNumber(path), getEndLineNumber(path), isLineNumber),
file,
skipped: true,
});
if (testNames.includes(name)) {
registerAnnotatedTest(path, { skipped: getEnclosingSuites(path).some(s => s.skipped) });
}

if (name === 'describe') {
// suite
if (!hasStringOrTemplateArgument(path.parentPath.container)) return;
const suite = path.parentPath.container;
suite.skipped = true;
addSuite(suite);
}

// todo: handle "context"
}

if (path.isIdentifier({ name: 'todo' })) {
if (!path.parent || !path.parent.object) {
return;
}
// todo tests => skipped tests
if (path.parent.object.name === 'test') {
// test
if (!hasStringOrTemplateArgument(path.parentPath.container)) return;
const name = getTestObjectName(path);
if (!name) return;

const testName = getStringValue(path.parentPath.container);
tests.push({
name: testName,
suites: currentSuite
.filter(s => getEndLineNumber({ container: s }) >= getLineNumber(path))
.map(s => getStringValue(s)),
line: getLineNumber(path),
code: getCode(source, getLineNumber(path), getEndLineNumber(path), isLineNumber),
file,
skipped: true,
});
// todo tests => skipped tests
if (testNames.includes(name)) {
registerAnnotatedTest(path, { skipped: true });
}
}

Expand All @@ -202,19 +190,18 @@ module.exports = (ast, file = '', source = '', opts = {}) => {
afterCode;

const testName = getStringValue(path.parent);
const enclosingSuites = getEnclosingSuites(path);

tests.push({
name: testName,
suites: currentSuite
.filter(s => getEndLineNumber({ container: s }) >= getLineNumber(path))
.map(s => getStringValue(s)),
suites: enclosingSuites.map(s => getStringValue(s)),
updatePoint: getUpdatePoint(path.parent),
line: getLineNumber(path),
code,
file,
tags: [...getAllSuiteTags(currentSuite), ...playwright.getTestProps(path.parentPath).tags],
annotations: playwright.getTestProps(path.parentPath).annotations,
skipped: !!currentSuite.filter(s => s.skipped).length,
skipped: enclosingSuites.some(s => s.skipped),
});

// stop the loop if the test is found
Expand All @@ -227,16 +214,15 @@ module.exports = (ast, file = '', source = '', opts = {}) => {

if (!hasStringOrTemplateArgument(currentPath.parent)) return;
const testName = getStringValue(currentPath.parent);
const enclosingSuites = getEnclosingSuites(path);
tests.push({
name: testName,
suites: currentSuite
.filter(s => getEndLineNumber({ container: s }) >= getLineNumber(path))
.map(s => getStringValue(s)),
suites: enclosingSuites.map(s => getStringValue(s)),
updatePoint: getUpdatePoint(path.parent),
line: getLineNumber(currentPath),
code: getCode(source, getLineNumber(currentPath), getEndLineNumber(currentPath), isLineNumber),
file,
skipped: !!currentSuite.filter(s => s.skipped).length,
skipped: enclosingSuites.some(s => s.skipped),
});
}
},
Expand Down
27 changes: 27 additions & 0 deletions tests/analyzer_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,33 @@ describe('analyzer', () => {
expect(decorator.getSuiteNames()).to.include('Login - Global Header: Institutional Sign In Modal');
});

it('should also scan TypeScript files when given a JS-only glob and TypeScript is enabled', () => {
analyzer = new Analyzer('mocha', path.join(__dirname, '..'));
analyzer.withTypeScript();
// a `.js` pattern would match nothing in this TS-only dir; buildPatterns adds the `.ts` variant
analyzer.analyze('./example/protractor/**.js');
const decorator = analyzer.getDecorator();
expect(decorator.getSuiteNames()).to.include('Login - Global Header: Institutional Sign In Modal');
});

it('buildPatterns expands a trailing .js extension to TS variants only when TypeScript is enabled', () => {
analyzer = new Analyzer('mocha', path.join(__dirname, '..'));

// without TypeScript the pattern is left untouched
expect(analyzer.buildPatterns('./example/foo/**.js')).to.deep.equal(['./example/foo/**.js']);
// non-.js patterns are never expanded, even with TypeScript on
analyzer.withTypeScript();
expect(analyzer.buildPatterns('./example/foo/**.ts')).to.deep.equal(['./example/foo/**.ts']);
// .js patterns gain the TS equivalents
expect(analyzer.buildPatterns('./example/foo/**.js')).to.deep.equal([
'./example/foo/**.js',
'./example/foo/**.ts',
'./example/foo/**.tsx',
'./example/foo/**.mts',
'./example/foo/**.cts',
]);
});

it('should exclude dir in file name if dir specified', () => {
analyzer = new Analyzer('mocha', 'example');
analyzer.analyze('mocha/**_test.js');
Expand Down
Loading
Loading