From 55ce8680ad1ab2fb83164061e861103051945cc3 Mon Sep 17 00:00:00 2001 From: John Stallo Date: Fri, 29 Aug 2025 14:38:50 +0800 Subject: [PATCH 1/7] Add status icons for test results --- index.html | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/index.html b/index.html index 0479c34..e4c2add 100644 --- a/index.html +++ b/index.html @@ -91,6 +91,12 @@ .chip.warn{background:var(--warn-bg);border-color:var(--warn-bd);color:var(--warn-fg)} .chip.mono{font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace} + /* Status icon placed left of each title */ + .statusIcon{display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px;border-radius:6px;font-size:12px;line-height:1;border:1px solid var(--chip-bd);color:var(--muted);background:transparent} + .statusIcon.ok{color:var(--ok-fg);border-color:var(--ok-bd);background:linear-gradient(transparent, rgba(46,160,67,0.04))} + .statusIcon.err{color:var(--err-fg);border-color:var(--err-bd);background:linear-gradient(transparent, rgba(248,81,73,0.04))} + .statusIcon.warn{color:var(--warn-fg);border-color:var(--warn-bd);background:linear-gradient(transparent, rgba(210,153,34,0.04))} + .counts{color:var(--muted); font-size:.9em; margin-top:4px} @@ -232,6 +238,15 @@

Playwright Reports

const stats = await fetchStatsFor(d); const statusCls = classifyStatus(stats, d.outcome); + // Decide a small icon for the row: tick for pass, X for failed, warning for flaky + const icon = statusCls === 'ok' + ? `` + : statusCls === 'err' + ? `` + : statusCls === 'warn' + ? `` + : `?`; + // Filtering if (branch && d.branch !== branch) return null; if (statusFilter && statusCls !== statusFilter) return null; @@ -273,6 +288,7 @@

