diff --git a/.github/workflows/testdriver.yml b/.github/workflows/testdriver.yml new file mode 100644 index 0000000..236f299 --- /dev/null +++ b/.github/workflows/testdriver.yml @@ -0,0 +1,46 @@ +name: TestDriver.ai Tests + +on: + push: + branches: [ main, master ] + pull_request: + branches: [ main, master ] + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + permissions: + id-token: write # required to mint the OIDC token for auth + contents: read + + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + # Authenticate to TestDriver via GitHub OIDC (no stored secret needed once the + # TestDriver GitHub App is authorized for your org). Falls back to a TD_API_KEY + # secret if you set one. Exports TD_API_KEY for the test step below. + - name: Authenticate to TestDriver + uses: testdriverai/action@stable + with: + api-key: ${{ secrets.TD_API_KEY }} + + - name: Run TestDriver.ai tests + run: npx vitest run + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results + path: test-results/ + retention-days: 30 diff --git a/.gitignore b/.gitignore index 28fec86..04a171c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ node_modules/ .env tmp/ +.testdriver/ +test-results/ .claude/settings.local.json .claude/worktrees/ .claude/scheduled_tasks.lock diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..f93150f --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,5 @@ +{ + "recommendations": [ + "vitest.explorer" + ] +} diff --git a/tests/birdsbirdbirds.test.js b/tests/birdsbirdbirds.test.js new file mode 100644 index 0000000..de4d3d8 --- /dev/null +++ b/tests/birdsbirdbirds.test.js @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { TestDriver } from "testdriverai/vitest/hooks"; + +describe("BirdsBirdBirds Challenge", () => { + it("allows selecting drawing tools and saving a bird", async (context) => { + const testdriver = TestDriver(context); + + await testdriver.provision.chrome({ + url: process.env.APP_URL || "https://arcade-quarter-horn-boxing.trycloudflare.com", + }); + + const assertHeader = await testdriver.assert( + "the Draw your bird header and drawing tools are visible" + ); + expect(assertHeader).toBeTruthy(); + + const saveButton = await testdriver.find("Save my bird button"); + await saveButton.click(); + + await testdriver.scroll("down", { amount: 200 }); + + const assertEmpty = await testdriver.assert( + 'the message "The canvas is empty. Draw your bird first!" is visible' + ); + expect(assertEmpty).toBeTruthy(); + + await testdriver.scroll("up", { amount: 200 }); + + const eraserButton = await testdriver.find("Eraser button"); + await eraserButton.click(); + + const assertEraser = await testdriver.assert("the Eraser tool is selected"); + expect(assertEraser).toBeTruthy(); + + const penButton = await testdriver.find("Pen button"); + await penButton.click(); + + const assertPen = await testdriver.assert("the Pen tool is selected"); + expect(assertPen).toBeTruthy(); + }); +}); diff --git a/tools/serve-birds.mjs b/tools/serve-birds.mjs new file mode 100644 index 0000000..9fc7652 --- /dev/null +++ b/tools/serve-birds.mjs @@ -0,0 +1,53 @@ +// Serves the birdsbirdbirds challenge page on the requested port with Host header forwarding. +import { spawn } from 'node:child_process' +import fs from 'node:fs' +import http from 'node:http' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const setupHook = path.join(repoRoot, 'challenges', 'birdsbirdbirds', 'setup') +const cleanupHook = path.join(repoRoot, 'challenges', 'birdsbirdbirds', 'cleanup') + +const port = Number(process.env.PORT || 3000) +const internalPort = 4322 +const runDir = path.join('/tmp', `birds-serve-${Date.now()}`) + +fs.mkdirSync(runDir, { recursive: true }) + +const setup = spawn(setupHook, [], { cwd: runDir, stdio: 'inherit' }) +setup.on('exit', (code) => { + if (code !== 0) { + console.error(`setup failed with code ${code}`) + process.exit(code ?? 1) + } + + const proxy = http.createServer((req, res) => { + const options = { + hostname: '127.0.0.1', + port: internalPort, + path: req.url, + method: req.method, + headers: { ...req.headers, host: `127.0.0.1:${internalPort}` }, + } + const proxyReq = http.request(options, (proxyRes) => { + res.writeHead(proxyRes.statusCode, proxyRes.headers) + proxyRes.pipe(res, { end: true }) + }) + req.pipe(proxyReq, { end: true }) + }) + + proxy.listen(port, '0.0.0.0', () => { + console.log(`BirdsBirdBirds server listening on port ${port}`) + }) + + function cleanup() { + try { + spawn(cleanupHook, [], { cwd: runDir, stdio: 'ignore' }) + } catch {} + process.exit(0) + } + + process.on('SIGTERM', cleanup) + process.on('SIGINT', cleanup) +}) diff --git a/vitest.config.js b/vitest.config.js new file mode 100644 index 0000000..01e1032 --- /dev/null +++ b/vitest.config.js @@ -0,0 +1,16 @@ +import { defineConfig } from 'vitest/config'; +import TestDriver from 'testdriverai/vitest'; + +// Note: dotenv is loaded automatically by the TestDriver SDK +export default defineConfig({ + test: { + include: ['tests/**/*.test.{js,mjs,ts}'], + testTimeout: 300000, + hookTimeout: 300000, + reporters: [ + 'default', + TestDriver(), + ], + setupFiles: ['testdriverai/vitest/setup'], + }, +});