Skip to content

Commit e7f4d75

Browse files
authored
Merge pull request #137 from dwightjack/tests
Tests
2 parents 0d573f0 + 361c434 commit e7f4d75

File tree

11 files changed

+528
-8
lines changed

11 files changed

+528
-8
lines changed

.github/workflows/main.yml

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ concurrency:
1717
jobs:
1818
build:
1919
runs-on: ubuntu-latest
20-
2120
steps:
2221
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
2322
- uses: pnpm/action-setup@v4
@@ -28,3 +27,24 @@ jobs:
2827
- run: pnpm i
2928
- run: pnpm run lint
3029
- run: pnpm run build
30+
test:
31+
timeout-minutes: 60
32+
runs-on: ubuntu-latest
33+
steps:
34+
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
35+
- uses: pnpm/action-setup@v4
36+
- uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4
37+
with:
38+
node-version-file: '.node-version'
39+
cache: 'pnpm'
40+
- run: pnpm i
41+
- name: Install Playwright Browsers
42+
run: pnpm exec playwright install chromium --with-deps
43+
- name: Run Playwright tests
44+
run: pnpm run test
45+
- uses: actions/upload-artifact@v4
46+
if: ${{ !cancelled() }}
47+
with:
48+
name: playwright-report
49+
path: playwright-report/
50+
retention-days: 10

.gitignore

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,10 @@ node_modules
33
dist
44
dist-ssr
55
*.local
6-
!.vscode
6+
!.vscode
7+
8+
# Playwright
9+
/test-results/
10+
/playwright-report/
11+
/blob-report/
12+
/playwright/.cache/

