Skip to content

Commit 6218f66

Browse files
khalatevarunautofix-ci[bot]rmarescu
authored
feat(cli): execute one test from a test file (#338)
Resolves #281 Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Razvan Marescu <[email protected]>
1 parent 1dba67e commit 6218f66

File tree

12 files changed

+1307
-568
lines changed

12 files changed

+1307
-568
lines changed

README.md

+4-3
Original file line numberDiff line numberDiff line change
@@ -190,9 +190,10 @@ shortest(`
190190
### Running tests
191191

192192
```bash
193-
pnpm shortest # Run all tests
194-
pnpm shortest __tests__/login.test.ts # Run specific test
195-
pnpm shortest --headless # Run in headless mode using CLI
193+
pnpm shortest # Run all tests
194+
pnpm shortest login.test.ts # Run specific tests from a file
195+
pnpm shortest login.test.ts:23 # Run specific test from a file using a line number
196+
pnpm shortest --headless # Run in headless mode using
196197
```
197198

198199
You can find example tests in the [`examples`](./examples) directory.

examples/api-assert-bearer.test.ts

+3-4
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,9 @@ import { shortest } from "@antiwork/shortest";
22
import { ALLOWED_TEST_BEARER, TESTING_API_BASE_URI } from "@/lib/constants";
33

44
// @note you should be authenticated in Clerk to run this test
5-
shortest(`
6-
Test the API POST endpoint ${TESTING_API_BASE_URI}/assert-bearer with body { "flagged": "false" } without providing a bearer token.
7-
Expect the response to indicate that the token is missing
8-
`);
5+
shortest(
6+
`Test the API POST endpoint ${TESTING_API_BASE_URI}/assert-bearer with body { "flagged": "false" } without providing a bearer token.`,
7+
).expect("Expect the response to indicate that the token is missing");
98

109
shortest(`
1110
Test the API POST endpoint ${TESTING_API_BASE_URI}/assert-bearer with body { "flagged": "true" } and the bearer token ${ALLOWED_TEST_BEARER}.

packages/shortest/README.md

+3-2
Original file line numberDiff line numberDiff line change
@@ -188,8 +188,9 @@ shortest(`
188188

189189
```bash
190190
pnpm shortest # Run all tests
191-
pnpm shortest login.test.ts # Run specific test
192-
pnpm shortest --headless # Run in headless mode using cli
191+
pnpm shortest login.test.ts # Run specific tests from a file
192+
pnpm shortest login.test.ts:23 # Run specific test from a file using a line number
193+
pnpm shortest --headless # Run in headless mode using
193194
```
194195

195196
You can find example tests in the [`examples`](./examples) directory.

packages/shortest/package.json

+7-3
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,9 @@
2828
"prepublishOnly": "pnpm build",
2929
"postinstall": "node -e \"if (process.platform !== 'win32') { try { require('child_process').execSync('chmod +x dist/cli/bin.js') } catch (_) {} }\"",
3030
"build:types": "tsup src/index.ts --dts-only --format esm --outDir dist",
31-
"build:js": "esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js --external:esbuild --external:punycode --external:playwright --external:expect --external:dotenv --external:ai --external:@ai-sdk/*",
32-
"build:cjs": "esbuild src/index.ts --bundle --platform=node --format=cjs --outfile=dist/index.cjs --external:esbuild --external:punycode --external:playwright --external:expect --external:dotenv --external:ai --external:@ai-sdk/*",
33-
"build:cli": "esbuild src/cli/bin.ts --bundle --platform=node --format=esm --outdir=dist/cli --metafile=dist/meta-cli.json --external:fsevents --external:chokidar --external:glob --external:esbuild --external:events --external:path --external:fs --external:util --external:stream --external:os --external:assert --external:url --external:playwright --external:expect --external:dotenv --external:otplib --external:picocolors --external:punycode --external:https --external:http --external:net --external:tls --external:crypto --external:mailosaur --external:ai --external:@ai-sdk/*",
31+
"build:js": "esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js --external:esbuild --external:punycode --external:playwright --external:expect --external:dotenv --external:ai --external:@ai-sdk/* --external:@babel/* --external:tty",
32+
"build:cjs": "esbuild src/index.ts --bundle --platform=node --format=cjs --outfile=dist/index.cjs --external:esbuild --external:punycode --external:playwright --external:expect --external:dotenv --external:ai --external:@ai-sdk/* --external:@babel/* --external:tty",
33+
"build:cli": "esbuild src/cli/bin.ts --bundle --platform=node --format=esm --outdir=dist/cli --metafile=dist/meta-cli.json --external:fsevents --external:chokidar --external:glob --external:esbuild --external:events --external:path --external:fs --external:util --external:stream --external:os --external:assert --external:url --external:playwright --external:expect --external:dotenv --external:otplib --external:picocolors --external:punycode --external:https --external:http --external:net --external:tls --external:crypto --external:mailosaur --external:ai --external:@ai-sdk/* --external:@babel/* --external:tty --external:debug",
3434
"dev": "pnpm build --watch",
3535
"test:unit": "npx vitest run",
3636
"test:unit:watch": "npx vitest --watch",
@@ -61,6 +61,10 @@
6161
"@ai-sdk/anthropic": "^1.1.9",
6262
"@ai-sdk/provider": "^1.0.8",
6363
"ai": "^4.1.45",
64+
"@babel/parser": "^7.26.9",
65+
"@babel/traverse": "^7.26.9",
66+
"@babel/types": "^7.26.9",
67+
"@types/babel__traverse": "^7.20.6",
6468
"dotenv": "^16.4.5",
6569
"esbuild": "^0.20.1",
6670
"expect": "^29.7.0",

packages/shortest/src/cli/bin.ts

+12-2
Original file line numberDiff line numberDiff line change
@@ -175,8 +175,15 @@ const main = async () => {
175175
const baseUrl = args
176176
.find((arg) => arg.startsWith("--target="))
177177
?.split("=")[1];
178-
const testPattern = args.find((arg) => !arg.startsWith("--"));
178+
let testPattern = args.find((arg) => !arg.startsWith("--"));
179179
const noCache = args.includes("--no-cache");
180+
let lineNumber: number | undefined;
181+
182+
if (testPattern?.includes(":")) {
183+
const [file, line] = testPattern.split(":");
184+
testPattern = file;
185+
lineNumber = parseInt(line, 10);
186+
}
180187

181188
const cliOptions: CLIOptions = {
182189
headless,
@@ -194,7 +201,10 @@ const main = async () => {
194201
log.trace("Initializing TestRunner");
195202
const runner = new TestRunner(process.cwd(), config);
196203
await runner.initialize();
197-
const success = await runner.execute(config.testPattern);
204+
const success = await runner.execute(
205+
testPattern ?? config.testPattern,
206+
lineNumber,
207+
);
198208
process.exitCode = success ? 0 : 1;
199209
} catch (error: any) {
200210
log.trace("Handling error for TestRunner");

packages/shortest/src/core/runner/index.ts

+75-9
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ import { BrowserTool } from "@/browser/core/browser-tool";
99
import { BrowserManager } from "@/browser/manager";
1010
import { TestCache } from "@/cache";
1111
import { TestCompiler } from "@/core/compiler";
12+
import {
13+
EXPRESSION_PLACEHOLDER,
14+
parseShortestTestFile,
15+
} from "@/core/runner/test-file-parser";
1216
import { TestReporter } from "@/core/runner/test-reporter";
1317
import { getLogger, Log } from "@/log";
1418
import {
@@ -281,16 +285,78 @@ export class TestRunner {
281285
};
282286
}
283287

284-
private async executeTestFile(file: string) {
288+
private async filterTestsByLineNumber(
289+
tests: TestFunction[],
290+
file: string,
291+
lineNumber: number,
292+
): Promise<TestFunction[]> {
293+
const testLocations = parseShortestTestFile(file);
294+
const escapeRegex = (str: string) =>
295+
str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
296+
297+
const filteredTests = tests.filter((test) => {
298+
const testNameNormalized = test.name.trim();
299+
let testLocation = testLocations.find(
300+
(location) => location.testName === testNameNormalized,
301+
);
302+
303+
if (!testLocation) {
304+
testLocation = testLocations.find((location) => {
305+
const TEMP_TOKEN = "##PLACEHOLDER##";
306+
let pattern = location.testName.replace(
307+
new RegExp(escapeRegex(EXPRESSION_PLACEHOLDER), "g"),
308+
TEMP_TOKEN,
309+
);
310+
311+
pattern = escapeRegex(pattern);
312+
pattern = pattern.replace(new RegExp(TEMP_TOKEN, "g"), ".*");
313+
const regex = new RegExp(`^${pattern}$`);
314+
315+
return regex.test(testNameNormalized);
316+
});
317+
}
318+
319+
if (!testLocation) {
320+
return false;
321+
}
322+
323+
const isInRange =
324+
lineNumber >= testLocation.startLine &&
325+
lineNumber <= testLocation.endLine;
326+
return isInRange;
327+
});
328+
329+
return filteredTests;
330+
}
331+
332+
private async executeTestFile(file: string, lineNumber?: number) {
285333
try {
334+
this.log.trace("Executing test file", { file, lineNumber });
286335
const registry = (global as any).__shortest__.registry;
287336
registry.tests.clear();
288337
registry.currentFileTests = [];
289338

290339
const filePathWithoutCwd = file.replace(this.cwd + "/", "");
291340
const compiledPath = await this.compiler.compileFile(file);
341+
292342
this.log.trace("Importing compiled file", { compiledPath });
293343
await import(pathToFileURL(compiledPath).href);
344+
let testsToRun = registry.currentFileTests;
345+
346+
if (lineNumber) {
347+
testsToRun = await this.filterTestsByLineNumber(
348+
registry.currentFileTests,
349+
file,
350+
lineNumber,
351+
);
352+
if (testsToRun.length === 0) {
353+
this.reporter.error(
354+
"Test Discovery",
355+
`No test found at line ${lineNumber} in ${filePathWithoutCwd}`,
356+
);
357+
process.exit(1);
358+
}
359+
}
294360

295361
let context;
296362
try {
@@ -309,14 +375,11 @@ export class TestRunner {
309375
await hook(testContext);
310376
}
311377

312-
this.reporter.onFileStart(
313-
filePathWithoutCwd,
314-
registry.currentFileTests.length,
315-
);
378+
this.reporter.onFileStart(filePathWithoutCwd, testsToRun.length);
316379

317380
// Execute tests in order they were defined
318-
this.log.info(`Running ${registry.currentFileTests.length} test(s)`);
319-
for (const test of registry.currentFileTests) {
381+
this.log.info(`Running ${testsToRun.length} test(s)`);
382+
for (const test of testsToRun) {
320383
// Execute beforeEach hooks with shared context
321384
for (const hook of registry.beforeEachFns) {
322385
await hook(testContext);
@@ -363,12 +426,15 @@ export class TestRunner {
363426
}
364427
}
365428

366-
async execute(testPattern: string): Promise<boolean> {
429+
async execute(testPattern: string, lineNumber?: number): Promise<boolean> {
367430
this.log.trace("Finding test files", { testPattern });
431+
368432
const files = await glob(testPattern, {
369433
cwd: this.cwd,
370434
absolute: true,
371435
});
436+
this.log.trace("Found test files", { files });
437+
372438
if (files.length === 0) {
373439
this.reporter.error(
374440
"Test Discovery",
@@ -382,7 +448,7 @@ export class TestRunner {
382448

383449
this.reporter.onRunStart(files.length);
384450
for (const file of files) {
385-
await this.executeTestFile(file);
451+
await this.executeTestFile(file, lineNumber);
386452
}
387453
this.reporter.onRunEnd();
388454

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import { readFileSync } from "fs";
2+
import * as parser from "@babel/parser";
3+
import type { NodePath } from "@babel/traverse";
4+
import traverse from "@babel/traverse";
5+
import type * as t from "@babel/types";
6+
import * as babelTypes from "@babel/types";
7+
import { z } from "zod";
8+
import { getLogger } from "@/log";
9+
10+
export const EXPRESSION_PLACEHOLDER = "${...}";
11+
12+
export const TestLocationSchema = z.object({
13+
testName: z.string(),
14+
startLine: z.number().int().positive(),
15+
endLine: z.number().int().positive(),
16+
});
17+
export type TestLocation = z.infer<typeof TestLocationSchema>;
18+
19+
const TestLocationsSchema = z.array(TestLocationSchema);
20+
21+
export const parseShortestTestFile = (filePath: string): TestLocation[] => {
22+
const log = getLogger();
23+
try {
24+
log.setGroup("File Parser");
25+
26+
const TemplateElementSchema = z.object({
27+
value: z.object({
28+
cooked: z.string().optional(),
29+
raw: z.string().optional(),
30+
}),
31+
});
32+
type TemplateElement = z.infer<typeof TemplateElementSchema>;
33+
34+
const StringLiteralSchema = z.object({
35+
type: z.literal("StringLiteral"),
36+
value: z.string(),
37+
});
38+
39+
const TemplateLiteralSchema = z.object({
40+
type: z.literal("TemplateLiteral"),
41+
quasis: z.array(TemplateElementSchema),
42+
});
43+
44+
const fileContent = readFileSync(filePath, "utf8");
45+
const ast = parser.parse(fileContent, {
46+
sourceType: "module",
47+
plugins: [
48+
"typescript",
49+
"objectRestSpread",
50+
"optionalChaining",
51+
"nullishCoalescingOperator",
52+
],
53+
});
54+
55+
const testLocations: TestLocation[] = [];
56+
57+
const testCallsByLine = new Map<
58+
number,
59+
{ name: string; node: NodePath<t.CallExpression> }
60+
>();
61+
62+
traverse(ast, {
63+
CallExpression(path: NodePath<t.CallExpression>) {
64+
const node = path.node;
65+
66+
if (
67+
!node.type ||
68+
node.type !== "CallExpression" ||
69+
!node.callee ||
70+
node.callee.type !== "Identifier" ||
71+
node.callee.name !== "shortest"
72+
) {
73+
return;
74+
}
75+
76+
const args = node.arguments || [];
77+
if (args.length === 0) return;
78+
79+
const firstArg = args[0];
80+
let testName = "";
81+
82+
if (babelTypes.isStringLiteral(firstArg)) {
83+
const parsed = StringLiteralSchema.parse(firstArg);
84+
testName = parsed.value;
85+
} else if (babelTypes.isTemplateLiteral(firstArg)) {
86+
const parsed = TemplateLiteralSchema.parse(firstArg);
87+
testName = parsed.quasis
88+
.map(
89+
(quasi: TemplateElement, i: number, arr: TemplateElement[]) => {
90+
const str = quasi.value.cooked || quasi.value.raw || "";
91+
return i < arr.length - 1 ? str + EXPRESSION_PLACEHOLDER : str;
92+
},
93+
)
94+
.join("")
95+
.replace(/\s+/g, " ")
96+
.trim();
97+
} else {
98+
return;
99+
}
100+
101+
const startLine = node.loc?.start?.line || 0;
102+
testCallsByLine.set(startLine, {
103+
name: testName,
104+
node: path,
105+
});
106+
},
107+
});
108+
109+
const sortedStartLines = Array.from(testCallsByLine.keys()).sort(
110+
(a, b) => a - b,
111+
);
112+
113+
for (let i = 0; i < sortedStartLines.length; i++) {
114+
const currentLine = sortedStartLines[i];
115+
const nextLine = sortedStartLines[i + 1] || Number.MAX_SAFE_INTEGER;
116+
const { name, node } = testCallsByLine.get(currentLine)!;
117+
118+
let path = node;
119+
let endLine = path.node.loc?.end?.line || 0;
120+
121+
let currentPath: NodePath<t.Node> = path;
122+
while (
123+
currentPath.parentPath &&
124+
(currentPath.parentPath.node.loc?.end?.line ?? 0) < nextLine
125+
) {
126+
const parentType = currentPath.parentPath.node.type;
127+
128+
if (parentType === "ExpressionStatement") {
129+
endLine = currentPath.parentPath.node.loc?.end?.line || endLine;
130+
break;
131+
}
132+
133+
if (
134+
parentType === "CallExpression" ||
135+
parentType === "MemberExpression"
136+
) {
137+
currentPath = currentPath.parentPath;
138+
endLine = Math.max(endLine, currentPath.node.loc?.end?.line || 0);
139+
} else {
140+
endLine = Math.max(
141+
endLine,
142+
currentPath.parentPath.node.loc?.end?.line || endLine,
143+
);
144+
break;
145+
}
146+
}
147+
endLine = Math.min(endLine, nextLine - 1);
148+
149+
const testLocation = TestLocationSchema.parse({
150+
testName: name,
151+
startLine: currentLine,
152+
endLine,
153+
});
154+
testLocations.push(testLocation);
155+
}
156+
157+
log.trace("Test locations", { filePath, testLocations });
158+
159+
return TestLocationsSchema.parse(testLocations);
160+
} finally {
161+
log.resetGroup();
162+
}
163+
};

0 commit comments

Comments
 (0)