Skip to content

Commit eb55f7d

Browse files
committed
Add basic e2e tests.
1 parent df3f961 commit eb55f7d

7 files changed

Lines changed: 241 additions & 2 deletions

File tree

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
node_modules
33
dist
44
coverage
5+
playwright-report
6+
test-results
57
js/compiled.js
68
css/_compiled_main.css
79
css/merged.css

README.md

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,17 @@ Useful commands:
6565
4. Preview the production build with `npm run preview`
6666
5. Run the test suite with `npm test`
6767
6. Measure unit-test coverage with `npm run test:coverage`
68-
7. Refresh the README coverage badge with `npm run coverage:badge`
69-
8. Run lint fixes with `npm run lint`
68+
7. Run browser smoke tests with `npm run test:e2e`
69+
8. Install the Playwright browser with `npm run test:e2e:install`
70+
9. Refresh the README coverage badge with `npm run coverage:badge`
71+
10. Run lint fixes with `npm run lint`
72+
73+
Playwright notes:
74+
75+
* E2E tests live in `tests/e2e`.
76+
* The Playwright config starts Vite automatically on `127.0.0.1:4173`.
77+
* If the bundled browser causes local issues, you can try an installed browser with `PLAYWRIGHT_CHANNEL=chrome npm run test:e2e`.
78+
* Current smoke coverage focuses on URL hydration, advanced option changes, share links, and the download dialog.
7079

7180
Project structure:
7281

package-lock.json

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

package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
},
1212
"devDependencies": {
1313
"@eslint/js": "^10.0.1",
14+
"@playwright/test": "^1.59.1",
1415
"@vitejs/plugin-vue": "^6.0.6",
1516
"@vitest/coverage-v8": "^4.1.5",
1617
"eslint": "^10.2.1",
@@ -27,6 +28,10 @@
2728
"preview": "vite preview",
2829
"test": "vitest run --pool=threads",
2930
"test:coverage": "vitest run --pool=threads --coverage",
31+
"test:e2e": "playwright test",
32+
"test:e2e:ui": "playwright test --ui",
33+
"test:e2e:headed": "playwright test --headed",
34+
"test:e2e:install": "playwright install chromium",
3035
"coverage:badge": "node scripts/update-coverage-badge.mjs",
3136
"lint": "eslint src --fix"
3237
}

playwright.config.js

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
const { defineConfig, devices } = require('@playwright/test');
2+
3+
const baseURL = process.env.PLAYWRIGHT_BASE_URL || 'http://127.0.0.1:4173';
4+
const browserChannel = process.env.PLAYWRIGHT_CHANNEL;
5+
const shouldManageWebServer = !process.env.PLAYWRIGHT_SKIP_WEBSERVER;
6+
const browserLaunchArgs = ['--disable-crash-reporter', '--disable-crashpad'];
7+
8+
module.exports = defineConfig({
9+
testDir: './tests/e2e',
10+
fullyParallel: true,
11+
forbidOnly: !!process.env.CI,
12+
retries: process.env.CI ? 2 : 0,
13+
reporter: process.env.CI ? [['html'], ['list']] : 'list',
14+
use: {
15+
baseURL,
16+
trace: 'on-first-retry',
17+
screenshot: 'only-on-failure',
18+
video: 'retain-on-failure',
19+
viewport: { width: 1440, height: 1024 },
20+
},
21+
projects: [
22+
{
23+
name: 'chromium',
24+
use: {
25+
...devices['Desktop Chrome'],
26+
...(browserChannel ? { channel: browserChannel } : {}),
27+
launchOptions: {
28+
args: browserLaunchArgs,
29+
},
30+
},
31+
},
32+
],
33+
webServer: shouldManageWebServer
34+
? {
35+
command: 'npm run dev -- --host 127.0.0.1 --port 4173',
36+
url: baseURL,
37+
reuseExistingServer: !process.env.CI,
38+
stdout: 'ignore',
39+
stderr: 'pipe',
40+
}
41+
: undefined,
42+
});

