diff --git a/e2e/fixture.html b/e2e/fixture.html new file mode 100644 index 0000000..3f4b111 --- /dev/null +++ b/e2e/fixture.html @@ -0,0 +1,41 @@ + + + + + E2E Test Fixture + + + + + + + + + + + + + + + + + + + + diff --git a/e2e/widget.spec.ts b/e2e/widget.spec.ts new file mode 100644 index 0000000..b6b5c7c --- /dev/null +++ b/e2e/widget.spec.ts @@ -0,0 +1,249 @@ +import { test, expect, type Page } from '@playwright/test'; + +// --------------------------------------------------------------------------- +// Mock data — matches the structure the GitHub adapter expects +// --------------------------------------------------------------------------- + +const TEST_DID = 'did:key:z6MkiTBz1ymuepAQ4HEHYSF1H8quG5GLVVQR3djdX3mDooWp'; + +const IDENTITY_JSON = JSON.stringify({ controller_did: TEST_DID }); +const ATTESTATION_JSON = JSON.stringify({ + version: 1, + rid: 'test-rid', + issuer: TEST_DID, + subject: 'did:key:z6MkDev1Device', + iat: '2025-01-01T00:00:00Z', + signature: 'deadbeef', +}); + +const IDENTITY_B64 = btoa(IDENTITY_JSON); +const ATTESTATION_B64 = btoa(ATTESTATION_JSON); + +// --------------------------------------------------------------------------- +// Route handler: mocks the GitHub REST API for forge adapter +// --------------------------------------------------------------------------- + +async function mockGitHubAPI(page: Page) { + // Intercept all requests to api.github.com + await page.route('https://api.github.com/**', async (route) => { + const url = route.request().url(); + + // 1. List refs — GET /repos/{owner}/{repo}/git/matching-refs/auths/ + if (url.includes('test-org/test-repo/git/matching-refs/auths/')) { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { ref: 'refs/auths/identity', object: { sha: 'commit-id-1' } }, + { ref: 'refs/auths/keys/dev1/signatures', object: { sha: 'commit-att-1' } }, + ]), + }); + } + + // Empty repo — no refs + if (url.includes('test-org/empty-repo/git/matching-refs/auths/')) { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([]), + }); + } + + // 2. Get commit → tree SHA + if (url.includes('git/commits/commit-id-1')) { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ tree: { sha: 'tree-identity' } }), + }); + } + + if (url.includes('git/commits/commit-att-1')) { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ tree: { sha: 'tree-attestation' } }), + }); + } + + // 3. Get tree → blob entries + if (url.includes('git/trees/tree-identity')) { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + tree: [{ path: 'identity.json', sha: 'blob-identity' }], + }), + }); + } + + if (url.includes('git/trees/tree-attestation')) { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + tree: [{ path: 'attestation.json', sha: 'blob-attestation' }], + }), + }); + } + + // 4. Read blobs + if (url.includes('git/blobs/blob-identity')) { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ content: IDENTITY_B64, encoding: 'base64' }), + }); + } + + if (url.includes('git/blobs/blob-attestation')) { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ content: ATTESTATION_B64, encoding: 'base64' }), + }); + } + + // Fallback: 404 + return route.fulfill({ status: 404, body: 'Not found' }); + }); +} + +// --------------------------------------------------------------------------- +// Helper: wait for widget to reach a terminal state +// --------------------------------------------------------------------------- + +async function waitForState(page: Page, selector: string, timeout = 15_000) { + await page.waitForFunction( + ({ sel }) => { + const el = document.querySelector(sel); + if (!el) return false; + const state = el.getAttribute('data-state'); + return state && state !== 'idle' && state !== 'loading'; + }, + { sel: selector }, + { timeout }, + ); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +test.describe('auths-verify widget E2E', () => { + test.beforeEach(async ({ page }) => { + await mockGitHubAPI(page); + await page.goto('/e2e/fixture.html'); + }); + + test('badge mode: resolves identity from mocked GitHub API and reaches terminal state', async ({ page }) => { + await waitForState(page, '#badge-repo'); + + const state = await page.getAttribute('#badge-repo', 'data-state'); + // The widget fetched refs, read identity.json, attempted WASM verification. + // With fake crypto data the result is either 'verified', 'invalid', or 'error' + // — any of these proves the pipeline ran end-to-end. + expect(['verified', 'invalid', 'error']).toContain(state); + + // Verify shadow DOM rendered a label + const label = await page.evaluate(() => { + const el = document.querySelector('#badge-repo'); + return el?.shadowRoot?.querySelector('.label')?.textContent; + }); + expect(label).toBeTruthy(); + expect(label).not.toBe('Not verified'); // moved past idle + expect(label).not.toBe('Verifying...'); // moved past loading + }); + + test('detail mode: resolves and renders detail panel', async ({ page }) => { + await waitForState(page, '#detail-repo'); + + const state = await page.getAttribute('#detail-repo', 'data-state'); + expect(['verified', 'invalid', 'error']).toContain(state); + + // Detail panel should exist in shadow DOM + const hasDetailPanel = await page.evaluate(() => { + const el = document.querySelector('#detail-repo'); + return el?.shadowRoot?.querySelector('.detail-panel') !== null; + }); + expect(hasDetailPanel).toBe(true); + }); + + test('tooltip mode: resolves and renders tooltip panel', async ({ page }) => { + await waitForState(page, '#tooltip-repo'); + + const state = await page.getAttribute('#tooltip-repo', 'data-state'); + expect(['verified', 'invalid', 'error']).toContain(state); + + // Tooltip wrapper should exist + const hasTooltip = await page.evaluate(() => { + const el = document.querySelector('#tooltip-repo'); + return el?.shadowRoot?.querySelector('.tooltip-wrapper') !== null; + }); + expect(hasTooltip).toBe(true); + }); + + test('empty repo: shows error state when no auths refs exist', async ({ page }) => { + await waitForState(page, '#badge-empty'); + + const state = await page.getAttribute('#badge-empty', 'data-state'); + expect(state).toBe('error'); + + const label = await page.evaluate(() => { + const el = document.querySelector('#badge-empty'); + return el?.shadowRoot?.querySelector('.label')?.textContent; + }); + expect(label).toBe('Error'); + }); + + test('events: widget emits auths-verified or auths-error', async ({ page }) => { + // Collect events from the badge-repo widget + const events = await page.evaluate(() => { + return new Promise<{ type: string; detail: unknown }[]>((resolve) => { + const collected: { type: string; detail: unknown }[] = []; + const el = document.querySelector('#badge-repo'); + if (!el) return resolve([]); + + el.addEventListener('auths-verified', (e) => { + collected.push({ type: 'auths-verified', detail: (e as CustomEvent).detail }); + }); + el.addEventListener('auths-error', (e) => { + collected.push({ type: 'auths-error', detail: (e as CustomEvent).detail }); + }); + + // Wait for event (widget auto-verifies on connect) + setTimeout(() => resolve(collected), 10_000); + }); + }); + + expect(events.length).toBeGreaterThan(0); + expect(['auths-verified', 'auths-error']).toContain(events[0].type); + }); + + test('accessibility: badge has correct ARIA attributes', async ({ page }) => { + await waitForState(page, '#badge-repo'); + + const aria = await page.evaluate(() => { + const el = document.querySelector('#badge-repo'); + const badge = el?.shadowRoot?.querySelector('.badge'); + return { + role: badge?.getAttribute('role'), + ariaLive: badge?.getAttribute('aria-live'), + }; + }); + expect(aria.role).toBe('status'); + expect(aria.ariaLive).toBe('polite'); + }); + + test('accessibility: detail mode has aria-expanded', async ({ page }) => { + await waitForState(page, '#detail-repo'); + + const expanded = await page.evaluate(() => { + const el = document.querySelector('#detail-repo'); + const badge = el?.shadowRoot?.querySelector('.badge'); + return badge?.getAttribute('aria-expanded'); + }); + expect(expanded).toBe('false'); + }); + +}); diff --git a/package-lock.json b/package-lock.json index f613765..64ac8fa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "devDependencies": { "@auths/verifier": "file:../auths/packages/auths-verifier-ts", + "@playwright/test": "^1.58.2", "happy-dom": "^12.10.3", "typescript": "^5.3.2", "vite": "^5.4.0", @@ -441,6 +442,21 @@ "dev": true, "license": "MIT" }, + "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, + "dependencies": { + "playwright": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@rollup/plugin-virtual": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@rollup/plugin-virtual/-/plugin-virtual-3.0.2.tgz", @@ -1682,6 +1698,50 @@ "dev": true, "license": "MIT" }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "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, + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/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, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", diff --git a/package.json b/package.json index 252ea22..4edbb67 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "build:wasm": "cd ../auths/crates/auths-verifier && wasm-pack build --target bundler --no-default-features --features wasm && rm -rf ../../../auths-verify-widget/wasm && mv pkg ../../../auths-verify-widget/wasm", "typecheck": "tsc --noEmit", "test": "vitest run", + "test:e2e": "playwright test", "test:watch": "vitest", "prepublishOnly": "npm run build:wasm && npm run test && npm run build" }, @@ -55,6 +56,7 @@ }, "devDependencies": { "@auths/verifier": "file:../auths/packages/auths-verifier-ts", + "@playwright/test": "^1.58.2", "happy-dom": "^12.10.3", "typescript": "^5.3.2", "vite": "^5.4.0", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..bdad732 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + timeout: 30_000, + use: { + baseURL: 'http://localhost:4174', + }, + webServer: { + command: 'python3 -m http.server 4174', + port: 4174, + reuseExistingServer: !process.env.CI, + }, + projects: [ + { name: 'chromium', use: { browserName: 'chromium' } }, + ], +});