Repository navigation
feat: add reproducible Meticulous recording suite #31
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 1 commit
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| export const METICULOUS_RECORDER_QUERY = 'meticulousForceRecording=true' | ||
| export const METICULOUS_RECORDER_READY_TIMEOUT_MS = 30_000 | ||
| export const METICULOUS_RECORDING_RETRIES = 0 | ||
| export const METICULOUS_RECORDING_WORKERS = 1 | ||
| export const METICULOUS_WEB_SERVER_TIMEOUT_MS = 120_000 | ||
| export const TIMER_COMPLETION_TIMEOUT_MS = 5_000 | ||
| export const MOBILE_VIEWPORT = { width: 375, height: 812 } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| interface MeticulousRecorder { | ||
| flush: () => Promise<void> | ||
| getSessionUrl: () => string | ||
| } | ||
|
|
||
| interface Window { | ||
| Meticulous?: { | ||
| record?: MeticulousRecorder | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| import { expect, type Page, type TestInfo } from '@playwright/test' | ||
| import { | ||
| METICULOUS_RECORDER_QUERY, | ||
| METICULOUS_RECORDER_READY_TIMEOUT_MS, | ||
| } from './constants' | ||
|
|
||
| /** Opens a route with forced localhost capture and waits until the recorder can accept the journey. @param page Playwright page for this isolated journey. @param path App-relative route, including any existing query. @returns A promise resolved when the page and recorder are ready. @example await openMeticulousRecording(page, '/en') */ | ||
| export async function openMeticulousRecording( | ||
| page: Page, | ||
| path: string, | ||
| ): Promise<void> { | ||
| const querySeparator = path.includes('?') ? '&' : '?' | ||
|
|
||
| await page.goto(`${path}${querySeparator}${METICULOUS_RECORDER_QUERY}`) | ||
| await page.waitForLoadState('networkidle') | ||
|
|
||
| await expect | ||
| .poll( | ||
| () => | ||
| page.evaluate( | ||
| () => typeof window.Meticulous?.record?.flush === 'function', | ||
| ), | ||
| { | ||
| message: | ||
| 'Meticulous recorder did not initialize. Check NEXT_PUBLIC_METICULOUS_PROJECT_ID and recorder loading.', | ||
| timeout: METICULOUS_RECORDER_READY_TIMEOUT_MS, | ||
| }, | ||
| ) | ||
| .toBe(true) | ||
| } | ||
|
|
||
| /** Flushes buffered capture before Playwright tears down the page and prints its Dashboard URL. @param page Playwright page containing the active recorder. @param testInfo Metadata used to label and attach the resulting session. @returns A promise resolved after Meticulous acknowledges the recording. @example await flushMeticulousRecording(page, testInfo) */ | ||
| export async function flushMeticulousRecording( | ||
| page: Page, | ||
| testInfo: TestInfo, | ||
| ): Promise<void> { | ||
| const hasRecorder = await page.evaluate( | ||
| () => typeof window.Meticulous?.record?.flush === 'function', | ||
| ) | ||
|
|
||
| // A navigation failure can happen before recorder initialization, so preserve the original test error. | ||
| if (!hasRecorder) return | ||
|
|
||
| const sessionUrl = await page.evaluate(async () => { | ||
| const recorder = window.Meticulous?.record | ||
|
|
||
| if (!recorder) { | ||
| throw new Error('Meticulous recorder is unavailable during flush') | ||
| } | ||
|
|
||
| const currentSessionUrl = recorder.getSessionUrl() | ||
| await recorder.flush() | ||
|
|
||
| return currentSessionUrl | ||
| }) | ||
|
|
||
| await testInfo.attach('meticulous-session-url', { | ||
| body: sessionUrl, | ||
| contentType: 'text/plain', | ||
| }) | ||
| console.info(`[Meticulous] ${testInfo.title}: ${sessionUrl}`) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,258 @@ | ||
| import { expect, test } from '@playwright/test' | ||
| import { MOBILE_VIEWPORT, TIMER_COMPLETION_TIMEOUT_MS } from './constants' | ||
| import { | ||
| flushMeticulousRecording, | ||
| openMeticulousRecording, | ||
| } from './recording-helpers' | ||
|
|
||
| test.describe('Meticulous Coffee Timer journeys', () => { | ||
| test.afterEach(async ({ page }, testInfo) => { | ||
| // Meticulous must receive buffered events before Playwright closes this fresh context. | ||
| await flushMeticulousRecording(page, testInfo) | ||
| }) | ||
|
|
||
| test('edits a duration with inputs, steppers, arrow keys, and boundaries', async ({ | ||
| page, | ||
| }) => { | ||
| // Arrange | ||
| await openMeticulousRecording(page, '/en') | ||
| const minutesInput = page.getByTestId('time-input-minutes') | ||
| const secondsInput = page.getByTestId('time-input-seconds') | ||
| const timerDisplay = page.getByTestId('timer-display') | ||
| const minutesStepper = minutesInput.locator('..') | ||
|
|
||
| await expect(minutesInput).toHaveValue('05') | ||
| await expect(secondsInput).toHaveValue('00') | ||
|
|
||
| // Act | ||
| await minutesStepper.getByRole('button', { name: /increase/i }).click() | ||
| await expect(minutesInput).toHaveValue('06') | ||
| await minutesStepper.getByRole('button', { name: /decrease/i }).click() | ||
| await minutesInput.fill('25') | ||
| await secondsInput.fill('45') | ||
| await secondsInput.press('ArrowUp') | ||
| await expect(timerDisplay).toContainText('25:46') | ||
| await secondsInput.fill('99') | ||
|
|
||
| // Assert | ||
| await expect(secondsInput).toHaveValue('59') | ||
| await expect(timerDisplay).toContainText('25:59') | ||
| }) | ||
|
|
||
| test('starts, pauses, resumes, and resets a timer', async ({ page }) => { | ||
| // Arrange | ||
| await openMeticulousRecording(page, '/en') | ||
| await page.getByTestId('time-input-minutes').fill('0') | ||
| await page.getByTestId('time-input-seconds').fill('15') | ||
| const timerDisplay = page.getByTestId('timer-display') | ||
| const startButton = page.getByTestId('timer-start-button') | ||
|
|
||
| // Act | ||
| await startButton.click() | ||
| await expect(page.getByTestId('timer-pause-button')).toBeVisible() | ||
| await page.getByTestId('timer-pause-button').click() | ||
| await expect(startButton).toBeVisible() | ||
| await startButton.click() | ||
| await expect(page.getByTestId('timer-pause-button')).toBeVisible() | ||
| await page.getByTestId('timer-reset-button').click() | ||
|
|
||
| // Assert | ||
| await expect(timerDisplay).toContainText('00:15') | ||
| await expect(startButton).toBeVisible() | ||
| }) | ||
|
|
||
| test('completes a countdown with sound and can restart it', async ({ | ||
| page, | ||
| }) => { | ||
| // Arrange | ||
| await openMeticulousRecording(page, '/en') | ||
| await page.getByTestId('time-input-minutes').fill('0') | ||
| await page.getByTestId('time-input-seconds').fill('2') | ||
| const timerDisplay = page.getByTestId('timer-display') | ||
| const soundRequest = page.waitForRequest( | ||
| (request) => | ||
| request.url().includes('/sounds/') && request.url().endsWith('.mp3'), | ||
| { timeout: TIMER_COMPLETION_TIMEOUT_MS }, | ||
| ) | ||
|
|
||
| // Act | ||
| await page.getByTestId('timer-start-button').click() | ||
|
|
||
| // Assert | ||
| await soundRequest | ||
| await expect(timerDisplay).toContainText('00:00', { | ||
| timeout: TIMER_COMPLETION_TIMEOUT_MS, | ||
| }) | ||
| await page.getByTestId('timer-start-button').click() | ||
| await expect(page.getByTestId('timer-pause-button')).toBeVisible() | ||
| await page.getByTestId('timer-reset-button').click() | ||
| await expect(timerDisplay).toContainText('00:02') | ||
| }) | ||
|
|
||
| test('switches through every deterministic theme', async ({ page }) => { | ||
| // Arrange | ||
| await openMeticulousRecording(page, '/en') | ||
| await page.getByRole('button', { name: /open settings/i }).click() | ||
| const themeSelector = page.getByTestId('theme-selector') | ||
| const html = page.locator('html') | ||
|
|
||
| // Act and Assert | ||
| await themeSelector.click() | ||
| await page.getByRole('option', { name: 'Dark', exact: true }).click() | ||
| await expect(html).toHaveAttribute('data-theme', 'dark') | ||
|
|
||
| await themeSelector.click() | ||
| await page.getByRole('option', { name: 'Light', exact: true }).click() | ||
| await expect(html).toHaveAttribute('data-theme', 'light') | ||
|
|
||
| await themeSelector.click() | ||
| await page.getByRole('option', { name: 'Coffee', exact: true }).click() | ||
| await expect(html).toHaveAttribute('data-theme', 'coffee') | ||
|
|
||
| await themeSelector.click() | ||
| await page.getByRole('option', { name: 'Glass Light', exact: true }).click() | ||
| await expect(html).toHaveAttribute('data-theme', 'liquid-glass-light') | ||
|
|
||
| await themeSelector.click() | ||
| await page.getByRole('option', { name: 'Glass Dark', exact: true }).click() | ||
| await expect(html).toHaveAttribute('data-theme', 'liquid-glass-dark') | ||
|
|
||
| await themeSelector.click() | ||
| await page | ||
| .getByRole('option', { name: 'Glass Coffee', exact: true }) | ||
| .click() | ||
| await expect(html).toHaveAttribute('data-theme', 'liquid-glass-coffee') | ||
| }) | ||
|
|
||
| test('previews and selects sound, changes volume, and toggles notifications', async ({ | ||
| context, | ||
| page, | ||
| }) => { | ||
| // Arrange | ||
| await context.grantPermissions(['notifications'], { | ||
| origin: 'http://localhost:3009', | ||
| }) | ||
| await openMeticulousRecording(page, '/en') | ||
| await page.getByRole('button', { name: /open settings/i }).click() | ||
| const soundSelector = page.getByTestId('sound-selector') | ||
| const notificationToggle = page.getByRole('switch', { | ||
| name: /notifications/i, | ||
| }) | ||
| const soundRequest = page.waitForRequest( | ||
| (request) => request.url().includes('/sounds/ascending-chime.mp3'), | ||
| { timeout: TIMER_COMPLETION_TIMEOUT_MS }, | ||
| ) | ||
|
|
||
| // Act | ||
| await soundSelector.click() | ||
| await page.getByRole('button', { name: /preview ascending chime/i }).click() | ||
| await soundRequest | ||
| await page.getByRole('option', { name: 'Bright Ding', exact: true }).click() | ||
| const volumeSlider = page.getByTestId('volume-slider-thumb') | ||
| await volumeSlider.press('Home') | ||
| await expect(volumeSlider).toHaveAttribute('aria-valuenow', '0') | ||
| await volumeSlider.press('End') | ||
| await expect(notificationToggle).toHaveAttribute('aria-checked', 'true') | ||
| await notificationToggle.click() | ||
| await expect(notificationToggle).toHaveAttribute('aria-checked', 'false') | ||
| await notificationToggle.click() | ||
| await page.getByTestId('settings-done-button').click() | ||
| const sendTestButton = page.getByRole('button', { name: /send test/i }) | ||
| const notificationShown = page.waitForEvent('console', { | ||
| predicate: (message) => | ||
| message.text() === '[Notifications] Notification shown successfully', | ||
| timeout: TIMER_COMPLETION_TIMEOUT_MS, | ||
| }) | ||
| await sendTestButton.click() | ||
| await notificationShown | ||
|
|
||
| // Assert | ||
| await expect(page.getByTestId('settings-panel')).toBeHidden() | ||
| }) | ||
|
|
||
| test('switches the timer and settings interface to Japanese', async ({ | ||
| page, | ||
| }) => { | ||
| // Arrange | ||
| await openMeticulousRecording(page, '/en') | ||
|
|
||
| // Act | ||
| await page.getByRole('combobox', { name: 'Language' }).click() | ||
| await page.getByRole('option', { name: '日本語', exact: true }).click() | ||
|
|
||
| // Assert | ||
| await expect(page).toHaveURL(/\/ja/) | ||
| await expect(page.getByRole('button', { name: '開始' })).toBeVisible() | ||
| await page.getByRole('button', { name: '設定を開く' }).click() | ||
| await expect(page.getByRole('dialog', { name: '設定' })).toBeVisible() | ||
| await page.getByRole('button', { name: '完了' }).click() | ||
| }) | ||
|
|
||
| test('rejects an invalid sign-in and navigates through sign-up without creating an account', async ({ | ||
| page, | ||
| }) => { | ||
| // Arrange | ||
| await openMeticulousRecording(page, '/en') | ||
|
|
||
| // Act | ||
| await page.getByRole('link', { name: 'Sign In', exact: true }).click() | ||
| await page.getByLabel('Email').fill('recording@example.com') | ||
| await page.getByLabel('Password').fill('not-a-real-password') | ||
| await page.getByRole('button', { name: 'Sign In', exact: true }).click() | ||
| await expect(page.getByRole('alert')).toBeVisible() | ||
| await page.getByRole('link', { name: 'Sign Up', exact: true }).click() | ||
| await page.getByLabel('Name').fill('Meticulous Recording') | ||
| await page.getByLabel('Email').fill('recording@example.com') | ||
| await page.getByLabel('Password').fill('not-a-real-password') | ||
|
|
||
| // Assert | ||
| await expect(page.getByRole('heading', { name: 'Sign Up' })).toBeVisible() | ||
| await page.getByRole('link', { name: 'Sign In', exact: true }).click() | ||
| await expect(page.getByRole('heading', { name: 'Sign In' })).toBeVisible() | ||
| }) | ||
|
|
||
| test('opens settings from the PWA settings shortcut', async ({ page }) => { | ||
| // Arrange and Act | ||
| await openMeticulousRecording(page, '/?action=settings') | ||
|
|
||
| // Assert | ||
| await expect(page).toHaveURL(/\/en/) | ||
| await expect(page.getByRole('dialog', { name: /settings/i })).toBeVisible() | ||
| await page.getByTestId('settings-done-button').click() | ||
| }) | ||
|
|
||
| test('starts the default timer from the PWA start shortcut', async ({ | ||
| page, | ||
| }) => { | ||
| // Arrange and Act | ||
| await openMeticulousRecording(page, '/?action=start') | ||
|
|
||
| // Assert | ||
| await expect(page).toHaveURL(/\/en/) | ||
| await expect(page.getByTestId('timer-pause-button')).toBeVisible() | ||
| await page.getByTestId('timer-reset-button').click() | ||
| await expect(page.getByTestId('timer-display')).toContainText('05:00') | ||
| }) | ||
|
|
||
| test('uses the critical timer and settings controls on a mobile viewport', async ({ | ||
| page, | ||
| }) => { | ||
| // Arrange | ||
| await page.setViewportSize(MOBILE_VIEWPORT) | ||
| await openMeticulousRecording(page, '/en') | ||
| await page.getByTestId('time-input-minutes').fill('1') | ||
| await page.getByTestId('time-input-seconds').fill('30') | ||
|
|
||
| // Act | ||
| await page.getByTestId('timer-start-button').click() | ||
| await expect(page.getByTestId('timer-pause-button')).toBeVisible() | ||
| await page.getByTestId('timer-pause-button').click() | ||
| await page.getByTestId('timer-reset-button').click() | ||
| await page.getByRole('button', { name: /open settings/i }).click() | ||
|
|
||
| // Assert | ||
| await expect(page.getByTestId('settings-panel')).toBeVisible() | ||
| await page.getByTestId('settings-done-button').click() | ||
| await expect(page.getByTestId('timer-display')).toContainText('01:30') | ||
| }) | ||
| }) |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.