Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .github/workflows/meticulous.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ permissions:

jobs:
test:
if: >-
github.event_name != 'pull_request' ||
(github.event.pull_request.head.repo.full_name == github.repository &&
github.actor != 'dependabot[bot]')
Comment thread
ryota-murakami marked this conversation as resolved.
Outdated
runs-on: ubuntu-latest
timeout-minutes: 30

Expand All @@ -42,6 +46,12 @@ jobs:
NODE_ENV: production
METICULOUS_BUILD: 'true'

# Serve immutable Next.js chunks directly to make visual replays faster and steadier.
- name: Prepare companion assets
run: |
mkdir -p companion-assets/_next
cp -r .next/static companion-assets/_next/

- name: Start app
run: |
pnpm start &
Expand All @@ -52,3 +62,5 @@ jobs:
with:
api-token: ${{ secrets.METICULOUS_API_TOKEN }}
app-url: 'http://localhost:3009'
companion-assets-folder: 'companion-assets'
companion-assets-regex: '^/_next/static/'
17 changes: 3 additions & 14 deletions app/[locale]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,20 +86,9 @@ const Home = memo(function Home() {
)
useTimerSessionSave(timeRemaining, initialTime, soundPreset, userSetTimeRef)

// Start timer from PWA shortcut (separate function to handle async)
const handleStartFromShortcut = useCallback(async () => {
try {
await audioManager.initialize()
const currentPreset = useSettingsStore.getState().soundPreset
if (currentPreset !== 'none') {
await audioManager.preload(currentPreset)
}
useTimerStore.getState().start()
} catch (error) {
console.error('[Shortcut] Failed to start timer:', error)
// Still try to start timer even if audio fails
useTimerStore.getState().start()
}
/** Starts a PWA shortcut timer synchronously because ShortcutHandler has no gesture to unlock Web Audio. @returns Nothing; the timer store is updated immediately. @example handleStartFromShortcut() */
const handleStartFromShortcut = useCallback(() => {
useTimerStore.getState().start()
}, [])

// Open settings callback for PWA shortcut (memoized for ShortcutHandler)
Expand Down
231 changes: 231 additions & 0 deletions docs/meticulous.md

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions e2e/meticulous/constants.ts
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 }
10 changes: 10 additions & 0 deletions e2e/meticulous/meticulous-window.d.ts
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
}
}
62 changes: 62 additions & 0 deletions e2e/meticulous/recording-helpers.ts
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}`)
}
258 changes: 258 additions & 0 deletions e2e/meticulous/recording.spec.ts
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')
})
})
Loading
Loading