Playwright Reports

  • + ${icon} ${titleText} ${commitHref && hashText ? `${hashText}` : ''} ${when} From 387996ffa19512e418c958d7b7da18c2045a37a1 Mon Sep 17 00:00:00 2001 From: John Stallo Date: Fri, 29 Aug 2025 15:01:59 +0800 Subject: [PATCH 2/7] Enhance timestamp formatting in reports and truncate title display --- index.html | 42 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 34 insertions(+), 8 deletions(-) diff --git a/index.html b/index.html index e4c2add..d3eeff5 100644 --- a/index.html +++ b/index.html @@ -226,8 +226,27 @@

    Playwright Reports

    } function safeUTC(ts){ - const d = ts ? new Date(ts) : null; - return (d && !isNaN(d)) ? d.toUTCString() : '—'; + if (!ts) return '—'; + const d = (ts instanceof Date) ? ts : new Date(ts); + if (!d || isNaN(d)) return '—'; + const now = new Date(); + const diff = Math.floor((now - d) / 1000); // seconds + if (diff < 5) return 'now'; + if (diff < 60) return diff === 1 ? '1 second ago' : `${diff} seconds ago`; + const mins = Math.floor(diff / 60); + if (mins < 60) return mins === 1 ? '1 minute ago' : `${mins} minutes ago`; + const hours = Math.floor(mins / 60); + if (hours < 24) return hours === 1 ? '1 hour ago' : `${hours} hours ago`; + const days = Math.floor(hours / 24); + if (days === 1) return 'yesterday'; + if (days < 7) return `${days} days ago`; + if (days < 30) { + const weeks = Math.floor(days / 7); + return `${weeks} week${weeks === 1 ? '' : 's'} ago`; + } + const months = Math.floor(days / 30); + if (months < 12) return `${months} month${months === 1 ? '' : 's'} ago`; + return d.toUTCString(); } async function render(){ @@ -253,6 +272,12 @@

    Playwright Reports

    // Title + links from new feed fields const titleText = d.title || (d.repo ? `${d.repo}@${d.shortSha||''}` : 'Report'); + // Truncate display title to at most 20 characters (including ellipsis) + const MAX_TITLE_LEN = 72; + let displayTitle = (typeof titleText === 'string') ? titleText : String(titleText || ''); + if (displayTitle.length > MAX_TITLE_LEN) { + displayTitle = displayTitle.slice(0, MAX_TITLE_LEN - 1).trimEnd() + '…'; + } const reportHref = d.reportUrl || d.url || '#'; // Commit link (build if not provided) @@ -262,6 +287,11 @@

    Playwright Reports

    const ts = d.updated || d.date || ''; const when = safeUTC(ts); + let fullTs = '—'; + if (ts) { + const maybe = new Date(ts); + if (!isNaN(maybe)) fullTs = maybe.toUTCString(); + } // Search text const text = [ @@ -289,16 +319,12 @@

    Playwright Reports

    ${icon} - ${titleText} + ${displayTitle} ${commitHref && hashText ? `${hashText}` : ''} - ${when} + ${when}
    ${badges}
    -
    - ${d.subtitle ? `${d.subtitle}` : `Open report`} - ${counts ? counts : ''} -
  • `; })); From 9323b8213c386318a68a9f7a1139ed864b8270a4 Mon Sep 17 00:00:00 2001 From: John Stallo Date: Fri, 29 Aug 2025 16:19:40 +0800 Subject: [PATCH 3/7] Add Azure login step --- .github/workflows/playwright-workspaces.yml | 9 ++- playwright.config.ts | 69 ++------------------- playwright.service.config.ts | 6 +- 3 files changed, 15 insertions(+), 69 deletions(-) diff --git a/.github/workflows/playwright-workspaces.yml b/.github/workflows/playwright-workspaces.yml index 45087c3..74f176b 100644 --- a/.github/workflows/playwright-workspaces.yml +++ b/.github/workflows/playwright-workspaces.yml @@ -24,6 +24,14 @@ jobs: with: node-version: lts/* + - name: Login to Azure (GitHub OIDC / Microsoft Entra) + uses: azure/login@v2 + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + enable-AzPSSession: true + - name: Install dependencies run: npm ci @@ -31,7 +39,6 @@ jobs: id: pw env: PLAYWRIGHT_SERVICE_URL: ${{ secrets.PLAYWRIGHT_SERVICE_URL }} - PLAYWRIGHT_SERVICE_ACCESS_TOKEN: ${{ secrets.PLAYWRIGHT_SERVICE_ACCESS_TOKEN }} NUMBER_OF_TESTS: ${{ secrets.NUMBER_OF_TESTS }} run: npx playwright test -c playwright.service.config.ts --workers=20 diff --git a/playwright.config.ts b/playwright.config.ts index b1826af..541a543 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,85 +1,28 @@ 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: './tests', - /* 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: [ - ['list'], // any console reporter you like - ['blob', { outputDir: 'blob-report' }],// raw results (robust on failures) - // Optional: also write HTML during the run (nice for local) + ['blob', { outputDir: 'blob-report' }], // keep this reporter for CI // ['html', { open: 'never', outputFolder: 'playwright-report' }], ], - /* 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', video: 'retain-on-failure', screenshot: 'on' }, - - /* 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' }, - // }, + { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, + { name: 'firefox', use: { ...devices['Desktop Firefox'] } }, + { name: 'webkit', use: { ...devices['Desktop Safari'] } }, + { name: 'Mobile Chrome', use: { ...devices['Pixel 5'] } }, ], - - /* 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/playwright.service.config.ts b/playwright.service.config.ts index 3524151..389b4a1 100644 --- a/playwright.service.config.ts +++ b/playwright.service.config.ts @@ -3,15 +3,11 @@ import { getServiceConfig, ServiceOS, ServiceAuth } from '@azure/playwright'; import { DefaultAzureCredential } from '@azure/identity'; import config from './playwright.config'; -/* Learn more about service configuration at https://aka.ms/pww/docs/config */ -// generateGuid removed; use Node's crypto.randomUUID() - export default defineConfig( config, getServiceConfig(config, { timeout: 3 * 60 * 1000, // 3 minutes os: ServiceOS.LINUX, - // credential: new DefaultAzureCredential(), - serviceAuthType: ServiceAuth.ACCESS_TOKEN, + credential: new DefaultAzureCredential(), }) ); From 9d0905b14570297441232b0e90bb3659befd175a Mon Sep 17 00:00:00 2001 From: John Stallo Date: Fri, 29 Aug 2025 16:20:08 +0800 Subject: [PATCH 4/7] Add todo app tests --- tests/example.spec.ts | 442 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 429 insertions(+), 13 deletions(-) diff --git a/tests/example.spec.ts b/tests/example.spec.ts index 0285025..8641cb5 100644 --- a/tests/example.spec.ts +++ b/tests/example.spec.ts @@ -1,21 +1,437 @@ -import { test, expect } from '@playwright/test'; +import { test, expect, type Page } from '@playwright/test'; -test('has title', async ({ page }) => { - await page.goto('https://playwright.dev/'); +test.beforeEach(async ({ page }) => { + await page.goto('https://demo.playwright.dev/todomvc'); +}); + +const TODO_ITEMS = [ + 'buy some cheese', + 'feed the cat', + 'book a doctors appointment' +] as const; + +test.describe('New Todo', () => { + test('should allow me to add todo items', async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + // Create 1st todo. + await newTodo.fill(TODO_ITEMS[0]); + await newTodo.press('Enter'); + + // Make sure the list only has one todo item. + await expect(page.getByTestId('todo-title')).toHaveText([ + TODO_ITEMS[0] + ]); + + // Create 2nd todo. + await newTodo.fill(TODO_ITEMS[1]); + await newTodo.press('Enter'); + + // Make sure the list now has two todo items. + await expect(page.getByTestId('todo-title')).toHaveText([ + TODO_ITEMS[0], + TODO_ITEMS[1] + ]); + + await checkNumberOfTodosInLocalStorage(page, 2); + }); + + test('should clear text input field when an item is added', async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + // Create one todo item. + await newTodo.fill(TODO_ITEMS[0]); + await newTodo.press('Enter'); + + // Check that input is empty. + await expect(newTodo).toBeEmpty(); + await checkNumberOfTodosInLocalStorage(page, 1); + }); + + test('should append new items to the bottom of the list', async ({ page }) => { + // Create 3 items. + await createDefaultTodos(page); + + // create a todo count locator + const todoCount = page.getByTestId('todo-count') + + // Check test using different methods. + await expect(page.getByText('3 items left')).toBeVisible(); + await expect(todoCount).toHaveText('3 items left'); + await expect(todoCount).toContainText('3'); + await expect(todoCount).toHaveText(/3/); + + // Check all items in one call. + await expect(page.getByTestId('todo-title')).toHaveText(TODO_ITEMS); + await checkNumberOfTodosInLocalStorage(page, 3); + }); +}); + +test.describe('Mark all as completed', () => { + test.beforeEach(async ({ page }) => { + await createDefaultTodos(page); + await checkNumberOfTodosInLocalStorage(page, 3); + }); + + test.afterEach(async ({ page }) => { + await checkNumberOfTodosInLocalStorage(page, 3); + }); + + test('should allow me to mark all items as completed', async ({ page }) => { + // Complete all todos. + await page.getByLabel('Mark all as complete').check(); + + // Ensure all todos have 'completed' class. + await expect(page.getByTestId('todo-item')).toHaveClass(['completed', 'completed', 'completed']); + await checkNumberOfCompletedTodosInLocalStorage(page, 3); + }); + + test('should allow me to clear the complete state of all items', async ({ page }) => { + const toggleAll = page.getByLabel('Mark all as complete'); + // Check and then immediately uncheck. + await toggleAll.check(); + await toggleAll.uncheck(); + + // Should be no completed classes. + await expect(page.getByTestId('todo-item')).toHaveClass(['', '', '']); + }); + + test('complete all checkbox should update state when items are completed / cleared', async ({ page }) => { + const toggleAll = page.getByLabel('Mark all as complete'); + await toggleAll.check(); + await expect(toggleAll).toBeChecked(); + await checkNumberOfCompletedTodosInLocalStorage(page, 3); - // Expect a title "to contain" a substring. - await expect(page).toHaveTitle(/Playwright/); + // Uncheck first todo. + const firstTodo = page.getByTestId('todo-item').nth(0); + await firstTodo.getByRole('checkbox').uncheck(); + + // Reuse toggleAll locator and make sure its not checked. + await expect(toggleAll).not.toBeChecked(); + + await firstTodo.getByRole('checkbox').check(); + await checkNumberOfCompletedTodosInLocalStorage(page, 3); + + // Assert the toggle all is checked again. + await expect(toggleAll).toBeChecked(); + }); }); -const numberOfTests = parseInt(process.env.NUMBER_OF_TESTS || '0', 10); -for (var i = 0; i < numberOfTests; i++) { - test('get started link ' + (i+1).toString(), async ({ page }) => { - await page.goto('https://playwright.dev/'); +test.describe('Item', () => { - // Click the get started link. - await page.getByRole('link', { name: 'Get started' }).click(); + test('should allow me to mark items as complete', async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); - // Expects page to have a heading with the name of Installation. - await expect(page.getByRole('heading', { name: 'Installation' })).toBeVisible(); + // Create two items. + for (const item of TODO_ITEMS.slice(0, 2)) { + await newTodo.fill(item); + await newTodo.press('Enter'); + } + + // Check first item. + const firstTodo = page.getByTestId('todo-item').nth(0); + await firstTodo.getByRole('checkbox').check(); + await expect(firstTodo).toHaveClass('completed'); + + // Check second item. + const secondTodo = page.getByTestId('todo-item').nth(1); + await expect(secondTodo).not.toHaveClass('completed'); + await secondTodo.getByRole('checkbox').check(); + + // Assert completed class. + await expect(firstTodo).toHaveClass('completed'); + await expect(secondTodo).toHaveClass('completed'); }); + + test('should allow me to un-mark items as complete', async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + // Create two items. + for (const item of TODO_ITEMS.slice(0, 2)) { + await newTodo.fill(item); + await newTodo.press('Enter'); + } + + const firstTodo = page.getByTestId('todo-item').nth(0); + const secondTodo = page.getByTestId('todo-item').nth(1); + const firstTodoCheckbox = firstTodo.getByRole('checkbox'); + + await firstTodoCheckbox.check(); + await expect(firstTodo).toHaveClass('completed'); + await expect(secondTodo).not.toHaveClass('completed'); + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + + await firstTodoCheckbox.uncheck(); + await expect(firstTodo).not.toHaveClass('completed'); + await expect(secondTodo).not.toHaveClass('completed'); + await checkNumberOfCompletedTodosInLocalStorage(page, 0); + }); + + test('should allow me to edit an item', async ({ page }) => { + await createDefaultTodos(page); + + const todoItems = page.getByTestId('todo-item'); + const secondTodo = todoItems.nth(1); + await secondTodo.dblclick(); + await expect(secondTodo.getByRole('textbox', { name: 'Edit' })).toHaveValue(TODO_ITEMS[1]); + await secondTodo.getByRole('textbox', { name: 'Edit' }).fill('buy some sausages'); + await secondTodo.getByRole('textbox', { name: 'Edit' }).press('Enter'); + + // Explicitly assert the new text value. + await expect(todoItems).toHaveText([ + TODO_ITEMS[0], + 'buy some sausages', + TODO_ITEMS[2] + ]); + await checkTodosInLocalStorage(page, 'buy some sausages'); + }); +}); + +test.describe('Editing', () => { + test.beforeEach(async ({ page }) => { + await createDefaultTodos(page); + await checkNumberOfTodosInLocalStorage(page, 3); + }); + + test('should hide other controls when editing', async ({ page }) => { + const todoItem = page.getByTestId('todo-item').nth(1); + await todoItem.dblclick(); + await expect(todoItem.getByRole('checkbox')).not.toBeVisible(); + await expect(todoItem.locator('label', { + hasText: TODO_ITEMS[1], + })).not.toBeVisible(); + await checkNumberOfTodosInLocalStorage(page, 3); + }); + + test('should save edits on blur', async ({ page }) => { + const todoItems = page.getByTestId('todo-item'); + await todoItems.nth(1).dblclick(); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill('buy some sausages'); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).dispatchEvent('blur'); + + await expect(todoItems).toHaveText([ + TODO_ITEMS[0], + 'buy some sausages', + TODO_ITEMS[2], + ]); + await checkTodosInLocalStorage(page, 'buy some sausages'); + }); + + test('should trim entered text', async ({ page }) => { + const todoItems = page.getByTestId('todo-item'); + await todoItems.nth(1).dblclick(); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill(' buy some sausages '); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).press('Enter'); + + await expect(todoItems).toHaveText([ + TODO_ITEMS[0], + 'buy some sausages', + TODO_ITEMS[2], + ]); + await checkTodosInLocalStorage(page, 'buy some sausages'); + }); + + test('should remove the item if an empty text string was entered', async ({ page }) => { + const todoItems = page.getByTestId('todo-item'); + await todoItems.nth(1).dblclick(); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill(''); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).press('Enter'); + + await expect(todoItems).toHaveText([ + TODO_ITEMS[0], + TODO_ITEMS[2], + ]); + }); + + test('should cancel edits on escape', async ({ page }) => { + const todoItems = page.getByTestId('todo-item'); + await todoItems.nth(1).dblclick(); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill('buy some sausages'); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).press('Escape'); + await expect(todoItems).toHaveText(TODO_ITEMS); + }); +}); + +test.describe('Counter', () => { + test('should display the current number of todo items', async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + // create a todo count locator + const todoCount = page.getByTestId('todo-count') + + await newTodo.fill(TODO_ITEMS[0]); + await newTodo.press('Enter'); + + await expect(todoCount).toContainText('1'); + + await newTodo.fill(TODO_ITEMS[1]); + await newTodo.press('Enter'); + await expect(todoCount).toContainText('2'); + + await checkNumberOfTodosInLocalStorage(page, 2); + }); +}); + +test.describe('Clear completed button', () => { + test.beforeEach(async ({ page }) => { + await createDefaultTodos(page); + }); + + test('should display the correct text', async ({ page }) => { + await page.locator('.todo-list li .toggle').first().check(); + await expect(page.getByRole('button', { name: 'Clear completed' })).toBeVisible(); + }); + + test('should remove completed items when clicked', async ({ page }) => { + const todoItems = page.getByTestId('todo-item'); + await todoItems.nth(1).getByRole('checkbox').check(); + await page.getByRole('button', { name: 'Clear completed' }).click(); + await expect(todoItems).toHaveCount(2); + await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[2]]); + }); + + test('should be hidden when there are no items that are completed', async ({ page }) => { + await page.locator('.todo-list li .toggle').first().check(); + await page.getByRole('button', { name: 'Clear completed' }).click(); + await expect(page.getByRole('button', { name: 'Clear completed' })).toBeHidden(); + }); +}); + +test.describe('Persistence', () => { + test('should persist its data', async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + for (const item of TODO_ITEMS.slice(0, 2)) { + await newTodo.fill(item); + await newTodo.press('Enter'); + } + + const todoItems = page.getByTestId('todo-item'); + const firstTodoCheck = todoItems.nth(0).getByRole('checkbox'); + await firstTodoCheck.check(); + await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[1]]); + await expect(firstTodoCheck).toBeChecked(); + await expect(todoItems).toHaveClass(['completed', '']); + + // Ensure there is 1 completed item. + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + + // Now reload. + await page.reload(); + await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[1]]); + await expect(firstTodoCheck).toBeChecked(); + await expect(todoItems).toHaveClass(['completed', '']); + }); +}); + +test.describe('Routing', () => { + test.beforeEach(async ({ page }) => { + await createDefaultTodos(page); + // make sure the app had a chance to save updated todos in storage + // before navigating to a new view, otherwise the items can get lost :( + // in some frameworks like Durandal + await checkTodosInLocalStorage(page, TODO_ITEMS[0]); + }); + + test('should allow me to display active items', async ({ page }) => { + const todoItem = page.getByTestId('todo-item'); + await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check(); + + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + await page.getByRole('link', { name: 'Active' }).click(); + await expect(todoItem).toHaveCount(2); + await expect(todoItem).toHaveText([TODO_ITEMS[0], TODO_ITEMS[2]]); + }); + + test('should respect the back button', async ({ page }) => { + const todoItem = page.getByTestId('todo-item'); + await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check(); + + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + + await test.step('Showing all items', async () => { + await page.getByRole('link', { name: 'All' }).click(); + await expect(todoItem).toHaveCount(3); + }); + + await test.step('Showing active items', async () => { + await page.getByRole('link', { name: 'Active' }).click(); + }); + + await test.step('Showing completed items', async () => { + await page.getByRole('link', { name: 'Completed' }).click(); + }); + + await expect(todoItem).toHaveCount(1); + await page.goBack(); + await expect(todoItem).toHaveCount(2); + await page.goBack(); + await expect(todoItem).toHaveCount(3); + }); + + test('should allow me to display completed items', async ({ page }) => { + await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check(); + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + await page.getByRole('link', { name: 'Completed' }).click(); + await expect(page.getByTestId('todo-item')).toHaveCount(1); + }); + + test('should allow me to display all items', async ({ page }) => { + await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check(); + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + await page.getByRole('link', { name: 'Active' }).click(); + await page.getByRole('link', { name: 'Completed' }).click(); + await page.getByRole('link', { name: 'All' }).click(); + await expect(page.getByTestId('todo-item')).toHaveCount(3); + }); + + test('should highlight the currently applied filter', async ({ page }) => { + await expect(page.getByRole('link', { name: 'All' })).toHaveClass('selected'); + + //create locators for active and completed links + const activeLink = page.getByRole('link', { name: 'Active' }); + const completedLink = page.getByRole('link', { name: 'Completed' }); + await activeLink.click(); + + // Page change - active items. + await expect(activeLink).toHaveClass('selected'); + await completedLink.click(); + + // Page change - completed items. + await expect(completedLink).toHaveClass('selected'); + }); +}); + +async function createDefaultTodos(page: Page) { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + for (const item of TODO_ITEMS) { + await newTodo.fill(item); + await newTodo.press('Enter'); + } +} + +async function checkNumberOfTodosInLocalStorage(page: Page, expected: number) { + return await page.waitForFunction(e => { + return JSON.parse(localStorage['react-todos']).length === e; + }, expected); +} + +async function checkNumberOfCompletedTodosInLocalStorage(page: Page, expected: number) { + return await page.waitForFunction(e => { + return JSON.parse(localStorage['react-todos']).filter((todo: any) => todo.completed).length === e; + }, expected); +} + +async function checkTodosInLocalStorage(page: Page, title: string) { + return await page.waitForFunction(t => { + return JSON.parse(localStorage['react-todos']).map((todo: any) => todo.title).includes(t); + }, title); } From 078092a5ec3723935a801ae60984e620295cbe6a Mon Sep 17 00:00:00 2001 From: John Stallo Date: Fri, 29 Aug 2025 16:25:29 +0800 Subject: [PATCH 5/7] Update Playwright service configuration to use access token authentication --- .github/workflows/playwright-workspaces.yml | 9 +-------- playwright.service.config.ts | 3 ++- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/.github/workflows/playwright-workspaces.yml b/.github/workflows/playwright-workspaces.yml index 74f176b..45087c3 100644 --- a/.github/workflows/playwright-workspaces.yml +++ b/.github/workflows/playwright-workspaces.yml @@ -24,14 +24,6 @@ jobs: with: node-version: lts/* - - name: Login to Azure (GitHub OIDC / Microsoft Entra) - uses: azure/login@v2 - with: - client-id: ${{ secrets.AZURE_CLIENT_ID }} - tenant-id: ${{ secrets.AZURE_TENANT_ID }} - subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - enable-AzPSSession: true - - name: Install dependencies run: npm ci @@ -39,6 +31,7 @@ jobs: id: pw env: PLAYWRIGHT_SERVICE_URL: ${{ secrets.PLAYWRIGHT_SERVICE_URL }} + PLAYWRIGHT_SERVICE_ACCESS_TOKEN: ${{ secrets.PLAYWRIGHT_SERVICE_ACCESS_TOKEN }} NUMBER_OF_TESTS: ${{ secrets.NUMBER_OF_TESTS }} run: npx playwright test -c playwright.service.config.ts --workers=20 diff --git a/playwright.service.config.ts b/playwright.service.config.ts index 389b4a1..ea7aee5 100644 --- a/playwright.service.config.ts +++ b/playwright.service.config.ts @@ -8,6 +8,7 @@ export default defineConfig( getServiceConfig(config, { timeout: 3 * 60 * 1000, // 3 minutes os: ServiceOS.LINUX, - credential: new DefaultAzureCredential(), + // credential: new DefaultAzureCredential(), + serviceAuthType: ServiceAuth.ACCESS_TOKEN, }) ); From 178dfd215875cbe944b0ed07a04a4a3949702e88 Mon Sep 17 00:00:00 2001 From: John Stallo Date: Fri, 29 Aug 2025 16:33:29 +0800 Subject: [PATCH 6/7] Set default branch to 'main' --- index.html | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/index.html b/index.html index d3eeff5..0eb6c35 100644 --- a/index.html +++ b/index.html @@ -142,6 +142,8 @@

    Playwright Reports

    const branches = [...new Set(items.map(i => i.branch).filter(Boolean))].sort(); const branchSel = document.getElementById('branchFilter'); branches.forEach(b => { const o=document.createElement('option'); o.value=b; o.textContent=b; branchSel.appendChild(o); }); + // Default to the 'main' branch when available + if (branches.includes('main')) branchSel.value = 'main'; const searchBox = document.getElementById('searchBox'); const ul = document.getElementById('list'); @@ -272,7 +274,7 @@

    Playwright Reports

    // Title + links from new feed fields const titleText = d.title || (d.repo ? `${d.repo}@${d.shortSha||''}` : 'Report'); - // Truncate display title to at most 20 characters (including ellipsis) + // Truncate display title to at most 72 characters (including ellipsis) const MAX_TITLE_LEN = 72; let displayTitle = (typeof titleText === 'string') ? titleText : String(titleText || ''); if (displayTitle.length > MAX_TITLE_LEN) { From e5601c04608af52746cc80834389956b01329334 Mon Sep 17 00:00:00 2001 From: John Stallo Date: Fri, 29 Aug 2025 16:35:04 +0800 Subject: [PATCH 7/7] Add README --- README.md | 160 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..7dbb496 --- /dev/null +++ b/README.md @@ -0,0 +1,160 @@ +# Playwright Workspaces sample (GitHub CI + Azure static website) + +This repository demonstrates running Playwright tests in GitHub Actions using Playwright Workspaces, publishing HTML reports to an Azure Storage static website, and maintaining a small JSON feed that the lightweight `index.html` viewer consumes. + +At a high level: + +- GitHub Actions (see `.github/workflows/playwright-workspaces.yml`) runs Playwright tests (configured in `playwright.service.config.ts`). +- The workflow assembles an HTML report (Playwright report), creates a `meta.json` describing the run, uploads the report to an Azure Storage static website (`$web`), and merges a new feed entry into `feed.json`. +- `index.html` in this repo is a viewer that reads `feed.json` and displays available test run reports (it expects `meta.json` and related report content to be present under the run folder on the static website). + +This README documents how the pieces fit together and how to configure Azure and GitHub Secrets to run the sample. + +--- + +## Files of interest + +- `.github/workflows/playwright-workspaces.yml` — the GitHub Actions pipeline that runs tests and uploads the HTML report and `meta.json` to Azure. It also updates `feed.json`. +- `playwright.service.config.ts` — Playwright configuration used by the sample tests (the repo includes a Playwright test in `tests/`). +- `index.html` — static viewer that loads `feed.json` and renders the list of reports (and pulls `data/report.json` from each report URL to show stats when available). + +--- + +## How the workflow works (high level) + +1. On `push` / `pull_request`, the workflow checks out the repo and installs dependencies. +2. It runs Playwright tests (the workflow uses `npx playwright test -c playwright.service.config.ts` and expects the Playwright Service environment variables if required). + + Note: the workflow uses the Playwright `blob` reporter and merges it into an HTML report in CI so an HTML report is produced and uploaded. + +3. The job ensures a minimal HTML exists for `playwright-report/index.html` and then builds `meta.json` containing metadata about the run (title, subtitle, dates, commitSha, runId, reportUrl, outcome). +4. It uploads the contents of `./playwright-report` to Azure Storage static website under a run directory (e.g. `$web/run--/...`). +5. The workflow writes `meta.json` and then merges a new feed entry into the global `feed.json` (also stored in the `$web` static website container) — `index.html` pulls this feed. + +`index.html` then fetches `feed.json` at runtime, lists entries, and loads `data/report.json` from each report URL to show test stats (if present). + +--- + +## Azure setup (short guide) + +The workflow uploads generated reports and metadata into the storage account's `$web` container (static website hosting). There are two primary setup tasks: + +1. Create an Azure Storage account and enable static website hosting +2. Create a service principal for `azcopy` to authenticate from GitHub Actions + +Below are example `az` commands to provision resources (replace placeholders): + +```bash +# create resource group +az group create --name my-playwright-rg --location eastus + +# create a StorageV2 account +az storage account create \ + --resource-group my-playwright-rg \ + --name \ + --location eastus \ + --sku Standard_LRS \ + --kind StorageV2 + +# enable static website and set index / error docs +az storage blob service-properties update \ + --account-name \ + --static-website --index-document index.html --404-document 404.html + +# get the static website endpoint (use this to preview uploads) +az storage account show -n -g my-playwright-rg --query primaryEndpoints.web -o tsv +``` + +Create a service principal used by `azcopy` in the Actions runner. Grant it at least the Storage Blob Data Contributor role on the storage account (scoping more narrowly is recommended): + +```bash +az ad sp create-for-rbac \ + --name "azcopy-playwright-ci" \ + --role "Storage Blob Data Contributor" \ + --scopes "/subscriptions//resourceGroups/my-playwright-rg/providers/Microsoft.Storage/storageAccounts/" +``` + +The above command prints JSON with `appId`, `password`, `tenant`. Save those and add to GitHub secrets. + +--- + +## Playwright Workspaces resource + +Create your Playwright Workspaces resource by following the Azure quickstart: + +https://learn.microsoft.com/en-us/azure/app-testing/playwright-workspaces/quickstart-automate-end-to-end-testing + +After the workspace is created, open the workspace in the Azure portal and copy the region-specific service endpoint from the **Get Started** page — this is the `PLAYWRIGHT_SERVICE_URL` you will store in GitHub Secrets. + +--- + +## Secrets and where to get them + +Below is a quick mapping of the GitHub secrets used by the workflow and how to obtain each value. + +This sample uses Playwright Workspaces access token authentication. You only need the `PLAYWRIGHT_SERVICE_URL` and `PLAYWRIGHT_SERVICE_ACCESS_TOKEN` for the test runs. The azcopy-related secrets are required for uploading reports to the static website (see below). + +- `PLAYWRIGHT_SERVICE_URL` — workspace service endpoint (Get Started page in the workspace) + - Portal: workspace → Get Started → copy the endpoint URL (region-specific) + +- `PLAYWRIGHT_SERVICE_ACCESS_TOKEN` — Playwright Workspaces access token (used in this sample) + - Portal: workspace → Access / API tokens → Create a token, copy the generated token value and add it to GitHub secrets as `PLAYWRIGHT_SERVICE_ACCESS_TOKEN`. + - Treat this token like a password: do not check it into source control. + +- `STORAGE_ACCOUNT_NAME` — Azure Storage account name used for hosting static website + - Portal: Storage account → Overview → Account name + - CLI: output from `az storage account create` or `az storage account list` + +- `AZCOPY_SPA_APPLICATION_ID`, `AZCOPY_SPA_CLIENT_SECRET`, `AZCOPY_TENANT_ID` — credentials for `azcopy` (if you are using a service principal to upload) + - Create a service principal scoped to your storage account and save the `appId` and `password` values: + ```bash + az ad sp create-for-rbac --name "azcopy-playwright-ci" \ + --role "Storage Blob Data Contributor" \ + --scopes "/subscriptions//resourceGroups//providers/Microsoft.Storage/storageAccounts/" + ``` + - Use the returned `appId` as `AZCOPY_SPA_APPLICATION_ID` + - Use the returned `password` as `AZCOPY_SPA_CLIENT_SECRET` + - Use the returned `tenant` as `AZCOPY_TENANT_ID` + +Notes: +- For best security, use Microsoft Entra ID + GitHub OIDC (configure a federated identity credential on the App Registration or create a user-assigned managed identity) instead of long-lived client secrets where possible. The workflow supports Entra ID authentication via the `azure/login` action (see the quickstart link). +- The Playwright workspace service endpoint (the `PLAYWRIGHT_SERVICE_URL`) is region-specific and must match the workspace you created. + +--- + +## GitHub secrets and repository variables + +Add the following repository secrets (Repository settings → Secrets → Actions). These are referenced by the workflow: + +- `STORAGE_ACCOUNT_NAME` — the storage account name used for static website uploads +- `AZCOPY_SPA_APPLICATION_ID` — the service principal app id (value `appId`) +- `AZCOPY_SPA_CLIENT_SECRET` — the service principal password (value `password`) +- `AZCOPY_TENANT_ID` — the tenant id (value `tenant`) +- `PLAYWRIGHT_SERVICE_URL` — (optional) Playwright Service URL if you run tests against a Playwright test service +- `PLAYWRIGHT_SERVICE_ACCESS_TOKEN` — (optional) Playwright Service token + +The workflow uses `azcopy` with service principal env vars (set as above). It also uses `jq` in the runner to build `meta.json` and to merge `feed.json` entries. + +--- + +## Feed format and `index.html` + +- The workflow writes a `meta.json` per run (contains title, date, updated, branch, commitSha, reportUrl, outcome, runId etc.). +- The workflow merges a feed entry into `feed.json` at the repository static site root. `index.html` fetches `feed.json` and renders entries; it will fetch `data/report.json` from each report URL to show stats in the viewer when available. + +If you are adapting this example, keep `meta.json` fields the same (title, updated, generatedAt, reportUrl, outcome) so `index.html` can show the run properly. + +--- + +## Troubleshooting / tips + +- Ensure the service principal has `Storage Blob Data Contributor` role on the storage account resource (otherwise `azcopy` will get 403). +- The workflow ensures `meta.json` is uploaded with `--cache-control "no-cache, no-store, must-revalidate"` to reduce stale content issues. +- If `index.html` shows "No matching reports", check that `feed.json` is present at the static website root (`$web/feed.json`) and that entries have `reportUrl` pointing to the correct run folder. +- Use `az storage account show -n -g --query primaryEndpoints.web -o tsv` to find the static website endpoint. + +--- + +## License + +This repository is a small sample. Use the code as you see fit.