diff --git a/biome.json b/biome.json index 628b706..c911e8c 100644 --- a/biome.json +++ b/biome.json @@ -17,6 +17,7 @@ "includes": [ "**/packages/**", "**/scripts/**", + "**/e2e/**", "!skills/**/scripts/*.{js,cjs,mjs}", "!**/node_modules", "!**/dist", diff --git a/e2e/cdp/fixtures/basic-no-debug/package.json b/e2e/cdp/fixtures/basic-no-debug/package.json new file mode 100644 index 0000000..7d23b32 --- /dev/null +++ b/e2e/cdp/fixtures/basic-no-debug/package.json @@ -0,0 +1,9 @@ +{ + "name": "@agent-skills/cdp-fixture-basic-no-debug", + "version": "1.0.0", + "private": true, + "type": "module", + "devDependencies": { + "@rstest/core": "^0.8.2" + } +} diff --git a/e2e/cdp/fixtures/basic-no-debug/rstest.config.ts b/e2e/cdp/fixtures/basic-no-debug/rstest.config.ts new file mode 100644 index 0000000..7cdbf20 --- /dev/null +++ b/e2e/cdp/fixtures/basic-no-debug/rstest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from '@rstest/core'; + +export default defineConfig({ + root: __dirname, + tools: { + rspack: (config) => { + config.devtool = 'inline-source-map'; + }, + }, + dev: { + writeToDisk: true, + }, +}); diff --git a/e2e/cdp/fixtures/basic-no-debug/src/math.ts b/e2e/cdp/fixtures/basic-no-debug/src/math.ts new file mode 100644 index 0000000..24b16ac --- /dev/null +++ b/e2e/cdp/fixtures/basic-no-debug/src/math.ts @@ -0,0 +1,13 @@ +export const summarizeScores = (scores: number[]) => { + const total = scores.reduce((sum, value) => sum + value, 0); + const average = scores.length ? total / scores.length : 0; + const weightedTotal = total + average * 0.25; + const label = `${scores.length}-scores`; + + return { + total, + average, + weightedTotal, + label, + }; +}; diff --git a/e2e/cdp/fixtures/basic-no-debug/src/profile.ts b/e2e/cdp/fixtures/basic-no-debug/src/profile.ts new file mode 100644 index 0000000..faca2b5 --- /dev/null +++ b/e2e/cdp/fixtures/basic-no-debug/src/profile.ts @@ -0,0 +1,15 @@ +export const formatUser = (name: string, role = 'member') => { + const trimmed = name.trim(); + const [firstName = '', lastName = ''] = trimmed.split(' '); + const normalized = `${firstName.toLowerCase()}-${lastName.toLowerCase()}`; + const displayName = `${firstName} ${lastName}`.trim(); + + return { + trimmed, + firstName, + lastName, + normalized, + displayName, + role, + }; +}; diff --git a/e2e/cdp/fixtures/basic-no-debug/test/combined.test.ts b/e2e/cdp/fixtures/basic-no-debug/test/combined.test.ts new file mode 100644 index 0000000..cb65373 --- /dev/null +++ b/e2e/cdp/fixtures/basic-no-debug/test/combined.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from '@rstest/core'; +import { summarizeScores } from '../src/math'; +import { formatUser } from '../src/profile'; + +describe('combined tests', () => { + it('formats user profile', () => { + const profile = formatUser('Ada Lovelace', 'admin'); + + expect(profile.displayName).toBe('Ada Lovelace'); + expect(profile.normalized).toBe('ada-lovelace'); + }); + + it('summarizes scores', () => { + const result = summarizeScores([12, 18, 30]); + + expect(result.total).toBe(60); + expect(result.average).toBe(20); + expect(result.weightedTotal).toBe(65); + }); +}); diff --git a/e2e/cdp/fixtures/basic-no-debug/test/math.test.ts b/e2e/cdp/fixtures/basic-no-debug/test/math.test.ts new file mode 100644 index 0000000..619169a --- /dev/null +++ b/e2e/cdp/fixtures/basic-no-debug/test/math.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from '@rstest/core'; +import { summarizeScores } from '../src/math'; + +describe('score summary', () => { + it('computes totals', () => { + const result = summarizeScores([12, 18, 30]); + + expect(result.total).toBe(60); + expect(result.average).toBe(20); + expect(result.weightedTotal).toBe(65); + }); +}); diff --git a/e2e/cdp/fixtures/basic-no-debug/test/profile.test.ts b/e2e/cdp/fixtures/basic-no-debug/test/profile.test.ts new file mode 100644 index 0000000..0c5299a --- /dev/null +++ b/e2e/cdp/fixtures/basic-no-debug/test/profile.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from '@rstest/core'; +import { formatUser } from '../src/profile'; + +describe('user profile', () => { + it('formats display names', () => { + const profile = formatUser('Ada Lovelace', 'admin'); + + expect(profile.displayName).toBe('Ada Lovelace'); + expect(profile.normalized).toBe('ada-lovelace'); + }); +}); diff --git a/e2e/cdp/fixtures/basic-no-debug/tsconfig.json b/e2e/cdp/fixtures/basic-no-debug/tsconfig.json new file mode 100644 index 0000000..599608f --- /dev/null +++ b/e2e/cdp/fixtures/basic-no-debug/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "module": "ESNext", + "target": "ESNext", + "moduleResolution": "Bundler", + "types": ["@rstest/core"], + "strict": true + }, + "include": ["src", "test", "rstest.config.ts"] +} diff --git a/e2e/cdp/fixtures/basic/package.json b/e2e/cdp/fixtures/basic/package.json new file mode 100644 index 0000000..98b99e8 --- /dev/null +++ b/e2e/cdp/fixtures/basic/package.json @@ -0,0 +1,9 @@ +{ + "name": "@agent-skills/cdp-fixture-basic", + "version": "1.0.0", + "private": true, + "type": "module", + "devDependencies": { + "@rstest/core": "^0.8.2" + } +} diff --git a/e2e/cdp/fixtures/basic/rstest.config.ts b/e2e/cdp/fixtures/basic/rstest.config.ts new file mode 100644 index 0000000..7cdbf20 --- /dev/null +++ b/e2e/cdp/fixtures/basic/rstest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from '@rstest/core'; + +export default defineConfig({ + root: __dirname, + tools: { + rspack: (config) => { + config.devtool = 'inline-source-map'; + }, + }, + dev: { + writeToDisk: true, + }, +}); diff --git a/e2e/cdp/fixtures/basic/src/math.ts b/e2e/cdp/fixtures/basic/src/math.ts new file mode 100644 index 0000000..24b16ac --- /dev/null +++ b/e2e/cdp/fixtures/basic/src/math.ts @@ -0,0 +1,13 @@ +export const summarizeScores = (scores: number[]) => { + const total = scores.reduce((sum, value) => sum + value, 0); + const average = scores.length ? total / scores.length : 0; + const weightedTotal = total + average * 0.25; + const label = `${scores.length}-scores`; + + return { + total, + average, + weightedTotal, + label, + }; +}; diff --git a/e2e/cdp/fixtures/basic/src/profile.ts b/e2e/cdp/fixtures/basic/src/profile.ts new file mode 100644 index 0000000..faca2b5 --- /dev/null +++ b/e2e/cdp/fixtures/basic/src/profile.ts @@ -0,0 +1,15 @@ +export const formatUser = (name: string, role = 'member') => { + const trimmed = name.trim(); + const [firstName = '', lastName = ''] = trimmed.split(' '); + const normalized = `${firstName.toLowerCase()}-${lastName.toLowerCase()}`; + const displayName = `${firstName} ${lastName}`.trim(); + + return { + trimmed, + firstName, + lastName, + normalized, + displayName, + role, + }; +}; diff --git a/e2e/cdp/fixtures/basic/test/combined.test.ts b/e2e/cdp/fixtures/basic/test/combined.test.ts new file mode 100644 index 0000000..cb65373 --- /dev/null +++ b/e2e/cdp/fixtures/basic/test/combined.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from '@rstest/core'; +import { summarizeScores } from '../src/math'; +import { formatUser } from '../src/profile'; + +describe('combined tests', () => { + it('formats user profile', () => { + const profile = formatUser('Ada Lovelace', 'admin'); + + expect(profile.displayName).toBe('Ada Lovelace'); + expect(profile.normalized).toBe('ada-lovelace'); + }); + + it('summarizes scores', () => { + const result = summarizeScores([12, 18, 30]); + + expect(result.total).toBe(60); + expect(result.average).toBe(20); + expect(result.weightedTotal).toBe(65); + }); +}); diff --git a/e2e/cdp/fixtures/basic/test/math.test.ts b/e2e/cdp/fixtures/basic/test/math.test.ts new file mode 100644 index 0000000..619169a --- /dev/null +++ b/e2e/cdp/fixtures/basic/test/math.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from '@rstest/core'; +import { summarizeScores } from '../src/math'; + +describe('score summary', () => { + it('computes totals', () => { + const result = summarizeScores([12, 18, 30]); + + expect(result.total).toBe(60); + expect(result.average).toBe(20); + expect(result.weightedTotal).toBe(65); + }); +}); diff --git a/e2e/cdp/fixtures/basic/test/profile.test.ts b/e2e/cdp/fixtures/basic/test/profile.test.ts new file mode 100644 index 0000000..0c5299a --- /dev/null +++ b/e2e/cdp/fixtures/basic/test/profile.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from '@rstest/core'; +import { formatUser } from '../src/profile'; + +describe('user profile', () => { + it('formats display names', () => { + const profile = formatUser('Ada Lovelace', 'admin'); + + expect(profile.displayName).toBe('Ada Lovelace'); + expect(profile.normalized).toBe('ada-lovelace'); + }); +}); diff --git a/e2e/cdp/fixtures/basic/tsconfig.json b/e2e/cdp/fixtures/basic/tsconfig.json new file mode 100644 index 0000000..599608f --- /dev/null +++ b/e2e/cdp/fixtures/basic/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "module": "ESNext", + "target": "ESNext", + "moduleResolution": "Bundler", + "types": ["@rstest/core"], + "strict": true + }, + "include": ["src", "test", "rstest.config.ts"] +} diff --git a/e2e/cdp/fixtures/invalid-line/package.json b/e2e/cdp/fixtures/invalid-line/package.json new file mode 100644 index 0000000..fbc26c0 --- /dev/null +++ b/e2e/cdp/fixtures/invalid-line/package.json @@ -0,0 +1,9 @@ +{ + "name": "@agent-skills/cdp-fixture-invalid-line", + "version": "1.0.0", + "private": true, + "type": "module", + "devDependencies": { + "@rstest/core": "^0.8.2" + } +} diff --git a/e2e/cdp/fixtures/invalid-line/rstest.config.ts b/e2e/cdp/fixtures/invalid-line/rstest.config.ts new file mode 100644 index 0000000..7cdbf20 --- /dev/null +++ b/e2e/cdp/fixtures/invalid-line/rstest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from '@rstest/core'; + +export default defineConfig({ + root: __dirname, + tools: { + rspack: (config) => { + config.devtool = 'inline-source-map'; + }, + }, + dev: { + writeToDisk: true, + }, +}); diff --git a/e2e/cdp/fixtures/invalid-line/src/math.ts b/e2e/cdp/fixtures/invalid-line/src/math.ts new file mode 100644 index 0000000..24b16ac --- /dev/null +++ b/e2e/cdp/fixtures/invalid-line/src/math.ts @@ -0,0 +1,13 @@ +export const summarizeScores = (scores: number[]) => { + const total = scores.reduce((sum, value) => sum + value, 0); + const average = scores.length ? total / scores.length : 0; + const weightedTotal = total + average * 0.25; + const label = `${scores.length}-scores`; + + return { + total, + average, + weightedTotal, + label, + }; +}; diff --git a/e2e/cdp/fixtures/invalid-line/src/profile.ts b/e2e/cdp/fixtures/invalid-line/src/profile.ts new file mode 100644 index 0000000..faca2b5 --- /dev/null +++ b/e2e/cdp/fixtures/invalid-line/src/profile.ts @@ -0,0 +1,15 @@ +export const formatUser = (name: string, role = 'member') => { + const trimmed = name.trim(); + const [firstName = '', lastName = ''] = trimmed.split(' '); + const normalized = `${firstName.toLowerCase()}-${lastName.toLowerCase()}`; + const displayName = `${firstName} ${lastName}`.trim(); + + return { + trimmed, + firstName, + lastName, + normalized, + displayName, + role, + }; +}; diff --git a/e2e/cdp/fixtures/invalid-line/test/combined.test.ts b/e2e/cdp/fixtures/invalid-line/test/combined.test.ts new file mode 100644 index 0000000..cb65373 --- /dev/null +++ b/e2e/cdp/fixtures/invalid-line/test/combined.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from '@rstest/core'; +import { summarizeScores } from '../src/math'; +import { formatUser } from '../src/profile'; + +describe('combined tests', () => { + it('formats user profile', () => { + const profile = formatUser('Ada Lovelace', 'admin'); + + expect(profile.displayName).toBe('Ada Lovelace'); + expect(profile.normalized).toBe('ada-lovelace'); + }); + + it('summarizes scores', () => { + const result = summarizeScores([12, 18, 30]); + + expect(result.total).toBe(60); + expect(result.average).toBe(20); + expect(result.weightedTotal).toBe(65); + }); +}); diff --git a/e2e/cdp/fixtures/invalid-line/test/math.test.ts b/e2e/cdp/fixtures/invalid-line/test/math.test.ts new file mode 100644 index 0000000..619169a --- /dev/null +++ b/e2e/cdp/fixtures/invalid-line/test/math.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from '@rstest/core'; +import { summarizeScores } from '../src/math'; + +describe('score summary', () => { + it('computes totals', () => { + const result = summarizeScores([12, 18, 30]); + + expect(result.total).toBe(60); + expect(result.average).toBe(20); + expect(result.weightedTotal).toBe(65); + }); +}); diff --git a/e2e/cdp/fixtures/invalid-line/test/profile.test.ts b/e2e/cdp/fixtures/invalid-line/test/profile.test.ts new file mode 100644 index 0000000..0c5299a --- /dev/null +++ b/e2e/cdp/fixtures/invalid-line/test/profile.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from '@rstest/core'; +import { formatUser } from '../src/profile'; + +describe('user profile', () => { + it('formats display names', () => { + const profile = formatUser('Ada Lovelace', 'admin'); + + expect(profile.displayName).toBe('Ada Lovelace'); + expect(profile.normalized).toBe('ada-lovelace'); + }); +}); diff --git a/e2e/cdp/fixtures/invalid-line/tsconfig.json b/e2e/cdp/fixtures/invalid-line/tsconfig.json new file mode 100644 index 0000000..599608f --- /dev/null +++ b/e2e/cdp/fixtures/invalid-line/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "module": "ESNext", + "target": "ESNext", + "moduleResolution": "Bundler", + "types": ["@rstest/core"], + "strict": true + }, + "include": ["src", "test", "rstest.config.ts"] +} diff --git a/e2e/cdp/fixtures/mismatch/package.json b/e2e/cdp/fixtures/mismatch/package.json new file mode 100644 index 0000000..0911b07 --- /dev/null +++ b/e2e/cdp/fixtures/mismatch/package.json @@ -0,0 +1,9 @@ +{ + "name": "@agent-skills/cdp-fixture-mismatch", + "version": "1.0.0", + "private": true, + "type": "module", + "devDependencies": { + "@rstest/core": "^0.8.2" + } +} diff --git a/e2e/cdp/fixtures/mismatch/rstest.config.ts b/e2e/cdp/fixtures/mismatch/rstest.config.ts new file mode 100644 index 0000000..7cdbf20 --- /dev/null +++ b/e2e/cdp/fixtures/mismatch/rstest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from '@rstest/core'; + +export default defineConfig({ + root: __dirname, + tools: { + rspack: (config) => { + config.devtool = 'inline-source-map'; + }, + }, + dev: { + writeToDisk: true, + }, +}); diff --git a/e2e/cdp/fixtures/mismatch/src/math.ts b/e2e/cdp/fixtures/mismatch/src/math.ts new file mode 100644 index 0000000..24b16ac --- /dev/null +++ b/e2e/cdp/fixtures/mismatch/src/math.ts @@ -0,0 +1,13 @@ +export const summarizeScores = (scores: number[]) => { + const total = scores.reduce((sum, value) => sum + value, 0); + const average = scores.length ? total / scores.length : 0; + const weightedTotal = total + average * 0.25; + const label = `${scores.length}-scores`; + + return { + total, + average, + weightedTotal, + label, + }; +}; diff --git a/e2e/cdp/fixtures/mismatch/src/profile.ts b/e2e/cdp/fixtures/mismatch/src/profile.ts new file mode 100644 index 0000000..faca2b5 --- /dev/null +++ b/e2e/cdp/fixtures/mismatch/src/profile.ts @@ -0,0 +1,15 @@ +export const formatUser = (name: string, role = 'member') => { + const trimmed = name.trim(); + const [firstName = '', lastName = ''] = trimmed.split(' '); + const normalized = `${firstName.toLowerCase()}-${lastName.toLowerCase()}`; + const displayName = `${firstName} ${lastName}`.trim(); + + return { + trimmed, + firstName, + lastName, + normalized, + displayName, + role, + }; +}; diff --git a/e2e/cdp/fixtures/mismatch/test/combined.test.ts b/e2e/cdp/fixtures/mismatch/test/combined.test.ts new file mode 100644 index 0000000..cb65373 --- /dev/null +++ b/e2e/cdp/fixtures/mismatch/test/combined.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from '@rstest/core'; +import { summarizeScores } from '../src/math'; +import { formatUser } from '../src/profile'; + +describe('combined tests', () => { + it('formats user profile', () => { + const profile = formatUser('Ada Lovelace', 'admin'); + + expect(profile.displayName).toBe('Ada Lovelace'); + expect(profile.normalized).toBe('ada-lovelace'); + }); + + it('summarizes scores', () => { + const result = summarizeScores([12, 18, 30]); + + expect(result.total).toBe(60); + expect(result.average).toBe(20); + expect(result.weightedTotal).toBe(65); + }); +}); diff --git a/e2e/cdp/fixtures/mismatch/test/math.test.ts b/e2e/cdp/fixtures/mismatch/test/math.test.ts new file mode 100644 index 0000000..619169a --- /dev/null +++ b/e2e/cdp/fixtures/mismatch/test/math.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from '@rstest/core'; +import { summarizeScores } from '../src/math'; + +describe('score summary', () => { + it('computes totals', () => { + const result = summarizeScores([12, 18, 30]); + + expect(result.total).toBe(60); + expect(result.average).toBe(20); + expect(result.weightedTotal).toBe(65); + }); +}); diff --git a/e2e/cdp/fixtures/mismatch/test/profile.test.ts b/e2e/cdp/fixtures/mismatch/test/profile.test.ts new file mode 100644 index 0000000..0c5299a --- /dev/null +++ b/e2e/cdp/fixtures/mismatch/test/profile.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from '@rstest/core'; +import { formatUser } from '../src/profile'; + +describe('user profile', () => { + it('formats display names', () => { + const profile = formatUser('Ada Lovelace', 'admin'); + + expect(profile.displayName).toBe('Ada Lovelace'); + expect(profile.normalized).toBe('ada-lovelace'); + }); +}); diff --git a/e2e/cdp/fixtures/mismatch/tsconfig.json b/e2e/cdp/fixtures/mismatch/tsconfig.json new file mode 100644 index 0000000..599608f --- /dev/null +++ b/e2e/cdp/fixtures/mismatch/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "module": "ESNext", + "target": "ESNext", + "moduleResolution": "Bundler", + "types": ["@rstest/core"], + "strict": true + }, + "include": ["src", "test", "rstest.config.ts"] +} diff --git a/e2e/cdp/fixtures/multi/package.json b/e2e/cdp/fixtures/multi/package.json new file mode 100644 index 0000000..e770394 --- /dev/null +++ b/e2e/cdp/fixtures/multi/package.json @@ -0,0 +1,9 @@ +{ + "name": "@agent-skills/cdp-fixture-multi", + "version": "1.0.0", + "private": true, + "type": "module", + "devDependencies": { + "@rstest/core": "^0.8.2" + } +} diff --git a/e2e/cdp/fixtures/multi/rstest.config.ts b/e2e/cdp/fixtures/multi/rstest.config.ts new file mode 100644 index 0000000..7cdbf20 --- /dev/null +++ b/e2e/cdp/fixtures/multi/rstest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from '@rstest/core'; + +export default defineConfig({ + root: __dirname, + tools: { + rspack: (config) => { + config.devtool = 'inline-source-map'; + }, + }, + dev: { + writeToDisk: true, + }, +}); diff --git a/e2e/cdp/fixtures/multi/src/math.ts b/e2e/cdp/fixtures/multi/src/math.ts new file mode 100644 index 0000000..24b16ac --- /dev/null +++ b/e2e/cdp/fixtures/multi/src/math.ts @@ -0,0 +1,13 @@ +export const summarizeScores = (scores: number[]) => { + const total = scores.reduce((sum, value) => sum + value, 0); + const average = scores.length ? total / scores.length : 0; + const weightedTotal = total + average * 0.25; + const label = `${scores.length}-scores`; + + return { + total, + average, + weightedTotal, + label, + }; +}; diff --git a/e2e/cdp/fixtures/multi/src/profile.ts b/e2e/cdp/fixtures/multi/src/profile.ts new file mode 100644 index 0000000..faca2b5 --- /dev/null +++ b/e2e/cdp/fixtures/multi/src/profile.ts @@ -0,0 +1,15 @@ +export const formatUser = (name: string, role = 'member') => { + const trimmed = name.trim(); + const [firstName = '', lastName = ''] = trimmed.split(' '); + const normalized = `${firstName.toLowerCase()}-${lastName.toLowerCase()}`; + const displayName = `${firstName} ${lastName}`.trim(); + + return { + trimmed, + firstName, + lastName, + normalized, + displayName, + role, + }; +}; diff --git a/e2e/cdp/fixtures/multi/test/combined.test.ts b/e2e/cdp/fixtures/multi/test/combined.test.ts new file mode 100644 index 0000000..cb65373 --- /dev/null +++ b/e2e/cdp/fixtures/multi/test/combined.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from '@rstest/core'; +import { summarizeScores } from '../src/math'; +import { formatUser } from '../src/profile'; + +describe('combined tests', () => { + it('formats user profile', () => { + const profile = formatUser('Ada Lovelace', 'admin'); + + expect(profile.displayName).toBe('Ada Lovelace'); + expect(profile.normalized).toBe('ada-lovelace'); + }); + + it('summarizes scores', () => { + const result = summarizeScores([12, 18, 30]); + + expect(result.total).toBe(60); + expect(result.average).toBe(20); + expect(result.weightedTotal).toBe(65); + }); +}); diff --git a/e2e/cdp/fixtures/multi/test/math.test.ts b/e2e/cdp/fixtures/multi/test/math.test.ts new file mode 100644 index 0000000..619169a --- /dev/null +++ b/e2e/cdp/fixtures/multi/test/math.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from '@rstest/core'; +import { summarizeScores } from '../src/math'; + +describe('score summary', () => { + it('computes totals', () => { + const result = summarizeScores([12, 18, 30]); + + expect(result.total).toBe(60); + expect(result.average).toBe(20); + expect(result.weightedTotal).toBe(65); + }); +}); diff --git a/e2e/cdp/fixtures/multi/test/profile.test.ts b/e2e/cdp/fixtures/multi/test/profile.test.ts new file mode 100644 index 0000000..0c5299a --- /dev/null +++ b/e2e/cdp/fixtures/multi/test/profile.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from '@rstest/core'; +import { formatUser } from '../src/profile'; + +describe('user profile', () => { + it('formats display names', () => { + const profile = formatUser('Ada Lovelace', 'admin'); + + expect(profile.displayName).toBe('Ada Lovelace'); + expect(profile.normalized).toBe('ada-lovelace'); + }); +}); diff --git a/e2e/cdp/fixtures/multi/tsconfig.json b/e2e/cdp/fixtures/multi/tsconfig.json new file mode 100644 index 0000000..599608f --- /dev/null +++ b/e2e/cdp/fixtures/multi/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "module": "ESNext", + "target": "ESNext", + "moduleResolution": "Bundler", + "types": ["@rstest/core"], + "strict": true + }, + "include": ["src", "test", "rstest.config.ts"] +} diff --git a/e2e/cdp/index.test.ts b/e2e/cdp/index.test.ts new file mode 100644 index 0000000..a028054 --- /dev/null +++ b/e2e/cdp/index.test.ts @@ -0,0 +1,302 @@ +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from '@rstest/core'; +import { type Result, x } from 'tinyexec'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const RSTEST_CDP_CLI_PATH = path.resolve( + __dirname, + '../../packages/rstest-cdp/dist/rstest-cdp.cjs', +); + +const fixturePath = (name: string) => path.join(__dirname, 'fixtures', name); + +const createRunner = (cwd: string, includeFile: string) => ({ + cmd: 'pnpm', + args: ['rstest', 'run', '-c', 'rstest.config.ts', '--include', includeFile], + cwd, + env: { FORCE_COLOR: '0' }, +}); + +const getTaskValues = (result: DebugResult, taskId: string) => { + const taskResult = result.results.find((item) => item.id === taskId); + expect(taskResult).toBeTruthy(); + return Object.fromEntries( + (taskResult?.values || []).map((entry) => [entry.expression, entry.value]), + ); +}; + +interface DebugResult { + status: 'full_succeed' | 'partial_succeed' | 'failed'; + exitCode?: number | null; + results: Array<{ + id: string; + values: Array<{ expression: string; value: unknown }>; + }>; + errors: Array<{ taskId?: string; error: string }>; + meta?: { + runner: { + cmd: string; + args: string[]; + cwd: string; + env?: Record; + }; + forwardedArgs: string[]; + mappingDiagnostics: Array<{ reason: string }>; + pendingTaskIds: string[]; + }; +} + +interface RunCdpDebugOptions { + plan: object; + cwd: string; + debug?: boolean; + timeout?: number; +} + +/** + * Helper to run rstest-cdp CLI and collect stdout. + */ +async function runCdpDebug(options: RunCdpDebugOptions): Promise { + const { plan, cwd, debug = false, timeout = 45_000 } = options; + const stdinPayload = JSON.stringify(plan, null, 2); + + const args = [RSTEST_CDP_CLI_PATH, '--plan', '-']; + if (debug) { + args.push('--debug', '1'); + } + + let stdout = ''; + const cli = x('node', args, { + nodeOptions: { cwd }, + }); + + cli.process?.stdout?.on('data', (chunk) => { + stdout += chunk.toString(); + }); + + cli.process?.stdin?.write(stdinPayload); + cli.process?.stdin?.end(); + + try { + await Promise.race([ + cli, + new Promise((_resolve, reject) => { + setTimeout(() => reject(new Error('rstest-cdp timed out.')), timeout); + }), + ]); + } catch (_error) { + const details = stdout.trim() ? `\n${stdout}` : ''; + throw new Error(`rstest-cdp failed.${details}`); + } + + return JSON.parse(stdout) as DebugResult; +} + +describe('cdp debug skill', () => { + it('evaluates locals via CDP', async () => { + const fixturesTargetPath = fixturePath('basic'); + + const profileSourcePath = path.join(fixturesTargetPath, 'src/profile.ts'); + const plan = { + runner: { + ...createRunner(fixturesTargetPath, 'test/profile.test.ts'), + }, + tasks: [ + { + description: 'Inspect formatted profile fields', + sourcePath: profileSourcePath, + line: 7, + column: 0, + expressions: ['trimmed', 'normalized', 'displayName', 'role'], + }, + ], + }; + + const result = await runCdpDebug({ + plan, + cwd: fixturesTargetPath, + debug: true, + }); + if (result.status === 'failed') { + throw new Error(`rstest-cdp failed.\n${JSON.stringify(result)}`); + } + + expect(result.status).toBe('full_succeed'); + expect(result.meta).toBeTruthy(); + expect(result.meta?.pendingTaskIds.length).toBe(0); + + expect(result.meta?.forwardedArgs).toContain('--pool.maxWorkers=1'); + expect(result.meta?.forwardedArgs).toContain( + '--pool.execArgv=--inspect-brk=0', + ); + + const values = getTaskValues(result, 'task-1'); + expect(values.trimmed).toBe('Ada Lovelace'); + expect(values.normalized).toBe('ada-lovelace'); + expect(values.displayName).toBe('Ada Lovelace'); + expect(values.role).toBe('admin'); + }, 60_000); + + it('omits meta when --debug is not set', async () => { + const fixturesTargetPath = fixturePath('basic-no-debug'); + + const profileSourcePath = path.join(fixturesTargetPath, 'src/profile.ts'); + const plan = { + runner: { + ...createRunner(fixturesTargetPath, 'test/profile.test.ts'), + }, + tasks: [ + { + description: 'Inspect profile fields without debug', + sourcePath: profileSourcePath, + line: 7, + column: 0, + expressions: ['trimmed'], + }, + ], + }; + + // Run without --debug flag + const result = await runCdpDebug({ + plan, + cwd: fixturesTargetPath, + debug: false, + }); + + expect(result.status).toBe('full_succeed'); + // meta should be undefined when --debug is not set + expect(result.meta).toBeUndefined(); + // results should still be present + expect(result.results.length).toBeGreaterThan(0); + + const values = getTaskValues(result, 'task-1'); + expect(values.trimmed).toBe('Ada Lovelace'); + }, 60_000); + + it('evaluates multiple breakpoints across files', async () => { + const fixturesTargetPath = fixturePath('multi'); + + const profileSourcePath = path.join(fixturesTargetPath, 'src/profile.ts'); + const mathSourcePath = path.join(fixturesTargetPath, 'src/math.ts'); + const plan = { + runner: { + ...createRunner(fixturesTargetPath, 'test/combined.test.ts'), + }, + tasks: [ + { + description: 'Inspect profile fields', + sourcePath: profileSourcePath, + line: 7, + column: 0, + expressions: ['trimmed', 'displayName', 'role'], + }, + { + description: 'Inspect math fields', + sourcePath: mathSourcePath, + line: 7, + column: 0, + expressions: ['total', 'average', 'label'], + }, + ], + }; + + const result = await runCdpDebug({ + plan, + cwd: fixturesTargetPath, + debug: true, + }); + + expect(result.status).toBe('full_succeed'); + expect(result.meta?.pendingTaskIds.length).toBe(0); + + // Verify profile.ts breakpoint result + const profileValues = getTaskValues(result, 'task-1'); + expect(profileValues.trimmed).toBe('Ada Lovelace'); + expect(profileValues.displayName).toBe('Ada Lovelace'); + expect(profileValues.role).toBe('admin'); + + // Verify math.ts breakpoint result + const mathValues = getTaskValues(result, 'task-2'); + expect(mathValues.total).toBe(60); + expect(mathValues.average).toBe(20); + expect(mathValues.label).toBe('3-scores'); + }, 60_000); + + it('handles non-existent breakpoint line gracefully', async () => { + const fixturesTargetPath = fixturePath('invalid-line'); + + const profileSourcePath = path.join(fixturesTargetPath, 'src/profile.ts'); + const plan = { + runner: { + ...createRunner(fixturesTargetPath, 'test/profile.test.ts'), + }, + tasks: [ + { + description: 'Breakpoint at non-existent line', + sourcePath: profileSourcePath, + line: 9999, + column: 0, + expressions: ['trimmed'], + }, + ], + }; + + const result = await runCdpDebug({ + plan, + cwd: fixturesTargetPath, + debug: true, + }); + + // The task should remain pending since the breakpoint line doesn't exist + expect(result.meta?.pendingTaskIds).toContain('task-1'); + // No results should be collected for the invalid breakpoint + const taskResult = result.results.find((item) => item.id === 'task-1'); + expect(taskResult).toBeUndefined(); + }, 60_000); + + it('reports sourcemap mismatch in diagnostics', async () => { + const fixturesTargetPath = fixturePath('mismatch'); + + // Use a non-existent source file path that won't match any sourcemap + const nonExistentSourcePath = path.join( + fixturesTargetPath, + 'src/non-existent-file.ts', + ); + const plan = { + runner: { + ...createRunner(fixturesTargetPath, 'test/profile.test.ts'), + }, + tasks: [ + { + description: 'Breakpoint in non-existent file', + sourcePath: nonExistentSourcePath, + line: 7, + column: 0, + expressions: ['foo'], + }, + ], + }; + + const result = await runCdpDebug({ + plan, + cwd: fixturesTargetPath, + debug: true, + }); + + // The task should remain pending since no sourcemap matches + expect(result.meta?.pendingTaskIds).toContain('task-1'); + // mappingDiagnostics should contain info about the mismatch + expect(result.meta?.mappingDiagnostics.length).toBeGreaterThan(0); + // At least one diagnostic should mention "no match" or similar + const hasMismatchDiagnostic = result.meta?.mappingDiagnostics.some( + (d) => + d.reason.toLowerCase().includes('no match') || + d.reason.toLowerCase().includes('not found') || + d.reason.toLowerCase().includes('mismatch'), + ); + expect(hasMismatchDiagnostic).toBeTruthy(); + }, 60_000); +}); diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 0000000..028811d --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,14 @@ +{ + "name": "@agent-skills/e2e", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "test": "rstest run -c rstest.config.ts" + }, + "devDependencies": { + "@rstest/core": "^0.8.2", + "tinyexec": "^1.0.2", + "typescript": "^5.9.3" + } +} diff --git a/e2e/rstest.config.ts b/e2e/rstest.config.ts new file mode 100644 index 0000000..192f933 --- /dev/null +++ b/e2e/rstest.config.ts @@ -0,0 +1,5 @@ +import { defineConfig } from '@rstest/core'; + +export default defineConfig({ + testTimeout: 60_000, +}); diff --git a/package.json b/package.json index a076321..4ec2e62 100644 --- a/package.json +++ b/package.json @@ -2,10 +2,12 @@ "name": "@rstackjs/agent-skills", "private": true, "scripts": { - "build": "pnpm --parallel --filter ./packages/** build", + "build": "pnpm --filter ./packages/** build", "format": "prettier --write .", "lint": "biome check", - "prepare": "simple-git-hooks && pnpm build" + "prepare": "simple-git-hooks && pnpm build", + "test": "pnpm --filter @agent-skills/e2e test", + "typecheck": "pnpm --filter ./packages/** typecheck" }, "simple-git-hooks": { "pre-commit": "pnpm exec nano-staged" diff --git a/packages/rstest-cdp/AGENTS.md b/packages/rstest-cdp/AGENTS.md new file mode 100644 index 0000000..18f8af8 --- /dev/null +++ b/packages/rstest-cdp/AGENTS.md @@ -0,0 +1,48 @@ +## rstest-cdp (agent-invoked CLI) + +This package ships a CDP debug CLI designed for AI agents. +Treat it as a portable artifact: deterministic, easy to invoke, minimal surface area. + +How it is used: + +- External agent generates a Plan JSON and calls: `node packages/rstest-cdp/dist/rstest-cdp.cjs --plan ` +- This repo focuses on keeping the CLI contract stable; the skill runbook lives in `skills/rstest-cdp/`. + +## Project structure (start here) + +- Entrypoint: `packages/rstest-cdp/src/cli.ts` +- CLI orchestration + output writing: `packages/rstest-cdp/src/index.ts` +- Plan parsing/validation + runner args normalization: `packages/rstest-cdp/src/plan.ts` +- CDP session + sourcemap mapping + breakpoint resolution: `packages/rstest-cdp/src/session.ts` +- Generated plan JSON Schema (do not edit): `packages/rstest-cdp/schema/plan.schema.json` +- Schema generator: `packages/rstest-cdp/scripts/genPlanSchema.mts` + +## Output contract (do not break) + +- stdout: a single JSON `DebugResult` (machine-readable) + - Core fields: `status`, `results`, `errors` (always present) + - `meta` field: diagnostic info, only included with `--debug` flag +- stderr: runner output + optional debug logs (`--debug`) +- stable ordering, explicit timeouts, no randomness + +## Do + +- Keep diffs small and localized to `packages/rstest-cdp/`. +- Treat input from files / CDP / subprocess as `unknown`, then validate/narrow. +- Keep the CLI deterministic (timeouts explicit, stable ordering). +- Rebuild after changes; do not edit `dist/*` by hand. + +## Don't + +- Don't print non-JSON to stdout (breaks callers). +- Don't add heavy dependencies without approval. + +## Commands + +```bash +pnpm --filter rstest-cdp typecheck +pnpm --filter rstest-cdp build +pnpm --filter rstest-cdp dev +pnpm --filter rstest-cdp gen:schema +pnpm --filter @agent-skills/e2e test +``` diff --git a/packages/rstest-cdp/README.md b/packages/rstest-cdp/README.md new file mode 100644 index 0000000..710995b --- /dev/null +++ b/packages/rstest-cdp/README.md @@ -0,0 +1,72 @@ +# rstest-cdp + +[CDP](https://chromedevtools.github.io/devtools-protocol/)-based debugger CLI for rstest, designed for use by [skills/rstest-cdp](../../skills/rstest-cdp/SKILL.md). + +Important: This CLI is intended to be invoked by AI agents, not for direct human use. Agents use the skill definition to generate plans and interpret results automatically. + +This package provides a command-line tool that helps debug Rstest test cases by: + +- Running a single test file in a single worker under the Node inspector (CDP) +- Setting sourcemap-mapped breakpoints via instrumentation breakpoints (handles timing/race conditions) +- Evaluating expressions to inspect intermediate variables + +Note: Browser mode debugging is not supported yet. + +## Usage + +```bash +# Build first +pnpm --filter rstest-cdp build + +# Read plan from a file +node ./packages/rstest-cdp/dist/rstest-cdp.cjs --plan plan.json + +# Read plan from stdin (using heredoc) +node ./packages/rstest-cdp/dist/rstest-cdp.cjs --plan - <<'EOF' +{ + "runner": { + "cmd": "pnpm", + "args": ["rstest", "run", "--include", "test/example.test.ts"], + "cwd": "/path/to/project" + }, + "tasks": [ + { + "sourcePath": "/path/to/project/src/example.ts", + "line": 42, + "column": 0, + "expressions": ["value", "typeof value"] + } + ] +} +EOF + +# Enable debug mode for diagnostic info (includes meta in output + stderr logs) +node ./packages/rstest-cdp/dist/rstest-cdp.cjs --debug --plan plan.json +``` + +### CLI args + +- `-p, --plan `: Path to plan JSON file (or `-` for stdin). Required. +- `--output `: Write JSON output to file (or `-` for stdout). +- `--breakpoint-timeout `: Timeout for resolving breakpoints (default: `20000`). +- `--inactivity-timeout `: Timeout between breakpoint hits (default: `40000`). +- `--debug`: Enable debug logging. + +## Output + +The CLI outputs a JSON `DebugResult` to stdout. + +With `--debug`, additional diagnostic metadata is included under `meta`. + +## Commands (development) + +```bash +pnpm --filter rstest-cdp build +pnpm --filter rstest-cdp dev +pnpm --filter rstest-cdp gen:schema +pnpm --filter rstest-cdp typecheck +``` + +## Plan schema + +The plan JSON Schema is generated from Valibot schemas and committed at `schema/plan.schema.json`. diff --git a/packages/rstest-cdp/package.json b/packages/rstest-cdp/package.json new file mode 100644 index 0000000..ca79df2 --- /dev/null +++ b/packages/rstest-cdp/package.json @@ -0,0 +1,39 @@ +{ + "name": "rstest-cdp", + "version": "0.1.0", + "private": true, + "description": "CDP-based debugger CLI for rstest, used by agent-skills.", + "repository": { + "type": "git", + "url": "https://github.com/rstackjs/agent-skills", + "directory": "packages/rstest-cdp" + }, + "type": "module", + "bin": "./dist/rstest-cdp.cjs", + "files": [ + "dist", + "schema" + ], + "scripts": { + "build": "rslib build", + "postbuild": "pnpm run gen:schema", + "dev": "rslib build --watch", + "gen:schema": "node --experimental-strip-types ./scripts/genPlanSchema.mts && pnpm prettier --write './schema/plan.schema.json'", + "prepare": "pnpm run gen:schema", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@rslib/core": "^0.19.0", + "@types/ws": "^8.18.1", + "@valibot/to-json-schema": "^1.2.0", + "cac": "^6.7.14", + "json-rpc-2.0": "^1.7.1", + "typescript": "^5.9.3", + "valibot": "^1.2.0", + "ws": "^8.18.3" + }, + "engines": { + "node": ">=18.12.0" + } +} diff --git a/packages/rstest-cdp/rslib.config.ts b/packages/rstest-cdp/rslib.config.ts new file mode 100644 index 0000000..33ab27f --- /dev/null +++ b/packages/rstest-cdp/rslib.config.ts @@ -0,0 +1,28 @@ +import { defineConfig } from '@rslib/core'; + +export default defineConfig({ + lib: [ + { + format: 'cjs', + dts: false, + bundle: true, + syntax: ['node 18.12.0'], + output: { + filename: { + js: '[name].cjs', + }, + }, + source: { + define: { + // `ws` optionally loads native addons (`bufferutil`, `utf-8-validate`). + // We don't ship them in the bundled script, so disable those code paths. + 'process.env.WS_NO_BUFFER_UTIL': JSON.stringify('1'), + 'process.env.WS_NO_UTF_8_VALIDATE': JSON.stringify('1'), + }, + entry: { + 'rstest-cdp': './src/cli.ts', + }, + }, + }, + ], +}); diff --git a/packages/rstest-cdp/schema/plan.schema.json b/packages/rstest-cdp/schema/plan.schema.json new file mode 100644 index 0000000..2fa6c29 --- /dev/null +++ b/packages/rstest-cdp/schema/plan.schema.json @@ -0,0 +1,98 @@ +{ + "type": "object", + "properties": { + "runner": { + "type": "object", + "properties": { + "cmd": { + "type": "string", + "description": "Runner command. Example: \"pnpm\"." + }, + "args": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Runner arguments. Example: [\"rstest\", \"run\", ...]." + }, + "cwd": { + "type": "string", + "description": "Working directory for the runner process." + }, + "env": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + }, + "description": "Environment variables passed to the runner. Default: {}." + } + }, + "required": ["cmd", "args", "cwd"], + "description": "Runner configuration used to execute the test command." + }, + "tasks": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Task id. If omitted, the CLI assigns \"task-\"." + }, + "description": { + "type": "string", + "description": "Human-readable task description." + }, + "sourcePath": { + "type": "string", + "description": "Absolute source file path in the runner workspace. Used for sourcemap mapping." + }, + "line": { + "type": "integer", + "minimum": 1, + "description": "1-based line number in the source file." + }, + "column": { + "type": "integer", + "minimum": 0, + "description": "0-based column number in the source file. Default: 0." + }, + "expressions": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Expressions to evaluate on the paused call frame when this breakpoint is hit." + }, + "hitLimit": { + "type": "integer", + "minimum": 1, + "description": "Stop after this many hits. Default: 1." + }, + "condition": { + "type": "string", + "description": "Conditional breakpoint expression (evaluated by the debugger). The breakpoint pauses only when this evaluates to a truthy value." + }, + "order": { + "type": "integer", + "description": "Optional ordering hint (lower values run first)." + }, + "hits": { + "type": "integer", + "minimum": 0, + "description": "Internal hit counter. Default: 0." + } + }, + "required": ["sourcePath", "line"] + }, + "minItems": 1, + "description": "List of breakpoint tasks. Must be non-empty." + } + }, + "required": ["runner", "tasks"], + "description": "Plan JSON passed to the CLI via --plan .", + "$schema": "http://json-schema.org/draft-07/schema#" +} diff --git a/packages/rstest-cdp/scripts/genPlanSchema.mts b/packages/rstest-cdp/scripts/genPlanSchema.mts new file mode 100644 index 0000000..a0dcb2a --- /dev/null +++ b/packages/rstest-cdp/scripts/genPlanSchema.mts @@ -0,0 +1,24 @@ +import { mkdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { toJsonSchema } from '@valibot/to-json-schema'; +import { PlanInputSchema } from '../src/schema.ts'; + +declare global { + interface ImportMeta { + url: string; + } +} + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +const outDir = path.resolve(__dirname, '../schema'); +const outFile = path.join(outDir, 'plan.schema.json'); + +await mkdir(outDir, { recursive: true }); + +const schema = toJsonSchema(PlanInputSchema, { target: 'draft-07' }); +await writeFile(outFile, `${JSON.stringify(schema, null, 2)}\n`, 'utf8'); + +process.stderr.write(`Wrote ${outFile}\n`); diff --git a/packages/rstest-cdp/src/cdp.ts b/packages/rstest-cdp/src/cdp.ts new file mode 100644 index 0000000..775a004 --- /dev/null +++ b/packages/rstest-cdp/src/cdp.ts @@ -0,0 +1,180 @@ +import { createJSONRPCErrorResponse, JSONRPCClient } from 'json-rpc-2.0'; +import WebSocket from 'ws'; +import type { CdpClient, EvaluatedValue } from './types'; + +const REQUEST_TIMEOUT_MS = 60_000; + +// ============================================================================ +// CDP Client +// ============================================================================ + +const isObject = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +/** + * CDP protocol doesn't use JSON-RPC 2.0's `jsonrpc` field. + * Strip it to avoid protocol errors. + */ +const stripJsonRpcField = (payload: unknown): unknown => { + if (Array.isArray(payload)) { + return payload.map((item) => stripJsonRpcField(item)); + } + if (!isObject(payload) || !('jsonrpc' in payload)) return payload; + const { jsonrpc: _, ...rest } = payload; + return rest; +}; + +export const createCdpClient = async (wsUrl: string): Promise => { + const socket = new WebSocket(wsUrl); + + const client = new JSONRPCClient((payload) => { + const raw = JSON.stringify(stripJsonRpcField(payload)); + return new Promise((resolve, reject) => { + socket.send(raw, (error) => (error ? reject(error) : resolve())); + }); + }); + + const requester = client.timeout(REQUEST_TIMEOUT_MS, (id) => + createJSONRPCErrorResponse(id, -32000, 'CDP request timed out.'), + ); + + const listeners = new Map void>(); + + socket.on('message', (raw) => { + let message: unknown; + try { + message = JSON.parse(raw.toString()); + } catch (error) { + client.rejectAllPendingRequests( + `Invalid CDP message: ${error instanceof Error ? error.message : String(error)}`, + ); + socket.close(); + return; + } + if (!isObject(message)) return; + + // Response to a request + if ('id' in message) { + client.receive( + message as unknown as Parameters[0], + ); + return; + } + + // Event notification + const method = message.method; + if (typeof method === 'string') { + listeners.get(method)?.(message.params); + } + }); + + socket.on('error', (error) => { + client.rejectAllPendingRequests( + error instanceof Error ? error.message : String(error), + ); + }); + + socket.on('close', () => { + client.rejectAllPendingRequests('CDP websocket closed.'); + }); + + // Wait for connection + await new Promise((resolve, reject) => { + socket.once('open', () => resolve()); + socket.once('error', (err) => reject(err)); + }); + + return { + send: (method, params = {}) => + Promise.resolve(requester.request(method, params, undefined)), + on: (method, handler) => + listeners.set(method, handler as (params: unknown) => void), + close: () => { + client.rejectAllPendingRequests('CDP client closed.'); + socket.close(); + }, + }; +}; + +// ============================================================================ +// Expression Evaluation +// ============================================================================ + +type RemoteObject = { + type?: string; + subtype?: string; + value?: unknown; + description?: string; + objectId?: string; +}; + +type PropertyDescriptor = { + name?: string; + value?: RemoteObject; +}; + +/** + * Evaluate expressions on a paused call frame. + * For objects, fetches shallow properties to provide useful debug output. + */ +export const evaluateExpressions = async ({ + cdp, + callFrameId, + expressions, + debugLog, +}: { + cdp: CdpClient; + callFrameId: string; + expressions: string[]; + debugLog?: (...args: unknown[]) => void; +}): Promise => { + return Promise.all( + expressions.map(async (expression) => { + const result = await cdp.send<{ result?: RemoteObject }>( + 'Debugger.evaluateOnCallFrame', + { + callFrameId, + expression, + }, + ); + const payload = result?.result; + let value = payload?.value; + const type = payload?.type; + const subtype = payload?.subtype; + const preview = payload?.description; + + // For objects without a primitive value, fetch shallow properties + if (value === undefined && payload?.objectId) { + try { + const properties = await cdp.send<{ result?: PropertyDescriptor[] }>( + 'Runtime.getProperties', + { objectId: payload.objectId, ownProperties: true }, + ); + const shallow: Record = {}; + for (const prop of properties?.result || []) { + if (!prop?.name || prop?.value == null) continue; + const propVal = prop.value; + shallow[prop.name] = + propVal.value ?? propVal.description ?? propVal.type; + } + value = shallow; + } catch (error) { + debugLog?.( + 'Failed to get properties for objectId', + payload.objectId, + error instanceof Error ? error.message : String(error), + ); + value = preview; + } + } + + return { + expression, + value, + type, + subtype, + preview, + } satisfies EvaluatedValue; + }), + ); +}; diff --git a/packages/rstest-cdp/src/cli.ts b/packages/rstest-cdp/src/cli.ts new file mode 100644 index 0000000..0944e48 --- /dev/null +++ b/packages/rstest-cdp/src/cli.ts @@ -0,0 +1,4 @@ +#!/usr/bin/env node +import { runCli } from './index'; + +void runCli(); diff --git a/packages/rstest-cdp/src/index.ts b/packages/rstest-cdp/src/index.ts new file mode 100644 index 0000000..de4a5bd --- /dev/null +++ b/packages/rstest-cdp/src/index.ts @@ -0,0 +1,210 @@ +import type { ChildProcess } from 'node:child_process'; +import { cac } from 'cac'; +import { + createOutputWriter, + loadPlan, + normalizeRunnerArgs, + parseCliOptions, + spawnRunner, + waitForInspectorUrl, +} from './plan'; +import { createCdpClient, DebugSession } from './session'; +import type { CdpClient, DebugResult, RunnerConfig } from './types'; + +/** Wait for child process to exit with timeout */ +const waitForExit = ( + child: ChildProcess, + timeoutMs = 5000, +): Promise => { + return new Promise((resolve) => { + if (child.exitCode != null) { + resolve(child.exitCode); + return; + } + const timeout = setTimeout(() => { + child.off('exit', onExit); + resolve(null); + }, timeoutMs); + const onExit = (code: number | null) => { + clearTimeout(timeout); + resolve(code); + }; + child.once('exit', onExit); + }); +}; + +// ============================================================================ +// CLI Argument Parsing +// ============================================================================ + +type ParsedArgs = { + options: Record; + positional: string[]; + shouldExit: boolean; +}; + +const parseArgs = (argv: string[]): ParsedArgs => { + const cli = cac('rstest-cdp'); + + cli.option('-p, --plan ', 'Path to plan JSON file (or "-" for stdin)'); + cli.option( + '--output ', + 'Write JSON output to file (or "-" for stdout)', + ); + cli.option( + '--breakpoint-timeout ', + 'Timeout for resolving breakpoints (default: 20000)', + ); + cli.option( + '--inactivity-timeout ', + 'Timeout between breakpoint hits (default: 40000)', + ); + cli.option('--debug', 'Enable debug logging'); + + cli.help(); + cli.globalCommand.allowUnknownOptions(); + + // Normalize `--plan -` and `--output -` so stdin/stdout markers survive parsing + // (cac/mri may drop standalone `-` tokens) + const normalizedArgv: string[] = []; + for (let i = 0; i < argv.length; i += 1) { + const token = argv[i]; + if (!token) continue; + if ((token === '--plan' || token === '-p') && argv[i + 1] === '-') { + normalizedArgv.push('--plan=-'); + i += 1; + continue; + } + if (token === '--output' && argv[i + 1] === '-') { + normalizedArgv.push('--output=-'); + i += 1; + continue; + } + normalizedArgv.push(token); + } + + const parsed = cli.parse(normalizedArgv, { run: false }); + + if (parsed.options.help) { + cli.outputHelp(); + return { options: {}, positional: [], shouldExit: true }; + } + + return { + options: parsed.options as Record, + positional: Array.from(parsed.args), + shouldExit: false, + }; +}; + +// ============================================================================ +// Main Entry Point +// ============================================================================ + +export const runCli = async (): Promise => { + const parsedArgs = parseArgs(process.argv); + if (parsedArgs.shouldExit) return; + + const options = parseCliOptions(parsedArgs.options); + const output = createOutputWriter(options.outputPath); + + const debugLog = (...args: unknown[]) => { + if (options.debug) { + console.error('[rstest-cdp]', ...args); + } + }; + + /** Write failure result and clean up */ + const writeFailure = (error: string, runner?: RunnerConfig): void => { + const failure: DebugResult = { + status: 'failed', + results: [], + errors: [{ error }], + // Only include meta in debug mode when runner info is available + ...(options.debug && + runner && { + meta: { + runner, + forwardedArgs: [runner.cmd, ...runner.args], + pendingTaskIds: [], + mappingDiagnostics: [], + }, + }), + }; + output.write(failure); + }; + + let child: ChildProcess | null = null; + let cdp: CdpClient | null = null; + let isCleaningUp = false; + + /** Cleanup resources on exit */ + const cleanup = async () => { + if (isCleaningUp) return; + isCleaningUp = true; + cdp?.close(); + if (child && child.exitCode == null) { + child.kill('SIGTERM'); + await waitForExit(child); + } + }; + + // Handle termination signals + const onSignal = () => { + debugLog('received termination signal, cleaning up...'); + cleanup().then(() => process.exit(1)); + }; + process.on('SIGINT', onSignal); + process.on('SIGTERM', onSignal); + + try { + const plan = await loadPlan(options.planPath); + + // Normalize runner args (enforce single worker, inspector, etc.) + const normalizedRunner = normalizeRunnerArgs(plan.runner.args); + if (normalizedRunner.error) { + writeFailure(normalizedRunner.error, plan.runner); + return; + } + plan.runner.args = normalizedRunner.args; + + const tasks = plan.tasks; + + if (!tasks.length) { + writeFailure('No tasks matched the filter.', plan.runner); + return; + } + + // Spawn runner process + child = spawnRunner(plan.runner); + + // Forward runner output to stderr so stdout stays valid JSON + child.stdout?.on('data', (chunk: Buffer) => process.stderr.write(chunk)); + child.stderr?.on('data', (chunk: Buffer) => process.stderr.write(chunk)); + + // Wait for inspector to be ready + const wsUrl = await waitForInspectorUrl(child); + debugLog('inspector url', wsUrl); + + // Connect CDP and start debug session + cdp = await createCdpClient(wsUrl); + + const session = new DebugSession({ + plan, + options, + tasks, + cdp, + runnerProcess: child, + output, + debugLog, + }); + session.start(); + + child.on('exit', (code: number | null) => session.onRunnerExit(code)); + await session.enableAndRun(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + writeFailure(message); + await cleanup(); + } +}; diff --git a/packages/rstest-cdp/src/plan.ts b/packages/rstest-cdp/src/plan.ts new file mode 100644 index 0000000..5ef8f5d --- /dev/null +++ b/packages/rstest-cdp/src/plan.ts @@ -0,0 +1,305 @@ +import { type ChildProcess, spawn } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import * as v from 'valibot'; +import { PlanInputSchema } from './schema'; +import type { DebugResult, Plan, RunnerConfig, TaskDefinition } from './types'; + +// ============================================================================ +// CLI Options +// ============================================================================ + +export type CliOptions = { + planPath?: string; + outputPath?: string; + breakpointTimeout?: number; + inactivityTimeout?: number; + debug: boolean; +}; + +const readStringOption = ( + options: Record, + keys: string[], +): string | undefined => { + for (const key of keys) { + const value = options[key]; + if (typeof value === 'string') return value; + if (typeof value === 'number' && Number.isFinite(value)) + return String(value); + } + return undefined; +}; + +const readBooleanOption = ( + options: Record, + keys: string[], +): boolean => { + for (const key of keys) { + const value = options[key]; + if (typeof value === 'boolean') return value; + if (value === '1' || value === 'true') return true; + if (value === '0' || value === 'false') return false; + } + return false; +}; + +const readNumberOption = ( + options: Record, + keys: string[], +): number | undefined => { + for (const key of keys) { + const value = options[key]; + if (typeof value === 'number' && Number.isFinite(value) && value > 0) { + return value; + } + if (typeof value === 'string') { + const parsed = Number.parseInt(value, 10); + if (Number.isFinite(parsed) && parsed > 0) return parsed; + } + } + return undefined; +}; + +export const parseCliOptions = ( + options: Record, +): CliOptions => ({ + planPath: readStringOption(options, ['plan', 'p']), + outputPath: readStringOption(options, ['output']), + breakpointTimeout: readNumberOption(options, ['breakpointTimeout']), + inactivityTimeout: readNumberOption(options, ['inactivityTimeout']), + debug: readBooleanOption(options, ['debug']), +}); + +// ============================================================================ +// Plan Loading +// ============================================================================ + +const readStdin = async (): Promise => { + return new Promise((resolve, reject) => { + let buffer = ''; + process.stdin.setEncoding('utf-8'); + process.stdin.on('data', (chunk: string) => { + buffer += chunk; + }); + process.stdin.on('end', () => resolve(buffer)); + process.stdin.on('error', reject); + }); +}; + +export const loadPlan = async (planPath?: string): Promise => { + if (!planPath) { + throw new Error('Missing required --plan '); + } + const content = + planPath === '-' + ? await readStdin() + : await fs.promises.readFile(path.resolve(planPath), 'utf-8'); + if (!content.trim()) { + throw new Error( + planPath === '-' + ? 'Empty plan received on stdin.' + : 'Plan file is empty.', + ); + } + const parsed = v.safeParse(PlanInputSchema, JSON.parse(content) as unknown); + if (!parsed.success) { + const messages = ( + parsed.issues as Array<{ + message?: string; + path?: Array<{ key?: string | number }>; + }> + ) + .map((issue) => { + const keyPath = (issue.path ?? []) + .map((item) => item?.key) + .filter( + (key): key is string | number => + typeof key === 'string' || typeof key === 'number', + ) + .map(String) + .join('.'); + const message = issue.message ?? 'Invalid value.'; + return keyPath ? `${keyPath}: ${message}` : message; + }) + .filter(Boolean); + + throw new Error( + messages.length + ? `Invalid plan schema.\n${messages.join('\n')}` + : 'Invalid plan schema.', + ); + } + + return normalizePlan(parsed.output); +}; + +const normalizePlan = (plan: v.InferOutput): Plan => { + const runner: RunnerConfig = { + cmd: plan.runner.cmd, + args: [...plan.runner.args], + cwd: plan.runner.cwd, + env: plan.runner.env ?? {}, + }; + + const tasks: TaskDefinition[] = plan.tasks.map( + (task: (typeof plan.tasks)[number], index: number) => { + const providedId = typeof task.id === 'string' ? task.id.trim() : ''; + const id = providedId || `task-${index + 1}`; + return { + ...task, + id, + // Derived defaults. + order: Number.isFinite(task.order) ? task.order : index, + hits: Number.isFinite(task.hits) ? task.hits : 0, + }; + }, + ); + + return { runner, tasks }; +}; + +// ============================================================================ +// Runner Args Normalization +// ============================================================================ + +export type NormalizedRunnerArgs = { args: string[]; error?: string }; + +/** + * Normalize runner args to ensure: + * - Exactly one `--include ` is present + * - Debug-related flags are stripped (will be re-added with correct values) + * - Single worker mode is enforced for deterministic debugging + */ +export const normalizeRunnerArgs = (args: string[]): NormalizedRunnerArgs => { + const normalized: string[] = []; + let includeCount = 0; + + for (let i = 0; i < args.length; i += 1) { + const value = args[i]; + if (!value) continue; + + // Count --include occurrences + if (value === '--include') { + const next = args[i + 1]; + if (!next || next.startsWith('-')) { + return { + args: normalized, + error: 'Runner args must include exactly one "--include ".', + }; + } + includeCount += 1; + normalized.push(value, next); + i += 1; + continue; + } + if (value.startsWith('--include=')) { + includeCount += 1; + normalized.push(value); + continue; + } + + // Strip flags that will be overridden + if ( + value === '--pool.maxWorkers' || + value === '--pool.execArgv' || + value === '--maxWorkers' + ) { + i += 1; // Skip next argument (the value) + continue; + } + if ( + value.startsWith('--pool.maxWorkers=') || + value.startsWith('--pool.execArgv=') || + value.startsWith('--maxWorkers=') + ) { + continue; + } + + // Strip inspect flags (will use --inspect-brk=0 via pool.execArgv) + if (value === '--inspect' || value === '--inspect-brk') { + const next = args[i + 1]; + if (next && !next.startsWith('-')) i += 1; + continue; + } + if (value.startsWith('--inspect=') || value.startsWith('--inspect-brk=')) { + continue; + } + + normalized.push(value); + } + + if (includeCount !== 1) { + return { + args: normalized, + error: + includeCount === 0 + ? 'Runner args must include exactly one "--include ".' + : 'Runner args must include exactly one "--include " (multiple provided).', + }; + } + + // Force single worker and inspector for deterministic debugging + normalized.push('--pool.maxWorkers=1'); + normalized.push('--pool.execArgv=--inspect-brk=0'); + + return { args: normalized }; +}; + +// ============================================================================ +// Runner Process +// ============================================================================ + +export const spawnRunner = (runner: RunnerConfig): ChildProcess => { + return spawn(runner.cmd, runner.args, { + cwd: runner.cwd, + env: { ...process.env, ...runner.env }, + stdio: ['ignore', 'pipe', 'pipe'], + }); +}; + +export const waitForInspectorUrl = (child: ChildProcess): Promise => { + return new Promise((resolve, reject) => { + let output = ''; + const onData = (data: Buffer) => { + output += data.toString(); + const match = output.match(/Debugger listening on (ws:\/\/[^\s]+)/); + if (match?.[1]) { + child.stderr?.off('data', onData); + child.stdout?.off('data', onData); + resolve(match[1]); + } + }; + child.stderr?.on('data', onData); + child.stdout?.on('data', onData); + child.on('exit', (code: number | null) => { + reject(new Error(`Runner exited before inspector ready (code: ${code})`)); + }); + }); +}; + +// ============================================================================ +// Output Writer +// ============================================================================ + +export type OutputWriter = { + write(output: DebugResult): void; +}; + +export const createOutputWriter = (outputPath?: string): OutputWriter => { + const resolvedPath = + typeof outputPath === 'string' && outputPath !== '-' + ? path.resolve(outputPath) + : null; + + return { + write: (output) => { + const payload = JSON.stringify(output, null, 2); + if (resolvedPath) { + fs.mkdirSync(path.dirname(resolvedPath), { recursive: true }); + fs.writeFileSync(resolvedPath, payload, 'utf-8'); + return; + } + console.log(payload); + }, + }; +}; diff --git a/packages/rstest-cdp/src/schema.ts b/packages/rstest-cdp/src/schema.ts new file mode 100644 index 0000000..2bb354c --- /dev/null +++ b/packages/rstest-cdp/src/schema.ts @@ -0,0 +1,146 @@ +import * as v from 'valibot'; + +/** + * Plan schema (SSoT) for `--plan` input. + * + * Notes: + * - `tasks[].id` is optional on input; the CLI will assign `task-`. + * - Some fields are derived at runtime (e.g. `order` defaults to task index). + */ + +export type RunnerConfigInput = { + cmd: string; + args: string[]; + cwd: string; + env?: Record; +}; + +export type RunnerConfigOutput = { + cmd: string; + args: string[]; + cwd: string; + env?: Record; +}; + +export const RunnerConfigSchema: v.GenericSchema< + RunnerConfigInput, + RunnerConfigOutput +> = v.object({ + cmd: v.pipe(v.string(), v.description('Runner command. Example: "pnpm".')), + args: v.pipe( + v.array(v.string()), + v.description('Runner arguments. Example: ["rstest", "run", ...].'), + ), + cwd: v.pipe( + v.string(), + v.description('Working directory for the runner process.'), + ), + env: v.pipe( + v.fallback(v.optional(v.record(v.string(), v.string())), {}), + v.description('Environment variables passed to the runner. Default: {}.'), + ), +}); + +export type TaskDefinitionInput = { + id?: string; + description?: string; + sourcePath: string; + line: number; + column?: number; + expressions?: string[]; + hitLimit?: number; + condition?: string; + order?: number; + hits?: number; +}; + +export type TaskDefinitionOutput = { + id?: string; + description?: string; + sourcePath: string; + line: number; + column?: number; + expressions?: string[]; + hitLimit?: number; + condition?: string; + order?: number; + hits?: number; +}; + +export const TaskDefinitionInputSchema: v.GenericSchema< + TaskDefinitionInput, + TaskDefinitionOutput +> = v.object({ + id: v.pipe( + v.optional(v.string()), + v.description('Task id. If omitted, the CLI assigns "task-".'), + ), + description: v.pipe( + v.optional(v.string()), + v.description('Human-readable task description.'), + ), + sourcePath: v.pipe( + v.string(), + v.description( + 'Absolute source file path in the runner workspace. Used for sourcemap mapping.', + ), + ), + // 1-based + line: v.pipe( + v.pipe(v.number(), v.integer(), v.minValue(1)), + v.description('1-based line number in the source file.'), + ), + // 0-based + column: v.pipe( + v.fallback(v.optional(v.pipe(v.number(), v.integer(), v.minValue(0))), 0), + v.description('0-based column number in the source file. Default: 0.'), + ), + expressions: v.pipe( + v.optional(v.array(v.string())), + v.description( + 'Expressions to evaluate on the paused call frame when this breakpoint is hit.', + ), + ), + hitLimit: v.pipe( + v.fallback(v.optional(v.pipe(v.number(), v.integer(), v.minValue(1))), 1), + v.description('Stop after this many hits. Default: 1.'), + ), + condition: v.pipe( + v.optional(v.string()), + v.description( + 'Conditional breakpoint expression (evaluated by the debugger). The breakpoint pauses only when this evaluates to a truthy value.', + ), + ), + order: v.pipe( + v.optional(v.pipe(v.number(), v.integer())), + v.description('Optional ordering hint (lower values run first).'), + ), + hits: v.pipe( + v.fallback(v.optional(v.pipe(v.number(), v.integer(), v.minValue(0))), 0), + v.description('Internal hit counter. Default: 0.'), + ), +}); + +export type PlanInput = { + runner: RunnerConfigInput; + tasks: TaskDefinitionInput[]; +}; + +export type PlanOutput = { + runner: RunnerConfigOutput; + tasks: TaskDefinitionOutput[]; +}; + +export const PlanInputSchema: v.GenericSchema = v.pipe( + v.object({ + runner: v.pipe( + RunnerConfigSchema, + v.description('Runner configuration used to execute the test command.'), + ), + tasks: v.pipe( + v.pipe(v.array(TaskDefinitionInputSchema), v.nonEmpty()), + v.description('List of breakpoint tasks. Must be non-empty.'), + ), + }), + v.description('Plan JSON passed to the CLI via --plan .'), +); diff --git a/packages/rstest-cdp/src/session.ts b/packages/rstest-cdp/src/session.ts new file mode 100644 index 0000000..94b99ce --- /dev/null +++ b/packages/rstest-cdp/src/session.ts @@ -0,0 +1,441 @@ +import type { ChildProcess } from 'node:child_process'; +import { createCdpClient, evaluateExpressions } from './cdp'; +import type { CliOptions, OutputWriter } from './plan'; +import { resolveBreakpoint } from './sourcemap'; +import type { + CdpClient, + DebugResult, + DebugStatus, + ExecutionError, + MappingDiagnostics, + Plan, + TaskDefinition, +} from './types'; +import { + DEFAULT_BREAKPOINT_RESOLVE_TIMEOUT_MS, + DEFAULT_INACTIVITY_TIMEOUT_MS, + MAX_DEBUG_MAPPING, + MAX_DEBUG_SCRIPTS, + MAX_MAPPING_DIAGNOSTICS, +} from './types'; + +// ============================================================================ +// Debug Session +// ============================================================================ + +const readWorkspacePath = (filePath: string, rootPath: string) => + filePath.replace(rootPath, '').replace(/^\//, '').replace(/\\/g, '/'); + +export type DebugSessionContext = { + plan: Plan; + options: CliOptions; + tasks: TaskDefinition[]; + cdp: CdpClient; + runnerProcess: ChildProcess; + output: OutputWriter; + debugLog: (...args: unknown[]) => void; +}; + +type DebuggerScriptParsedParams = { scriptId: string; url?: string }; +type DebuggerPausedParams = { + callFrames?: Array<{ callFrameId: string }>; + hitBreakpoints?: string[]; + reason?: string; + data?: { scriptId?: string; url?: string }; +}; + +export class DebugSession { + private readonly plan: Plan; + private readonly options: CliOptions; + private readonly cdp: CdpClient; + private readonly runnerProcess: ChildProcess; + private readonly output: OutputWriter; + private readonly debugLog: (...args: unknown[]) => void; + + private readonly remaining: TaskDefinition[]; + private readonly results: DebugResult['results'] = []; + private readonly errors: ExecutionError[] = []; + private readonly mappingDiagnostics: MappingDiagnostics[] = []; + + private readonly scripts = new Set(); + private readonly triedScripts = new Set(); + private readonly breakpoints = new Map(); + + private finished = false; + private breakpointTimeout: ReturnType | null = null; + private inactivityTimer: ReturnType | null = null; + + private pausedOnce = false; + + private scriptCount = 0; + + /** + * Map of scriptId -> Promise that resolves when the script has been processed + * for breakpoint resolution. This is used to coordinate between scriptParsed + * and instrumentation pause events, which can fire in either order. + */ + private readonly scriptProcessed = new Map>(); + private readonly scriptProcessedResolvers = new Map void>(); + + /** + * Get or create a Promise for tracking when a script has been processed. + * This method is safe to call from either onScriptParsed or onPaused, + * regardless of which fires first. + */ + private getOrCreateScriptPromise(scriptId: string): Promise { + const existing = this.scriptProcessed.get(scriptId); + if (existing) { + return existing; + } + let resolver: () => void; + const promise = new Promise((resolve) => { + resolver = resolve; + }); + this.scriptProcessed.set(scriptId, promise); + this.scriptProcessedResolvers.set(scriptId, resolver!); + return promise; + } + + /** + * Mark a script as processed (resolve its Promise). + * If the Promise doesn't exist yet, create it first then resolve. + */ + private markScriptProcessed(scriptId: string): void { + // Ensure the Promise exists (in case onPaused hasn't created it yet) + if (!this.scriptProcessed.has(scriptId)) { + this.getOrCreateScriptPromise(scriptId); + } + const resolver = this.scriptProcessedResolvers.get(scriptId); + if (resolver) { + resolver(); + } + } + + constructor(ctx: DebugSessionContext) { + this.plan = ctx.plan; + this.options = ctx.options; + this.cdp = ctx.cdp; + this.runnerProcess = ctx.runnerProcess; + this.output = ctx.output; + this.debugLog = ctx.debugLog; + this.remaining = [...ctx.tasks]; + } + + /** Start listening for CDP events and set up timeout */ + start(): void { + this.cdp.on( + 'Debugger.scriptParsed', + (params) => void this.onScriptParsed(params), + ); + this.cdp.on( + 'Debugger.paused', + (params) => void this.onPaused(params), + ); + + const breakpointTimeoutMs = + this.options.breakpointTimeout ?? DEFAULT_BREAKPOINT_RESOLVE_TIMEOUT_MS; + this.breakpointTimeout = setTimeout(() => { + if (!this.breakpoints.size && this.remaining.length) { + this.errors.push({ error: 'No breakpoints resolved for tasks.' }); + this.finalize(this.runnerProcess.exitCode); + this.cdp.close(); + } + }, breakpointTimeoutMs); + } + + /** Enable debugger and start execution */ + async enableAndRun(): Promise { + await this.cdp.send('Runtime.enable'); + await this.cdp.send('Debugger.enable'); + // Set instrumentation breakpoint to pause before each script execution + // This ensures we can set breakpoints before the script runs + try { + await this.cdp.send('Debugger.setInstrumentationBreakpoint', { + instrumentation: 'beforeScriptExecution', + }); + this.debugLog('instrumentation breakpoint set: beforeScriptExecution'); + } catch (error) { + this.debugLog( + 'failed to set instrumentation breakpoint:', + error instanceof Error ? error.message : String(error), + ); + } + await this.cdp.send('Runtime.runIfWaitingForDebugger'); + } + + /** Handle runner process exit */ + onRunnerExit(code: number | null): void { + if (!this.finished) { + this.finalize(code); + } + this.cdp.close(); + } + + // -------------------------------------------------------------------------- + // Private methods + // -------------------------------------------------------------------------- + + private finalize(exitCode: number | null): void { + if (this.finished) return; + this.finished = true; + this.clearTimers(); + + // Determine status based on results and remaining tasks + let status: DebugStatus; + if (this.results.length === 0) { + status = 'failed'; + } else if (this.remaining.length === 0) { + status = 'full_succeed'; + } else { + status = 'partial_succeed'; + } + + const output: DebugResult = { + status, + exitCode, + results: this.results, + errors: this.errors, + // Only include meta in debug mode - it's diagnostic info not needed for normal use + ...(this.options.debug && { + meta: { + runner: this.plan.runner, + forwardedArgs: [this.plan.runner.cmd, ...this.plan.runner.args], + pendingTaskIds: this.remaining.map((t) => t.id), + mappingDiagnostics: this.mappingDiagnostics, + }, + }), + }; + this.output.write(output); + + if (status === 'failed' && this.runnerProcess.exitCode == null) { + this.runnerProcess.kill('SIGTERM'); + } + } + + private clearTimers(): void { + if (this.breakpointTimeout) { + clearTimeout(this.breakpointTimeout); + this.breakpointTimeout = null; + } + if (this.inactivityTimer) { + clearTimeout(this.inactivityTimer); + this.inactivityTimer = null; + } + } + + private resetInactivityTimer(): void { + if (this.inactivityTimer) clearTimeout(this.inactivityTimer); + const inactivityTimeoutMs = + this.options.inactivityTimeout ?? DEFAULT_INACTIVITY_TIMEOUT_MS; + this.inactivityTimer = setTimeout(() => { + this.errors.push({ error: 'Timeout waiting for breakpoint hits.' }); + this.finalize(this.runnerProcess.exitCode); + this.cdp.close(); + }, inactivityTimeoutMs); + } + + private async onScriptParsed( + params: DebuggerScriptParsedParams, + ): Promise { + if (!params?.scriptId || this.scripts.has(params.scriptId)) return; + this.scripts.add(params.scriptId); + this.scriptCount += 1; + + if (this.options.debug && this.scriptCount <= MAX_DEBUG_SCRIPTS) { + this.debugLog('scriptParsed', params.scriptId, params.url || ''); + } + + // Skip internal Node.js scripts + if (params.url?.startsWith('node:')) { + this.markScriptProcessed(params.scriptId); + return; + } + + const tasksToResolve = this.remaining.filter( + (task) => !this.triedScripts.has(`${params.scriptId}:${task.id}`), + ); + + await Promise.all( + tasksToResolve.map(async (task) => { + this.triedScripts.add(`${params.scriptId}:${task.id}`); + const resolution = await resolveBreakpoint({ + cdp: this.cdp, + scriptId: params.scriptId, + url: params.url, + task, + rootDir: this.plan.runner.cwd, + }); + + if (this.mappingDiagnostics.length < MAX_MAPPING_DIAGNOSTICS) { + this.mappingDiagnostics.push(resolution.diagnostics); + } + + if (!resolution.location) { + if ( + this.options.debug && + this.mappingDiagnostics.length <= MAX_DEBUG_MAPPING + ) { + this.debugLog('mapping', resolution.diagnostics); + } + return; + } + + try { + const result = await this.cdp.send<{ breakpointId: string }>( + 'Debugger.setBreakpoint', + { + location: resolution.location, + ...(task.condition ? { condition: task.condition } : {}), + }, + ); + this.breakpoints.set(result.breakpointId, task); + this.debugLog( + `breakpoint set for ${task.id} at ${resolution.location.scriptId}`, + `${resolution.location.lineNumber}:${resolution.location.columnNumber}`, + ); + if (this.breakpointTimeout) { + clearTimeout(this.breakpointTimeout); + this.breakpointTimeout = null; + } + } catch (error) { + this.errors.push({ + taskId: task.id, + error: error instanceof Error ? error.message : String(error), + }); + } + }), + ); + + // Mark this script as processed so instrumentation pause can resume + this.markScriptProcessed(params.scriptId); + } + + private async onPaused(params: DebuggerPausedParams): Promise { + // Skip if session already finished (WebSocket may be closing/closed) + if (this.finished) { + this.debugLog('onPaused skipped (session finished)'); + return; + } + + this.debugLog('onPaused', { + reason: params.reason, + pausedOnce: this.pausedOnce, + hitBreakpoints: params.hitBreakpoints, + breakpointCount: this.breakpoints.size, + breakpointIds: Array.from(this.breakpoints.keys()), + data: params.data, + }); + + // Handle instrumentation breakpoints (beforeScriptExecution) + // These fire before each script runs, giving us a chance to set breakpoints + if (params.reason === 'instrumentation') { + const scriptId = params.data?.scriptId; + if (scriptId) { + // Wait for the scriptParsed handler to finish processing this script + // This ensures breakpoints are set before the script executes + // Note: getOrCreateScriptPromise handles the race condition where + // onPaused may fire before onScriptParsed + const processedPromise = this.getOrCreateScriptPromise(scriptId); + await processedPromise; + // Check again after await - session may have finished while waiting + if (this.finished) { + this.debugLog('onPaused skipped after wait (session finished)'); + return; + } + this.debugLog('script processed, resuming', scriptId); + } + // After all breakpoints are set, disable instrumentation to avoid performance hit + if (this.breakpoints.size >= this.remaining.length) { + try { + await this.cdp.send('Debugger.removeInstrumentationBreakpoint', { + instrumentation: 'beforeScriptExecution', + }); + this.debugLog('instrumentation breakpoint removed'); + } catch (error) { + this.debugLog( + 'failed to remove instrumentation breakpoint:', + error instanceof Error ? error.message : String(error), + ); + } + } + try { + await this.cdp.send('Debugger.resume'); + } catch (error) { + this.debugLog( + 'failed to resume after instrumentation:', + error instanceof Error ? error.message : String(error), + ); + } + return; + } + + // First pause (from --inspect-brk): just resume, instrumentation breakpoint will handle the rest + if (!this.pausedOnce) { + this.pausedOnce = true; + this.debugLog('first pause resume (instrumentation enabled)'); + await this.cdp.send('Debugger.resume'); + if (this.breakpoints.size) this.resetInactivityTimer(); + return; + } + + this.resetInactivityTimer(); + + const frame = params.callFrames?.[0]; + const hitBreakpointId = params.hitBreakpoints?.[0]; + this.debugLog('breakpoint hit check', { frame: !!frame, hitBreakpointId }); + if (!frame || !hitBreakpointId) { + await this.cdp.send('Debugger.resume'); + return; + } + + const task = this.breakpoints.get(hitBreakpointId); + if (!task) { + await this.cdp.send('Debugger.resume'); + return; + } + + // Determine expressions to evaluate + const expressions = task.expressions?.length ? task.expressions : []; + if (!expressions.length) { + this.errors.push({ taskId: task.id, error: 'No expressions specified.' }); + await this.cdp.send('Debugger.resume'); + return; + } + + // Evaluate and record result + const evaluated = await evaluateExpressions({ + cdp: this.cdp, + callFrameId: frame.callFrameId, + expressions, + debugLog: this.options.debug ? this.debugLog : undefined, + }); + + this.results.push({ + id: task.id, + description: task.description, + sourcePath: readWorkspacePath(task.sourcePath, this.plan.runner.cwd), + line: task.line, + column: task.column ?? 0, + values: evaluated, + }); + + // Track hits and remove completed tasks + task.hits = (task.hits ?? 0) + 1; + if (task.hits >= (task.hitLimit ?? 1)) { + const index = this.remaining.findIndex((t) => t.id === task.id); + if (index >= 0) this.remaining.splice(index, 1); + this.breakpoints.forEach((value, key) => { + if (value.id === task.id) this.breakpoints.delete(key); + }); + } + + await this.cdp.send('Debugger.resume'); + if (!this.remaining.length) { + this.finalize(this.runnerProcess.exitCode); + this.cdp.close(); + } + } +} + +// Re-export for index.ts +export { createCdpClient }; diff --git a/packages/rstest-cdp/src/sourcemap.ts b/packages/rstest-cdp/src/sourcemap.ts new file mode 100644 index 0000000..daada1f --- /dev/null +++ b/packages/rstest-cdp/src/sourcemap.ts @@ -0,0 +1,212 @@ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + GREATEST_LOWER_BOUND, + generatedPositionFor, + LEAST_UPPER_BOUND, + TraceMap, +} from '@jridgewell/trace-mapping'; +import type { CdpClient, MappingDiagnostics, TaskDefinition } from './types'; + +// ============================================================================ +// Source Map Resolution +// ============================================================================ + +const INLINE_SOURCEMAP_REGEX = + /\/\/[#@]\s*sourceMappingURL=data:application\/json(?:;charset=[^;]+)?;base64,([^\s]+)/; +const FILE_SOURCEMAP_REGEX = /\/\/[#@]\s*sourceMappingURL=([^\s]+)/; + +const normalizePath = (value: string) => value.replace(/\\/g, '/'); + +/** + * Normalize source paths from sourcemaps. + * Handles webpack/rspack prefixes like: + * - webpack:///./src/foo.ts + * - webpack:///./src/foo.ts + */ +const normalizeMapSourcePath = (value: string) => { + if (!value) return ''; + if (value.startsWith('file://')) { + try { + return normalizePath(fileURLToPath(value)); + } catch { + // fall through + } + } + let source = normalizePath(value); + source = source.replace(/^[a-zA-Z]+:\/\/\/+/, ''); + const firstSlash = source.indexOf('/'); + if (firstSlash >= 0) source = source.slice(firstSlash + 1); + source = source.replace(/^\.(\/|\\)/, ''); + return source; +}; + +const matchesSource = (source: string, target: string, rootDir?: string) => { + const sourceValue = normalizeMapSourcePath(source); + const targetValue = normalizePath(target); + const targetRelative = rootDir + ? normalizePath(path.relative(rootDir, targetValue)) + : ''; + const candidates = [targetValue, targetRelative].filter(Boolean); + const targetBase = path.posix.basename(targetValue); + + return ( + candidates.some( + (c) => + sourceValue === c || sourceValue.endsWith(c) || c.endsWith(sourceValue), + ) || + (targetBase && sourceValue.endsWith(`/${targetBase}`)) || + sourceValue === targetBase + ); +}; + +/** Try line and adjacent lines to handle minification offset */ +const hintTaskLines = (task: TaskDefinition) => + [task.line, task.line - 1, task.line + 1].filter((v) => v > 0); + +const parseSourceMap = (source: string, scriptUrl?: string) => { + // Inline base64 sourcemap + const inlineMatch = source.match(INLINE_SOURCEMAP_REGEX); + if (inlineMatch?.[1]) { + const json = Buffer.from(inlineMatch[1], 'base64').toString('utf-8'); + return JSON.parse(json); + } + // External sourcemap file + const fileMatch = source.match(FILE_SOURCEMAP_REGEX); + if (!fileMatch?.[1] || !scriptUrl?.startsWith('file://')) return null; + const scriptPath = fileURLToPath(scriptUrl); + const mapPath = path.resolve(path.dirname(scriptPath), fileMatch[1]); + return JSON.parse(readFileSync(mapPath, 'utf-8')); +}; + +export type BreakpointResolution = { + location: { + scriptId: string; + lineNumber: number; + columnNumber: number; + } | null; + diagnostics: MappingDiagnostics; +}; + +export const resolveBreakpoint = async ({ + cdp, + scriptId, + url, + task, + rootDir, +}: { + cdp: CdpClient; + scriptId: string; + url?: string; + task: TaskDefinition; + rootDir?: string; +}): Promise => { + try { + const { scriptSource } = await cdp.send<{ scriptSource: string }>( + 'Debugger.getScriptSource', + { + scriptId, + }, + ); + const sourceMap = parseSourceMap(scriptSource, url); + if (!sourceMap) { + return { + location: null, + diagnostics: { + scriptId, + url, + taskId: task.id, + reason: 'no-sourcemap', + hasSourceMapComment: FILE_SOURCEMAP_REGEX.test(scriptSource), + }, + }; + } + + const traceMap = new TraceMap(sourceMap); + const matchedSource = traceMap.sources.find( + (s) => s && matchesSource(s, task.sourcePath, rootDir), + ); + if (!matchedSource) { + return { + location: null, + diagnostics: { + scriptId, + url, + taskId: task.id, + reason: 'source-mismatch', + sourcesSample: traceMap.sources + .filter((s): s is string => Boolean(s)) + .slice(0, 3), + }, + }; + } + + // Try multiple column/line combinations to find a valid mapping + const columns = [task.column ?? 0, 0, Math.max((task.column ?? 0) - 1, 0)]; + let generated: { line: number; column: number } | null = null; + + outer: for (const col of columns) { + for (const line of hintTaskLines(task)) { + const primary = generatedPositionFor(traceMap, { + source: matchedSource, + line, + column: col, + bias: GREATEST_LOWER_BOUND, + }); + const fallback = generatedPositionFor(traceMap, { + source: matchedSource, + line, + column: col, + bias: LEAST_UPPER_BOUND, + }); + const resolved = primary?.line ? primary : fallback; + if (resolved?.line) { + generated = { line: resolved.line, column: resolved.column }; + break outer; + } + } + } + + if (!generated?.line || generated.column == null) { + return { + location: null, + diagnostics: { + scriptId, + url, + taskId: task.id, + reason: 'generated-position-missing', + matchedSource, + }, + }; + } + + return { + location: { + scriptId, + lineNumber: generated.line - 1, // CDP uses 0-based line numbers + columnNumber: generated.column, + }, + diagnostics: { + scriptId, + url, + taskId: task.id, + reason: 'ok', + matchedSource, + generatedLine: generated.line, + generatedColumn: generated.column, + }, + }; + } catch (error) { + return { + location: null, + diagnostics: { + scriptId, + url, + taskId: task.id, + reason: 'script-error', + error: error instanceof Error ? error.message : String(error), + }, + }; + } +}; diff --git a/packages/rstest-cdp/src/types.ts b/packages/rstest-cdp/src/types.ts new file mode 100644 index 0000000..972e8df --- /dev/null +++ b/packages/rstest-cdp/src/types.ts @@ -0,0 +1,126 @@ +// ============================================================================ +// Types +// ============================================================================ + +export type Plan = { + runner: RunnerConfig; + tasks: TaskDefinition[]; +}; + +export type RunnerConfig = { + cmd: string; + args: string[]; + cwd: string; + env?: Record; +}; + +export type TaskDefinition = { + id: string; + description?: string; + sourcePath: string; + /** 1-based line number */ + line: number; + /** 0-based column number */ + column?: number; + expressions?: string[]; + hitLimit?: number; + condition?: string; + order?: number; + hits?: number; +}; + +export type TaskResult = { + id: string; + description?: string; + sourcePath: string; + line: number; + column: number; + values: EvaluatedValue[]; +}; + +export type EvaluatedValue = { + expression: string; + value: unknown; + type?: string; + subtype?: string; + preview?: string; +}; + +export type MappingDiagnostics = { + scriptId: string; + url?: string; + taskId: string; + reason: + | 'ok' + | 'no-sourcemap' + | 'source-mismatch' + | 'generated-position-missing' + | 'script-error'; + hasSourceMapComment?: boolean; + sourcesSample?: string[]; + matchedSource?: string; + generatedLine?: number; + generatedColumn?: number; + error?: string; +}; + +export type ExecutionError = { + taskId?: string; + error: string; +}; + +/** + * - 'full_succeed': All tasks completed (all hitLimits reached) + * - 'partial_succeed': Some results collected, but not all tasks completed + * - 'failed': No results collected (e.g., no breakpoints resolved, runner crashed) + */ +export type DebugStatus = 'full_succeed' | 'partial_succeed' | 'failed'; + +export type DebugResult = { + status: DebugStatus; + /** Runner exit code (null if killed or not exited) */ + exitCode?: number | null; + results: TaskResult[]; + errors: ExecutionError[]; + /** Diagnostic metadata, only included in debug mode */ + meta?: { + runner: RunnerConfig; + forwardedArgs: string[]; + pendingTaskIds: string[]; + mappingDiagnostics: MappingDiagnostics[]; + }; +}; + +export type CdpClient = { + send( + method: string, + params?: Record, + ): Promise; + on( + method: string, + handler: (params: TParams) => void, + ): void; + close(): void; +}; + +// ============================================================================ +// Constants +// ============================================================================ + +/** Grace period before first resume to allow breakpoints to be set */ +export const DEFAULT_FIRST_PAUSE_GRACE_MS = 4_000; + +/** Timeout for resolving at least one breakpoint */ +export const DEFAULT_BREAKPOINT_RESOLVE_TIMEOUT_MS = 20_000; + +/** Timeout between breakpoint hits before giving up */ +export const DEFAULT_INACTIVITY_TIMEOUT_MS = 40_000; + +/** Maximum mapping diagnostics to record (prevents memory bloat) */ +export const MAX_MAPPING_DIAGNOSTICS = 50; + +/** Maximum scripts to log in debug mode */ +export const MAX_DEBUG_SCRIPTS = 10; + +/** Maximum mapping diagnostics to log in debug mode */ +export const MAX_DEBUG_MAPPING = 10; diff --git a/packages/rstest-cdp/tsconfig.json b/packages/rstest-cdp/tsconfig.json new file mode 100644 index 0000000..01ec196 --- /dev/null +++ b/packages/rstest-cdp/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "lib": ["ESNext"], + "jsx": "preserve", + "target": "ES2022", + "types": ["node"], + "skipLibCheck": true, + "useDefineForClassFields": true, + + /* modules */ + "module": "ES2022", + "moduleDetection": "force", + "moduleResolution": "bundler", + "esModuleInterop": true, + "verbatimModuleSyntax": true, + "resolveJsonModule": true, + "noUncheckedSideEffectImports": true, + + /* type checking */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + + "noEmit": true, + "allowImportingTsExtensions": true + }, + "$schema": "https://json.schemastore.org/tsconfig", + "display": "Base" +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2a21092..eb7572c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -33,6 +33,48 @@ importers: specifier: ^5.9.3 version: 5.9.3 + e2e: + devDependencies: + '@rstest/core': + specifier: ^0.8.2 + version: 0.8.3 + tinyexec: + specifier: ^1.0.2 + version: 1.0.2 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + e2e/cdp/fixtures/basic: + devDependencies: + '@rstest/core': + specifier: ^0.8.2 + version: 0.8.3 + + e2e/cdp/fixtures/basic-no-debug: + devDependencies: + '@rstest/core': + specifier: ^0.8.2 + version: 0.8.3 + + e2e/cdp/fixtures/invalid-line: + devDependencies: + '@rstest/core': + specifier: ^0.8.2 + version: 0.8.3 + + e2e/cdp/fixtures/mismatch: + devDependencies: + '@rstest/core': + specifier: ^0.8.2 + version: 0.8.3 + + e2e/cdp/fixtures/multi: + devDependencies: + '@rstest/core': + specifier: ^0.8.2 + version: 0.8.3 + packages/rsdoctor-analysis: devDependencies: '@rslib/core': @@ -48,6 +90,36 @@ importers: specifier: ^5.9.3 version: 5.9.3 + packages/rstest-cdp: + devDependencies: + '@jridgewell/trace-mapping': + specifier: 0.3.31 + version: 0.3.31 + '@rslib/core': + specifier: ^0.19.0 + version: 0.19.4(typescript@5.9.3) + '@types/ws': + specifier: ^8.18.1 + version: 8.18.1 + '@valibot/to-json-schema': + specifier: ^1.2.0 + version: 1.5.0(valibot@1.2.0(typescript@5.9.3)) + cac: + specifier: ^6.7.14 + version: 6.7.14 + json-rpc-2.0: + specifier: ^1.7.1 + version: 1.7.1 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + valibot: + specifier: ^1.2.0 + version: 1.2.0(typescript@5.9.3) + ws: + specifier: ^8.18.3 + version: 8.19.0 + scripts/config: devDependencies: '@rslib/core': @@ -190,6 +262,16 @@ packages: '@emnapi/wasi-threads@1.1.0': resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@module-federation/error-codes@0.22.0': resolution: {integrity: sha512-xF9SjnEy7vTdx+xekjPCV5cIHOGCkdn3pIxo9vU7gEZMIw0SvAEdsy6Uh17xaCpm8V0FWvR0SZoK9Ik6jGOaug==} @@ -297,15 +379,50 @@ packages: '@rspack/lite-tapable@1.1.0': resolution: {integrity: sha512-E2B0JhYFmVAwdDiG14+DW0Di4Ze4Jg10Pc4/lILUrd5DRCaklduz2OvJ5HYQ6G+hd+WTzqQb3QnDNfK4yvAFYw==} + '@rstest/core@0.8.3': + resolution: {integrity: sha512-KNBCrqeYhyU5/D20zyyEeOHjsx2AD6E3j6kY9FxMRkFEq+Dnhm1V35X1yi4jTyUzpIKgK/roLeLYAOBlBmg+lA==} + engines: {node: '>=18.12.0'} + hasBin: true + peerDependencies: + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + happy-dom: + optional: true + jsdom: + optional: true + '@swc/helpers@0.5.18': resolution: {integrity: sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==} '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/node@24.10.10': resolution: {integrity: sha512-+0/4J266CBGPUq/ELg7QUHhN25WYjE0wYTPSQJn1xeu8DOlIOPxXxrNGiLmfAWl7HMMgWFWXpt9IDjMWrF5Iow==} + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + + '@valibot/to-json-schema@1.5.0': + resolution: {integrity: sha512-GE7DmSr1C2UCWPiV0upRH6mv0cCPsqYGs819fb6srCS1tWhyXrkGGe+zxUiwzn/L1BOfADH4sNjY/YHCuP8phQ==} + peerDependencies: + valibot: ^1.2.0 + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + commander@12.1.0: resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} engines: {node: '>=18'} @@ -341,6 +458,9 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + json-rpc-2.0@1.7.1: + resolution: {integrity: sha512-JqZjhjAanbpkXIzFE7u8mE/iFblawwlXtONaCvRqI+pyABVz7B4M1EUNpyVW+dZjqgQ2L5HFmZCmOCgUKm00hg==} + nano-staged@0.9.0: resolution: {integrity: sha512-0JfyX4i0Vp5HhC9RDtJ1kp7psz8CFuS3Gya3Z6WZv//QCwA9dPzi1S803VdR0c0P6R7sSvweZ5mSJmYQ/N+loQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -399,10 +519,18 @@ packages: engines: {node: '>=20'} hasBin: true + tinyexec@1.0.2: + resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} + engines: {node: '>=18'} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -414,6 +542,26 @@ packages: undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + valibot@1.2.0: + resolution: {integrity: sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==} + peerDependencies: + typescript: '>=5' + peerDependenciesMeta: + typescript: + optional: true + + ws@8.19.0: + resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + snapshots: '@ast-grep/napi-darwin-arm64@0.37.0': @@ -506,6 +654,15 @@ snapshots: tslib: 2.8.1 optional: true + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + '@module-federation/error-codes@0.22.0': {} '@module-federation/runtime-core@0.22.0': @@ -610,6 +767,12 @@ snapshots: '@rspack/lite-tapable@1.1.0': {} + '@rstest/core@0.8.3': + dependencies: + '@rsbuild/core': 1.7.3 + '@types/chai': 5.2.3 + tinypool: 1.1.1 + '@swc/helpers@0.5.18': dependencies: tslib: 2.8.1 @@ -619,10 +782,29 @@ snapshots: tslib: 2.8.1 optional: true + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + '@types/node@24.10.10': dependencies: undici-types: 7.16.0 + '@types/ws@8.18.1': + dependencies: + '@types/node': 24.10.10 + + '@valibot/to-json-schema@1.5.0(valibot@1.2.0(typescript@5.9.3))': + dependencies: + valibot: 1.2.0(typescript@5.9.3) + + assertion-error@2.0.1: {} + + cac@6.7.14: {} + commander@12.1.0: {} core-js@3.47.0: {} @@ -641,6 +823,8 @@ snapshots: jiti@2.6.1: {} + json-rpc-2.0@1.7.1: {} + nano-staged@0.9.0: dependencies: picocolors: 1.1.1 @@ -680,13 +864,23 @@ snapshots: sort-object-keys: 2.1.0 tinyglobby: 0.2.15 + tinyexec@1.0.2: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tinypool@1.1.1: {} + tslib@2.8.1: {} typescript@5.9.3: {} undici-types@7.16.0: {} + + valibot@1.2.0(typescript@5.9.3): + optionalDependencies: + typescript: 5.9.3 + + ws@8.19.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 316a29b..d706a28 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,7 @@ packages: - packages/** - scripts/** + - e2e/** - '!**/dist/**' - '!**/dist-*/**' diff --git a/skills/rspack-debugging/scripts/setup_debug_deps.cjs b/skills/rspack-debugging/scripts/setup_debug_deps.cjs index f5d0562..25e061a 100644 --- a/skills/rspack-debugging/scripts/setup_debug_deps.cjs +++ b/skills/rspack-debugging/scripts/setup_debug_deps.cjs @@ -70,8 +70,8 @@ function isVersionLessThan(v1, v2) { // Backup first if (!fs.existsSync(backupPath)) { - fs.copyFileSync(pkgPath, backupPath); - console.log(`๐Ÿ“ฆ Created backup of package.json at ${backupPath}`); + fs.copyFileSync(pkgPath, backupPath); + console.log(`๐Ÿ“ฆ Created backup of package.json at ${backupPath}`); } console.log('๐Ÿ”Ž Searching for @rspack/core version in pnpm-lock.yaml...'); @@ -90,8 +90,12 @@ let version = versionMatch[1]; console.log(`โœ… Detected Rspack version: ${version}`); if (isVersionLessThan(version, USER_MIN_VERSION)) { - console.warn(`\nโš ๏ธ WARNING: @rspack-debug/* packages are only officially supported for versions >= ${USER_MIN_VERSION}.`); - console.warn(` Current version is ${version}. Falling back to debug version ${USER_MIN_VERSION}.`); + console.warn( + `\nโš ๏ธ WARNING: @rspack-debug/* packages are only officially supported for versions >= ${USER_MIN_VERSION}.`, + ); + console.warn( + ` Current version is ${version}. Falling back to debug version ${USER_MIN_VERSION}.`, + ); console.warn(` This may lead to binary incompatibility if there are major API changes.\n`); version = USER_MIN_VERSION; } @@ -114,4 +118,4 @@ pkg.pnpm.overrides['@rspack/cli'] = debugCli; fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n'); console.log(`โœ… package.json at ${workspaceRoot} updated.`); console.log('\n๐Ÿ‘‰ Next Step: Run `pnpm install` in the workspace root to apply the overrides.'); -console.log(' To revert changes, run this script with --restore'); \ No newline at end of file +console.log(' To revert changes, run this script with --restore'); diff --git a/skills/rspack-tracing/scripts/analyze_trace.js b/skills/rspack-tracing/scripts/analyze_trace.js index 87a41b0..20b2fab 100644 --- a/skills/rspack-tracing/scripts/analyze_trace.js +++ b/skills/rspack-tracing/scripts/analyze_trace.js @@ -6,19 +6,24 @@ const path = require('path'); // Parse duration string (e.g., "1.23ms", "456.78ยตs", "0.12s") to milliseconds function parseDuration(durationStr) { if (!durationStr) return 0; - + const match = durationStr.match(/^([\d.]+)(ms|ยตs|s|ns)$/); if (!match) return 0; - + const value = parseFloat(match[1]); const unit = match[2]; - + switch (unit) { - case 's': return value * 1000; - case 'ms': return value; - case 'ยตs': return value / 1000; - case 'ns': return value / 1000000; - default: return value; + case 's': + return value * 1000; + case 'ms': + return value; + case 'ยตs': + return value / 1000; + case 'ns': + return value / 1000000; + default: + return value; } } @@ -35,59 +40,63 @@ console.log(`Analyzing trace file: ${tracePath}\n`); try { const fileContent = fs.readFileSync(tracePath, 'utf8'); - + // Parse line-delimited JSON - const events = fileContent.trim().split('\n') - .map(line => { - try { return JSON.parse(line); } - catch(err) { return null; } + const events = fileContent + .trim() + .split('\n') + .map((line) => { + try { + return JSON.parse(line); + } catch (err) { + return null; + } }) .filter(Boolean); if (!events.length) { - console.error("No valid trace events found."); + console.error('No valid trace events found.'); process.exit(1); } - console.log("=== Rspack Build Performance Analysis ===\n"); + console.log('=== Rspack Build Performance Analysis ===\n'); console.log(`Total events: ${events.length}\n`); - + // Categorize events by target const pluginStats = new Map(); const loaderStats = new Map(); - - events.forEach(event => { + + events.forEach((event) => { const target = event.target; const timeField = event.fields?.['time.busy']; - + if (!timeField) return; - + const duration = parseDuration(timeField); - + if (target === 'Plugin Analysis') { // Plugin performance const pluginName = event.span?.name; if (!pluginName) return; - + if (!pluginStats.has(pluginName)) { - pluginStats.set(pluginName, { - count: 0, - total: 0, + pluginStats.set(pluginName, { + count: 0, + total: 0, max: 0, - min: Infinity + min: Infinity, }); } - + const stat = pluginStats.get(pluginName); stat.count++; stat.total += duration; stat.max = Math.max(stat.max, duration); stat.min = Math.min(stat.min, duration); - } else if (target === 'Loader Analysis') { // Loader performance let loaderName = event.span?.name; - + // For pitch phase (span.name is null), use resource path if (!loaderName) { const resource = event.fields?.resource; @@ -100,16 +109,16 @@ try { loaderName = 'Loader pitch (unknown resource)'; } } - + if (!loaderStats.has(loaderName)) { - loaderStats.set(loaderName, { - count: 0, - total: 0, + loaderStats.set(loaderName, { + count: 0, + total: 0, max: 0, - min: Infinity + min: Infinity, }); } - + const stat = loaderStats.get(loaderName); stat.count++; stat.total += duration; @@ -120,46 +129,46 @@ try { // Display Plugin Analysis if (pluginStats.size > 0) { - console.log("๐Ÿ”Œ Plugin Analysis (by name):"); - console.log("โ”€".repeat(80)); - - const sortedPlugins = [...pluginStats.entries()] - .sort((a, b) => b[1].total - a[1].total); - + console.log('๐Ÿ”Œ Plugin Analysis (by name):'); + console.log('โ”€'.repeat(80)); + + const sortedPlugins = [...pluginStats.entries()].sort((a, b) => b[1].total - a[1].total); + sortedPlugins.forEach(([name, stat]) => { const avg = stat.total / stat.count; console.log(`${name}`); - console.log(` Total: ${stat.total.toFixed(2)}ms | Count: ${stat.count} | ` + - `Avg: ${avg.toFixed(2)}ms | Max: ${stat.max.toFixed(2)}ms | Min: ${stat.min.toFixed(2)}ms`); - console.log(""); + console.log( + ` Total: ${stat.total.toFixed(2)}ms | Count: ${stat.count} | ` + + `Avg: ${avg.toFixed(2)}ms | Max: ${stat.max.toFixed(2)}ms | Min: ${stat.min.toFixed(2)}ms`, + ); + console.log(''); }); - - const totalPluginTime = [...pluginStats.values()] - .reduce((sum, stat) => sum + stat.total, 0); + + const totalPluginTime = [...pluginStats.values()].reduce((sum, stat) => sum + stat.total, 0); console.log(`Total Plugin Time: ${totalPluginTime.toFixed(2)}ms\n`); } // Display Loader Analysis if (loaderStats.size > 0) { - console.log("\n๐Ÿ”ง Loader Analysis (by name):"); - console.log("โ”€".repeat(80)); - - const sortedLoaders = [...loaderStats.entries()] - .sort((a, b) => b[1].total - a[1].total); - + console.log('\n๐Ÿ”ง Loader Analysis (by name):'); + console.log('โ”€'.repeat(80)); + + const sortedLoaders = [...loaderStats.entries()].sort((a, b) => b[1].total - a[1].total); + sortedLoaders.forEach(([name, stat]) => { const avg = stat.total / stat.count; console.log(`${name}`); - console.log(` Total: ${stat.total.toFixed(2)}ms | Count: ${stat.count} | ` + - `Avg: ${avg.toFixed(2)}ms | Max: ${stat.max.toFixed(2)}ms | Min: ${stat.min.toFixed(2)}ms`); - console.log(""); + console.log( + ` Total: ${stat.total.toFixed(2)}ms | Count: ${stat.count} | ` + + `Avg: ${avg.toFixed(2)}ms | Max: ${stat.max.toFixed(2)}ms | Min: ${stat.min.toFixed(2)}ms`, + ); + console.log(''); }); - - const totalLoaderTime = [...loaderStats.values()] - .reduce((sum, stat) => sum + stat.total, 0); + + const totalLoaderTime = [...loaderStats.values()].reduce((sum, stat) => sum + stat.total, 0); console.log(`Total Loader Time: ${totalLoaderTime.toFixed(2)}ms\n`); } } catch (err) { - console.error("Error processing trace file:", err); + console.error('Error processing trace file:', err); process.exit(1); } diff --git a/skills/rstest-cdp/SKILL.md b/skills/rstest-cdp/SKILL.md new file mode 100644 index 0000000..a3c73e0 --- /dev/null +++ b/skills/rstest-cdp/SKILL.md @@ -0,0 +1,154 @@ +--- +name: rstest-cdp +description: Debug a failing rstest test via Node inspector (CDP) by setting sourcemap-mapped breakpoints and evaluating expressions at specific source locations. Use when a test fails and logs are insufficient; you need in-scope values at an exact line/column. +compatibility: Requires Node >=18.12, npx, and permission to run the project's test command (pnpm/npm) in the target workspace. +--- + +# rstest-cdp + +This skill uses the `rstest-cdp` CLI as a black box. You generate a JSON plan, run the CLI once, then iterate by updating the plan. + +## Quick start + +1. Identify the failing test file and the project root (where the test config lives). +2. Choose a source location to inspect (absolute `sourcePath` + `line` + optional `column`). +3. Decide what to inspect (`expressions[]`). +4. Run: + +```bash +npx rstest-cdp --plan - <<'EOF' +{ + "runner": { + "cmd": "pnpm", + "args": ["rstest", "run", "-c", "rstest.config.ts", "--include", "test/foo.test.ts"], + "cwd": "/abs/path/to/project", + "env": {} + }, + "tasks": [ + { + "sourcePath": "/abs/path/to/project/src/foo.ts", + "line": 42, + "column": 0, + "expressions": ["value", "typeof value"] + } + ] +} +EOF +``` + +5. Parse stdout as JSON (`DebugResult`). Do not treat stderr as JSON. + +## Inputs to collect (minimum) + +- `projectRoot`: absolute path (becomes `runner.cwd`) +- `testFile`: relative/absolute path used by `--include ` (must be exactly one) +- `config` (optional): e.g. `-c rstest.config.ts` if required +- `breakpointLocation`: + - `sourcePath`: absolute path to original source file (not built output) + - `line`: 1-based line number + - `column` (optional): 0-based column number +- `expressions`: variable names / property paths / small probes to evaluate at pause + +Optional: + +- `condition`: only collect values on matching hits +- `hitLimit`: raise if the breakpoint can trigger many times + +## Plan format + +The plan is a JSON object: + +- `runner` (required) + - `cmd` (string): executable (e.g. `pnpm`, `npm`, `node`) + - `args` (string[]): command arguments; MUST include exactly one `--include ` (or `--include=`) + - `cwd` (string): project root + - `env` (object, optional): string-to-string environment variables +- `tasks` (required, non-empty array) + - `id` (optional): stable identifier; if omitted, the CLI assigns `task-` + - `sourcePath` (required): absolute source path + - `line` (required): 1-based line + - `column` (optional): 0-based column (default 0) + - `expressions` (optional): array of JS expressions evaluated in the paused frame + - `condition` (optional): JS expression; only record values when truthy + - `hitLimit` (optional, min 1): max hits allowed for this task + +## Core workflow + +### 1) Pick the right breakpoint + +Prefer one of: + +- Immediately before the failing assertion/throw +- Immediately after the value-under-test is computed +- The boundary between parsing/normalization and assertion + +If a line has no emitted code (e.g. type-only, blank, comments), move to a nearby executable line. + +### 2) Start with small expressions + +Use 3-8 simple probes first: + +- `value`, `typeof value`, `value?.prop`, `Array.isArray(value)` +- `Object.keys(obj)` (or a limited slice) +- `JSON.stringify(value)` only if you expect it to be small + +Then iterate: add deeper probes only after you see the shape. + +### 3) Run once (no watch loop) + +- The CLI runs the plan, prints JSON on stdout, and forwards runner output to stderr. +- Iterate by editing the plan and rerunning. + +### 4) Interpret output and report back + +Read these fields: + +- `status`: `'full_succeed' | 'partial_succeed' | 'failed'` +- `results[]`: per task values (`expression` -> evaluated value) +- `errors[]`: why execution failed +- `meta.forwardedArgs`: the final runner invocation (useful to confirm forced single-worker/inspector flags) +- `meta.mappingDiagnostics[]`: why a breakpoint mapping failed (sourcemap issues, source mismatch, etc.) + +When replying to the user, include: + +- The breakpoint location you used +- Key evaluated values (and what they imply about the bug) +- If failed: the top `errors[]` and the most relevant `mappingDiagnostics[]` reason(s) + +## Troubleshooting + +### Plan validation fails + +- Ensure `tasks` is non-empty +- Ensure `runner.cmd`, `runner.args`, `runner.cwd` are present +- Ensure `line` is 1-based and `column` is 0-based +- Ensure `env` values are strings (no numbers/booleans) + +### Runner args error: include missing/multiple + +- The runner command MUST include exactly one `--include ` (or `--include=`) +- Remove any extra `--include` occurrences + +### Breakpoint not resolved + +Try: + +- A nearby executable line +- Confirm `sourcePath` is absolute and points to the original source under `runner.cwd` +- Confirm the project emits sourcemaps for the test run + +### Breakpoint resolved but never hit + +Try: + +- Move the breakpoint earlier in the flow +- Increase `hitLimit` if it triggers many times +- Add a `condition` to narrow to the relevant test case/input + +### Expression evaluation fails + +Try: + +- Evaluate the parent object first (`obj`, then `obj?.field`) +- Replace complex expressions with small safe probes +- Avoid mutating expressions; keep them read-only