package.json

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@
55
"description": "A configurable pomodoro-like timer",
66
"type": "module",
77
"scripts": {
8-
"dev": "vite",
8+
"dev": "vite --port 8888",
99
"build": "vue-tsc --noEmit && vite build",
1010
"lint": "eslint 'src/**/*.*'",
11-
"serve": "vite preview"
11+
"serve": "vite preview",
12+
"dev:test": "vite dev --port 8888 --mode=test",
13+
"test": "playwright test"
1214
},
1315
"packageManager": "[email protected]",
1416
"engines": {
@@ -24,6 +26,7 @@
2426
},
2527
"devDependencies": {
2628
"@eslint/js": "9.20.0",
29+
"@playwright/test": "^1.51.0",
2730
"@tailwindcss/vite": "4.0.9",
2831
"@types/node": "22.13.7",
2932
"@vitejs/plugin-vue": "5.2.1",

playwright.config.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { defineConfig, devices } from '@playwright/test';
2+
3+
/**
4+
* Read environment variables from file.
5+
* https://github.com/motdotla/dotenv
6+
*/
7+
// import dotenv from 'dotenv';
8+
// import path from 'path';
9+
// dotenv.config({ path: path.resolve(__dirname, '.env') });
10+
11+
/**
12+
* See https://playwright.dev/docs/test-configuration.
13+
*/
14+
export default defineConfig({
15+
testDir: './tests',
16+
/* Run tests in files in parallel */
17+
fullyParallel: true,
18+
/* Fail the build on CI if you accidentally left test.only in the source code. */
19+
forbidOnly: !!process.env.CI,
20+
/* Retry on CI only */
21+
retries: process.env.CI ? 2 : 0,
22+
/* Opt out of parallel tests on CI. */
23+
workers: process.env.CI ? 1 : undefined,
24+
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
25+
reporter: 'html',
26+
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
27+
use: {
28+
/* Base URL to use in actions like `await page.goto('/')`. */
29+
baseURL: 'http://localhost:8888',
30+
31+
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
32+
trace: 'on-first-retry',
33+
},
34+
35+
/* Configure projects for major browsers */
36+
projects: [
37+
{
38+
name: 'chromium',
39+
use: { ...devices['Desktop Chrome'] },
40+
},
41+
42+
{
43+
name: 'Mobile Chrome',
44+
use: { ...devices['Pixel 5'] },
45+
},
46+
],
47+
48+
webServer: {
49+
command: 'pnpm run dev:test',
50+
url: 'http://localhost:8888',
51+
reuseExistingServer: !process.env.CI,
52+
},
53+
});

pnpm-lock.yaml

Lines changed: 38 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/components/BaseTimer.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ const label = computed(() => {
2525
if (mins === 0) {
2626
return `${toSpacedString(type)}: less than 1 minute left`;
2727
}
28-
return `${toSpacedString(type)}: ${mins + 1} minutes left`;
28+
return `${toSpacedString(type)}: ${mins + (secs === 0 ? 0 : 1)} minutes left`;
2929
});
3030
3131
const classes = computed(() => getIntervalTypeColor(type as IntervalType));

src/components/TheCycle.vue

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
11
<script setup lang="ts">
22
import IntervalSquare from '@/components/IntervalSquare.vue';
33
import { useCycle } from '@/stores/cycle';
4+
import { useId } from 'vue';
45
56
const cycle = useCycle();
7+
const id = useId();
68
</script>
79
<template>
810
<div>
9-
<h2 class="sr-only">Intervals</h2>
10-
<ul class="flex flex-wrap items-center justify-center gap-1">
11+
<h2 :id="id + '-cycle'" aria-hidden="true" class="sr-only">Intervals</h2>
12+
<ul
13+
class="flex flex-wrap items-center justify-center gap-1"
14+
:aria-labelledby="id + '-cycle'"
15+
>
1116
<IntervalSquare
1217
v-for="(interval, i) in cycle.intervals"
1318
:key="interval.id"

tests/fixtures/app.ts

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
/* eslint-disable @typescript-eslint/no-explicit-any */
2+
import {
3+
type Page,
4+
type Locator,
5+
test as base,
6+
expect as baseExpect,
7+
} from '@playwright/test';
8+
import { Interval } from '../../src/types';
9+
import { uid } from 'uid';
10+
import { IntervalType } from '../../src/types';
11+
12+
export const mockInterval = (type: IntervalType, duration: number) => ({
13+
type,
14+
duration: duration * 60 * 1000,
15+
id: uid(),
16+
});
17+
18+
class Queries {
19+
protected readonly list: Locator;
20+
protected readonly controls: Locator;
21+
22+
readonly timer: Locator;
23+
24+
constructor(public readonly page: Page) {
25+
this.list = this.page.getByRole('list', { name: 'Intervals' });
26+
this.timer = this.page.getByRole('timer');
27+
this.controls = this.page.getByRole('group', { name: 'Timer controls' });
28+
}
29+
30+
getControl(name: string | RegExp, { context }: { context?: Locator } = {}) {
31+
return (context ?? this.controls).getByRole('button', { name });
32+
}
33+
34+
getButton(name: string | RegExp, { context }: { context?: Locator } = {}) {
35+
return (context ?? this.page).getByRole('button', { name });
36+
}
37+
38+
getIntervalList() {
39+
return this.list.getByRole('listitem');
40+
}
41+
42+
getIntervalByType(type: 'W' | 'LB' | 'SB') {
43+
return this.getIntervalList().filter({ hasText: type });
44+
}
45+
46+
getIntervalByDuration(duration: number) {
47+
return this.getIntervalList().filter({
48+
hasText: `${String(duration).padStart(2, '0')}:00`,
49+
});
50+
}
51+
}
52+
53+
export class AppPage {
54+
constructor(public readonly page: Page) {}
55+
56+
async setStorage(intervals: Interval[]) {
57+
// set two intervals
58+
await this.page.addInitScript((intervals) => {
59+
window.localStorage.setItem('intervals', JSON.stringify(intervals));
60+
}, intervals);
61+
}
62+
63+
async withinSettings(fn: (dialog: Locator) => Promise<void> | void) {
64+
await fn(this.page.getByRole('dialog', { name: 'Settings' }));
65+
}
66+
}
67+
68+
export const test = base.extend<{ appPage: AppPage; queries: Queries }>({
69+
appPage: async ({ page }, use) => {
70+
const todoPage = new AppPage(page);
71+
72+
await use(todoPage);
73+
},
74+
queries: async ({ page }, use) => {
75+
await use(new Queries(page));
76+
},
77+
});
78+
79+
export const expect = baseExpect.extend({
80+
async toHaveOptions(
81+
locator: Locator,
82+
expectedOptions: string[],
83+
options?: { timeout?: number },
84+
) {
85+
const assertionName = 'toHaveOptions';
86+
let pass = true;
87+
88+
let matcherResult: any;
89+
try {
90+
const optionsLocator = locator.getByRole('option');
91+
await baseExpect(optionsLocator).toHaveText(expectedOptions, options);
92+
} catch (e) {
93+
matcherResult = (e as any).matcherResult;
94+
pass = false;
95+
}
96+
97+
const message = () =>
98+
this.utils.matcherHint(assertionName, undefined, undefined, {
99+
isNot: this.isNot,
100+
}) +
101+
'\n\n' +
102+
`Locator: ${locator}\n` +
103+
`Expected options: ${this.utils.printExpected(expectedOptions)}\n` +
104+
`Received options: ${this.utils.printReceived(matcherResult?.actual)}`;
105+
106+
return {
107+
message,
108+
pass,
109+
name: assertionName,
110+
expected: expectedOptions,
111+
actual: matcherResult?.actual,
112+
};
113+
},
114+
async toHaveSelected(
115+
locator: Locator,
116+
expected: string | RegExp,
117+
options?: { timeout?: number },
118+
) {
119+
const assertionName = 'toHaveOptions';
120+
let pass = true;
121+
122+
let matcherResult: any;
123+
try {
124+
const matchOptionValue =
125+
(await locator
126+
.getByRole('option', { name: expected })
127+
.getAttribute('value')) ?? expected;
128+
await baseExpect(locator).toHaveValue(matchOptionValue, options);
129+
} catch (e) {
130+
matcherResult = (e as any).matcherResult;
131+
pass = false;
132+
}
133+
134+
const message = () =>
135+
this.utils.matcherHint(assertionName, undefined, undefined, {
136+
isNot: this.isNot,
137+
}) +
138+
'\n\n' +
139+
`Locator: ${locator}\n` +
140+
`Expected selected option: ${this.utils.printExpected(expected)}\n` +
141+
`Received selected option: ${this.utils.printReceived(matcherResult?.actual)}`;
142+
143+
return {
144+
message,
145+
pass,
146+
name: assertionName,
147+
expected,
148+
actual: matcherResult?.actual,
149+
};
150+
},
151+
});

0 commit comments

Comments
 (0)