tests/e2e/app.spec.js

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
const { test, expect } = require('@playwright/test');
2+
3+
async function blockThirdPartyRequests(page) {
4+
await page.route('https://www.googletagmanager.com/**', (route) => route.abort());
5+
await page.route('https://platform.twitter.com/**', (route) => route.abort());
6+
}
7+
8+
function nestedShareTarget(href, key) {
9+
const shareUrl = new URL(href);
10+
return new URL(shareUrl.searchParams.get(key));
11+
}
12+
13+
async function shareTargets(page) {
14+
const xHref = await page.getByLabel('share on x').getAttribute('href');
15+
const linkedInHref = await page.getByLabel('share on linkedin').getAttribute('href');
16+
const facebookHref = await page.getByLabel('share on facebook').getAttribute('href');
17+
18+
return {
19+
x: nestedShareTarget(xHref, 'url'),
20+
linkedIn: nestedShareTarget(linkedInHref, 'url'),
21+
facebook: nestedShareTarget(facebookHref, 'u'),
22+
};
23+
}
24+
25+
async function checkRadioValue(page, groupSelector, value) {
26+
const input = page.locator(`${groupSelector} input[value="${value}"]`);
27+
const inputId = await input.getAttribute('id');
28+
29+
await page.locator(`${groupSelector} label[for="${inputId}"]`).click();
30+
}
31+
32+
test.beforeEach(async ({ page }) => {
33+
await blockThirdPartyRequests(page);
34+
});
35+
36+
test('loads the app and renders the main editor controls', async ({ page }) => {
37+
await page.goto('/');
38+
39+
await expect(page.locator('#terminal-display')).toBeVisible();
40+
await expect(page.getByText('Welcome to fish, the friendly interactive shell')).toBeVisible();
41+
await expect(page.locator('#controls')).toBeVisible();
42+
await expect(page.locator('#advanced')).toBeVisible();
43+
await expect(page.getByRole('link', { name: 'Download Scheme' })).toBeVisible();
44+
45+
const targets = await shareTargets(page);
46+
47+
expect(targets.x.origin).toBe('https://ciembor.github.io');
48+
expect(targets.x.pathname).toBe('/4bit/');
49+
expect(targets.linkedIn.href).toBe(targets.x.href);
50+
expect(targets.facebook.href).toBe(targets.x.href);
51+
});
52+
53+
test('hydrates scheme state from the query string and keeps share links in sync', async ({ page }) => {
54+
await page.goto('/?hue=12&colorMode=duotone&hueDistance=18&dyeScope=all&background=white');
55+
56+
await expect(page.locator('#dye-radio input[value="all"]')).toBeChecked();
57+
await expect(page.locator('#background-radio input[value="white"]')).toBeChecked();
58+
await expect(page.locator('#hue-set-radio input[value="duotone"]')).toBeChecked();
59+
60+
const targets = await shareTargets(page);
61+
62+
expect(targets.x.searchParams.get('hue')).toBe('12');
63+
expect(targets.x.searchParams.get('colorMode')).toBe('duotone');
64+
expect(targets.x.searchParams.get('hueDistance')).toBe('18');
65+
expect(targets.x.searchParams.get('dyeScope')).toBe('all');
66+
expect(targets.x.searchParams.get('background')).toBe('white');
67+
expect(targets.linkedIn.href).toBe(targets.x.href);
68+
expect(targets.facebook.href).toBe(targets.x.href);
69+
});
70+
71+
test('updates URL and share links when advanced options change', async ({ page }) => {
72+
await page.goto('/');
73+
74+
await page.getByRole('tab', { name: 'Bg' }).click();
75+
await checkRadioValue(page, '#background-radio', 'white');
76+
await expect(page.locator('#background-radio input[value="white"]')).toBeChecked();
77+
78+
await page.getByRole('tab', { name: 'Dye' }).click();
79+
await checkRadioValue(page, '#dye-radio', 'all');
80+
await expect(page.locator('#dye-radio input[value="all"]')).toBeChecked();
81+
82+
await page.getByRole('tab', { name: 'Color Mode' }).click();
83+
await checkRadioValue(page, '#hue-set-radio', 'duotone');
84+
await expect(page.locator('#hue-set-radio input[value="duotone"]')).toBeChecked();
85+
86+
await expect.poll(() => new URL(page.url()).search).toContain('background=white');
87+
await expect.poll(() => new URL(page.url()).search).toContain('dyeScope=all');
88+
await expect.poll(() => new URL(page.url()).search).toContain('colorMode=duotone');
89+
90+
const targets = await shareTargets(page);
91+
92+
expect(targets.x.searchParams.get('background')).toBe('white');
93+
expect(targets.x.searchParams.get('dyeScope')).toBe('all');
94+
expect(targets.x.searchParams.get('colorMode')).toBe('duotone');
95+
expect(targets.x.searchParams.get('degrees')).toBeNull();
96+
});
97+
98+
test('opens the export dialog and downloads an iTerm2 file', async ({ page }) => {
99+
await page.goto('/');
100+
101+
await page.getByRole('link', { name: 'Download Scheme' }).click();
102+
await expect(page.getByRole('dialog', { name: 'Export scheme to the configuration file' })).toBeVisible();
103+
104+
const downloadPromise = page.waitForEvent('download');
105+
await page.locator('#iterm2-button').click();
106+
const download = await downloadPromise;
107+
108+
expect(download.suggestedFilename()).toBe('4bit.itermcolors');
109+
110+
await page.keyboard.press('Escape');
111+
await expect(page.getByRole('dialog', { name: 'Export scheme to the configuration file' })).toBeHidden();
112+
});

vite.config.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { defineConfig } from 'vite';
22
import vue from '@vitejs/plugin-vue';
33
import { resolve } from 'path';
44
import { readFileSync } from 'fs';
5+
import { configDefaults } from 'vitest/config';
56

67
const jqueryUiVersion = JSON.parse(
78
readFileSync('./node_modules/jquery-ui/package.json', 'utf8')
@@ -19,6 +20,10 @@ export default defineConfig(({ command }) => ({
1920
include: ['jquery', 'jquery-ui'],
2021
},
2122
test: {
23+
exclude: [
24+
...configDefaults.exclude,
25+
'tests/e2e/**',
26+
],
2227
coverage: {
2328
provider: 'v8',
2429
all: true,

0 commit comments

Comments
 (0)