diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..5571cdf --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,11 @@ +{ + "image": "mcr.microsoft.com/playwright:v1.58.2", + "forwardPorts": [6080], + "postCreateCommand": "npm install", + "features": { + "desktop-lite": { + "webPort": "6080" + }, + "git-lfs": "latest" + } +} \ No newline at end of file diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..0f0ac47 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,3 @@ +node_modules +playwright-report +test-results \ No newline at end of file diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..9e7fa74 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,16 @@ +{ + "parser": "@typescript-eslint/parser", + "parserOptions": { + "ecmaVersion": 2020, + "sourceType": "module" + }, + "plugins": ["@typescript-eslint"], + "extends": [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended", + "prettier" + ], + "rules": { + "@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }] + } +} diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml new file mode 100644 index 0000000..5255ff7 --- /dev/null +++ b/.github/workflows/playwright.yml @@ -0,0 +1,38 @@ +name: Playwright Tests +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] +jobs: + test: + timeout-minutes: 60 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: lts/* + cache: 'npm' + - name: Install dependencies + run: npm ci + - name: Install Playwright Browsers + run: npx playwright install --with-deps + - name: Run Playwright tests + run: npx playwright test + - name: Upload Playwright HTML Report + uses: actions/upload-artifact@v6 + if: ${{ !cancelled() }} + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 + - name: Append Reverse Gherkin Markdown Report to GitHub Summary + if: ${{ !cancelled() }} + run: | + if [ -f test-results/reverse-gherkin.md ]; then + echo "Appending Reverse Gherkin report to GitHub Summary..." + cat test-results/reverse-gherkin.md >> $GITHUB_STEP_SUMMARY + else + echo "⚠️ No Reverse Gherkin report found at test-results/reverse-gherkin.md" >> $GITHUB_STEP_SUMMARY + fi diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..335bd46 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ + +# Playwright +node_modules/ +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ +/playwright/.auth/ diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..ad38bc4 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,7 @@ +{ + "semi": true, + "tabWidth": 2, + "trailingComma": "es5", + "singleQuote": true, + "jsxSingleQuote": true +} diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..5a2b734 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,10 @@ +{ + "recommendations": [ + "github.copilot-chat", + "dbaeumer.vscode-eslint", + "ms-playwright.playwright", + "esbenp.prettier-vscode", + "github.vscode-github-actions" + ], + "unwantedRecommendations": [] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..5199390 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,12 @@ +{ + "editor.formatOnSave": true, + "editor.defaultFormatter": "esbenp.prettier-vscode", + "[liquid]": { + "editor.formatOnSave": false + }, + "editor.tabSize": 2, + "files.eol": "\n", + "files.trimTrailingWhitespace": true, + "js/ts.preferences.quoteStyle": "single", + "playwright.env": {} +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..02567aa --- /dev/null +++ b/package-lock.json @@ -0,0 +1,97 @@ +{ + "name": "playwright-codespaces", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "playwright-codespaces", + "version": "1.0.0", + "license": "MIT", + "devDependencies": { + "@playwright/test": "^1.58.2", + "@types/node": "^25.5.0" + } + }, + "node_modules/@playwright/test": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "25.5.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", + "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..6a79d72 --- /dev/null +++ b/package.json @@ -0,0 +1,24 @@ +{ + "name": "playwright-codespaces", + "version": "1.0.0", + "description": "A Playwright Codespaces Template", + "homepage": "https://github.com/alisterscott/playwright-codespaces#readme", + "bugs": { + "url": "https://github.com/alisterscott/playwright-codespaces/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/alisterscott/playwright-codespaces.git" + }, + "license": "MIT", + "author": "", + "type": "commonjs", + "main": "index.js", + "scripts": { + "test": "npx playwright test" + }, + "devDependencies": { + "@playwright/test": "^1.58.2", + "@types/node": "^25.5.0" + } +} diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..9c6302f --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,89 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// import dotenv from 'dotenv'; +// import path from 'path'; +// dotenv.config({ path: path.resolve(__dirname, '.env') }); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './specs', + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: [ + ['list', { printSteps: true }], + ['html'], + [ + './reverse-gherkin-reporter.ts', + { + outputFile: 'test-results/reverse-gherkin.md', + includeAnnotations: true, + }, + ], + ], + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('')`. */ + // baseURL: 'http://localhost:3000', + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, + + /* Test against mobile viewports. */ + // { + // name: 'Mobile Chrome', + // use: { ...devices['Pixel 5'] }, + // }, + // { + // name: 'Mobile Safari', + // use: { ...devices['iPhone 12'] }, + // }, + + /* Test against branded browsers. */ + // { + // name: 'Microsoft Edge', + // use: { ...devices['Desktop Edge'], channel: 'msedge' }, + // }, + // { + // name: 'Google Chrome', + // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, + // }, + ], + + /* Run your local dev server before starting the tests */ + // webServer: { + // command: 'npm run start', + // url: 'http://localhost:3000', + // reuseExistingServer: !process.env.CI, + // }, +}); diff --git a/reverse-gherkin-reporter.ts b/reverse-gherkin-reporter.ts new file mode 100644 index 0000000..aa74feb --- /dev/null +++ b/reverse-gherkin-reporter.ts @@ -0,0 +1,265 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import type { + Reporter, + Suite, + TestCase, + TestResult, + TestStep, +} from '@playwright/test/reporter'; + +type StepStatus = 'passed' | 'failed'; + +type CapturedStep = { + title: string; + status: StepStatus; +}; + +type CapturedAnnotation = { + type: string; + description?: string; +}; + +type CapturedTest = { + feature: string; + testTitle: string; + project: string; + status: TestResult['status']; + tags: string[]; + annotations: CapturedAnnotation[]; + steps: CapturedStep[]; +}; + +type ReverseGherkinReporterOptions = { + outputFile?: string; + includeAnnotations?: boolean; +}; + +class ReverseGherkinReporter implements Reporter { + private outputFile: string; + private includeAnnotations: boolean; + private testAttempts = new Map(); + private finalTests: CapturedTest[] = []; + private featureOrder: string[] = []; + private featureSet = new Set(); + + constructor(options: ReverseGherkinReporterOptions = {}) { + this.outputFile = + options.outputFile || path.join('test-results', 'reverse-gherkin.md'); + this.includeAnnotations = options.includeAnnotations ?? true; + } + + onTestBegin(test: TestCase, result: TestResult): void { + const attemptKey = this.getAttemptKey(test, result); + this.testAttempts.set(attemptKey, []); + } + + onStepEnd(test: TestCase, result: TestResult, step: TestStep): void { + // Capture only explicit user steps created with test.step(...) + if (step.category !== 'test.step') { + return; + } + + const attemptKey = this.getAttemptKey(test, result); + const steps = this.testAttempts.get(attemptKey) || []; + steps.push({ + title: step.title, + status: step.error ? 'failed' : 'passed', + }); + this.testAttempts.set(attemptKey, steps); + } + + onTestEnd(test: TestCase, result: TestResult): void { + const attemptKey = this.getAttemptKey(test, result); + const feature = this.getFeatureTitle(test); + const project = this.getProjectName(test); + const titleTags = this.getTitleTags(test.title); + const mergedTags = [...new Set([...test.tags, ...titleTags])]; + + if (!this.featureSet.has(feature)) { + this.featureSet.add(feature); + this.featureOrder.push(feature); + } + + this.finalTests.push({ + feature, + testTitle: this.getDisplayTestTitle(test.title), + project, + status: result.status, + tags: mergedTags, + annotations: test.annotations, + steps: this.testAttempts.get(attemptKey) || [], + }); + } + + async onEnd(): Promise { + const featureToTests = new Map(); + + for (const testData of this.finalTests) { + if (!featureToTests.has(testData.feature)) { + featureToTests.set(testData.feature, []); + } + featureToTests.get(testData.feature)?.push(testData); + } + + const lines: string[] = []; + lines.push('# Reverse Gherkin Test Results 🥒'); + lines.push(''); + + for (const feature of this.featureOrder) { + const tests = featureToTests.get(feature) || []; + if (tests.length === 0) { + continue; + } + + lines.push(`# ${feature}`); + lines.push(''); + + for (const testData of tests) { + const testEmoji = this.getTestEmoji(testData.status); + const testTitle = this.includeAnnotations + ? `## ${testData.testTitle} ${testEmoji}` + : `## ${testData.testTitle} ${testEmoji} \`${testData.project}\``; + lines.push(testTitle); + lines.push(''); + + if (this.includeAnnotations) { + this.appendMetadataTable(lines, testData); + lines.push(''); + } + + lines.push('```text'); + + if (testData.steps.length === 0) { + lines.push('(No test steps recorded)'); + } else { + for (const step of testData.steps) { + const stepEmoji = this.getStepEmoji(step.status); + lines.push(`${step.title} ${stepEmoji}`); + } + } + + lines.push('```'); + lines.push(''); + } + } + + const outputPath = path.resolve(this.outputFile); + fs.mkdirSync(path.dirname(outputPath), { recursive: true }); + fs.writeFileSync(outputPath, `${lines.join('\n')}\n`, 'utf8'); + } + + private appendMetadataTable(lines: string[], testData: CapturedTest): void { + const rows: Array<{ key: string; value: string }> = []; + rows.push({ key: 'Project', value: `\`${testData.project}\`` }); + + if (testData.tags.length > 0) { + rows.push({ + key: '**Tags**', + value: testData.tags.map((tag) => `\`${tag}\``).join(' '), + }); + } + + for (const annotation of testData.annotations) { + rows.push({ + key: `**${annotation.type}**`, + value: annotation.description || '', + }); + } + + if (rows.length === 0) { + return; + } + + lines.push(`| ${rows[0].key} | ${rows[0].value} |`); + lines.push('| --------------- | ----------------------- |'); + for (let i = 1; i < rows.length; i += 1) { + lines.push(`| ${rows[i].key} | ${rows[i].value} |`); + } + } + + private getProjectName(test: TestCase): string { + let current: Suite | undefined = test.parent; + while (current) { + const project = current.project(); + if (project) { + return project.name; + } + current = current.parent; + } + return 'unknown'; + } + + private getAttemptKey(test: TestCase, result: TestResult): string { + const project = this.getProjectName(test); + return `${project}::${test.id}#${result.retry}`; + } + + private getTitleTags(title: string): string[] { + const tags = title.match(/@[A-Za-z0-9:_-]+/g) || []; + return [...new Set(tags)]; + } + + private getDisplayTestTitle(title: string): string { + const titleTags = this.getTitleTags(title); + let cleanedTitle = title; + + for (const tag of titleTags) { + cleanedTitle = cleanedTitle.replace( + new RegExp(`(^|\\s)${this.escapeRegExp(tag)}(?=\\s|$)`, 'g'), + ' ' + ); + } + + cleanedTitle = cleanedTitle.replace(/\s+/g, ' ').trim(); + return cleanedTitle || title; + } + + private escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + } + + private getFeatureTitle(test: TestCase): string { + const describeTitles: string[] = []; + let current: Suite | undefined = test.parent; + + while (current) { + if (current.type === 'describe' && current.title) { + describeTitles.unshift(current.title); + } + current = current.parent; + } + + if (describeTitles.length === 0) { + return 'Unnamed Feature'; + } + + return describeTitles.join(' / '); + } + + private getTestEmoji(status: TestResult['status']): string { + if (status === 'passed') { + return '✅'; + } + + if ( + status === 'failed' || + status === 'timedOut' || + status === 'interrupted' + ) { + return '❌'; + } + + if (status === 'skipped') { + return '⏭️'; + } + + return '❔'; + } + + private getStepEmoji(status: StepStatus): string { + return status === 'passed' ? '✅' : '❌'; + } +} + +export default ReverseGherkinReporter; diff --git a/specs/example.spec.ts b/specs/example.spec.ts new file mode 100644 index 0000000..31b9bfa --- /dev/null +++ b/specs/example.spec.ts @@ -0,0 +1,45 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Playwright Homepage', () => { + const homePageURL = 'https://playwright.dev/'; + + test( + 'As a visitor to the homepage I can see the correct title in my browser', + { + tag: ['@smoke', '@regression'], + annotation: { type: 'homePageURL', description: homePageURL }, + }, + async ({ page }) => { + await test.step('Given I visit the Playwright homepage', async () => { + await page.goto(homePageURL); + }); + + await test.step('Then I should see the title of end page ends with "Playwright"', async () => { + await expect(page).toHaveTitle(/Playwright$/); + }); + } + ); + + test( + 'As a visitor to the homepage I can navigate to the get started page to see installation instructions @fast', + { + tag: '@smoke', + annotation: { type: 'homePageURL', description: homePageURL }, + }, + async ({ page }) => { + await test.step('Given I visit the Playwright homepage', async () => { + await page.goto(homePageURL); + }); + + await test.step('When I click the get started link', async () => { + await page.getByRole('link', { name: 'Get started' }).click(); + }); + + await test.step('Then I should see the "Installation" heading', async () => { + await expect( + page.getByRole('heading', { name: 'Installation', exact: true }) + ).toBeVisible(); + }); + } + ); +});