Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
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
51 changes: 51 additions & 0 deletions .github/workflows/playwright-e2e.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
name: Playwright E2E Tests

on:
pull_request:
push:
branches: [ master ]

permissions:
contents: read
actions: read

jobs:
e2e:
name: Playwright E2E
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
cache: 'npm'

- name: Install dependencies
run: npm ci

- name: Install Playwright browsers
run: npx playwright install --with-deps

- name: Start dev server
run: |
npm run dev --silent &
npx wait-on http://127.0.0.1:3000 --timeout 60000

- name: Run Playwright tests
env:
TEST_SPOTIFY_ACCESS_TOKEN: ${{ secrets.TEST_SPOTIFY_ACCESS_TOKEN }}
TEST_SPOTIFY_ME_JSON: ${{ secrets.TEST_SPOTIFY_ME_JSON }}
run: npm run test:e2e --if-present --silent

- name: Upload Playwright artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-test-results
path: |
tests/e2e/test-results
tests/e2e/**/*.spec.ts-snapshots
playwright-report
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,7 @@ pnpm-debug.log*
*.sw?

.claude/settings.local.json

# Playwright test artifacts
tests/e2e/test-results/
playwright-report/
64 changes: 64 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"lint": "vue-tsc --noEmit && eslint src/**/*.{vue,ts} && prettier --c ./src/**/*.{ts,vue,json} && stylelint ./src/**/*.{vue,scss}",
"build": "npm run lint && vite build",
"fix": "prettier --write ./src/**/*.{ts,vue,json} && eslint src/**/*.{vue,ts} --fix && stylelint ./src/**/*.{vue,scss} --fix",
"test:e2e": "playwright test",
"preview": "npm run build && vite preview"
},
"dependencies": {
Expand All @@ -26,6 +27,7 @@
},
"devDependencies": {
"@eslint/js": "^9.38.0",
"@playwright/test": "^1.57.0",
"@rushstack/eslint-patch": "^1.14.0",
"@types/dompurify": "^3.0.5",
"@types/node": "^24.9.1",
Expand Down
24 changes: 24 additions & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { defineConfig } from '@playwright/test';

export default defineConfig({
testDir: 'tests/e2e',
timeout: 60_000,
expect: { timeout: 5_000 },
webServer: {
command: 'npm run dev',
port: 3000,
reuseExistingServer: true,
},
use: {
baseURL: 'http://127.0.0.1:3000',
headless: true,
viewport: { width: 1280, height: 720 },
actionTimeout: 10_000,
trace: 'on-first-retry',
screenshot: 'on',
video: 'retain-on-failure',
},
projects: [
{ name: 'chromium', use: { browserName: 'chromium' } },
],
});
18 changes: 17 additions & 1 deletion src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -190,9 +190,25 @@ body {

#app-content {
display: grid;
grid-template-columns: 19rem 1fr;

/* Use a flex-friendly fallback for sidebar and main content. On small screens we collapse to a single column. */
grid-template-columns: minmax(0, 19rem) 1fr;
overflow: hidden;

@include responsive.mobile {
grid-template-columns: 1fr;

/* Hide the sidebar visually on mobile to avoid layout overflow; the component still exists in the DOM. */
.sidebar {
display: none;
}

/* Allow the main content to scroll horizontally if needed (prevents the entire app from overflowing hidden) */
& > *:nth-child(2) {
overflow-x: auto;
}
}

@include responsive.hdpi {
grid-template-columns: 25rem 1fr;
}
Expand Down
5 changes: 2 additions & 3 deletions src/assets/scss/responsive.scss
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,8 @@ $xl: 2000px;
$fourk: 2560px;

@mixin mobile {
// @media

@media (width <= $mobile-width) {
// Mobile breakpoint - use max-width for broader compatibility
@media (max-width: $mobile-width) {
@content;
}
}
Expand Down
3 changes: 2 additions & 1 deletion src/components/player/device/QueuedTracks.vue
Original file line number Diff line number Diff line change
Expand Up @@ -28,16 +28,17 @@
import { onClickOutside } from "@vueuse/core";
import { computed, ref, watch } from "vue";

import ButtonIndex from "@/components/ui/ButtonIndex.vue";
import TrackHistory from "@/components/player/history/TrackHistory.vue";
import { usePlayer } from "@/components/player/PlayerStore";
import ButtonIndex from "@/components/ui/ButtonIndex.vue";

const playerStore = usePlayer();
const currentTrack = computed(() => playerStore.playerState?.track_window.current_track);
const isPlayingPodcast = computed(() => {
const track = currentTrack.value;
return track?.type === "episode" || track?.uri?.includes("spotify:episode:");
});

const popup = ref<HTMLElement | null>();

watch(currentTrack, (track) => {
Expand Down
4 changes: 4 additions & 0 deletions test-results/.last-run.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"status": "passed",
"failedTests": []
}
Binary file added test-results/responsive-desktop.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added test-results/responsive-mobile.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added test-results/responsive-tablet.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added test-results/routes-home-desktop.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added test-results/routes-home-mobile.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added test-results/routes-home-tablet.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
29 changes: 29 additions & 0 deletions tests/e2e/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# E2E / Responsive tests

These tests use Playwright and require a test Spotify account to be available via env variables.

Required env variables:
- TEST_SPOTIFY_ACCESS_TOKEN — a valid Spotify access token for a test account
- TEST_SPOTIFY_ME_JSON — the JSON-stringified Spotify `me` object (example: `{"id":"user123","display_name":"Test"}`)

Install and run locally:

1) Install Playwright test runner:
npm i -D @playwright/test

2) Install the browsers (recommended):
npx playwright install --with-deps

3) Run tests:
TEST_SPOTIFY_ACCESS_TOKEN="<token>" TEST_SPOTIFY_ME_JSON='{"id":"...","display_name":"..."}' npm run test:e2e

Notes:
- Tests set the Pinia persisted auth store (`beardify-auth`) via localStorage before the app is loaded.
- Prefer using a short-lived test access token and do not commit secrets to the repo.

CI / GitHub Actions
- A workflow has been added at `.github/workflows/playwright-e2e.yml` to run Playwright on pull requests and pushes to `master`.
- The workflow expects two repository **secrets** to be configured in Settings → Secrets:
- `TEST_SPOTIFY_ACCESS_TOKEN` — a Spotify access token for a test account
- `TEST_SPOTIFY_ME_JSON` — JSON string of the Spotify `me` object (e.g. `{"id":"...","display_name":"..."}`)
- If the secrets are not present, authenticated route tests will be skipped; public responsiveness and snapshot tests will still run.
87 changes: 87 additions & 0 deletions tests/e2e/responsive.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { test, expect } from '@playwright/test';

// This test uses a real Spotify account supplied via environment variables:
// - TEST_SPOTIFY_ACCESS_TOKEN: Spotify access token (string)
// - TEST_SPOTIFY_ME_JSON: JSON string of the Spotify user object (stringified JSON)

const VIEWPORTS = [
{ name: 'mobile', width: 375, height: 812 },
{ name: 'tablet', width: 768, height: 1024 },
{ name: 'desktop', width: 1366, height: 768 },
];

function makeAuthPayload(accessToken: string, meJson: string) {
// Matches the Pinia persist key used by AuthStore (key: 'beardify-auth')
const storage = {
codeChallenge: '',
codeVerifier: '',
referer: '',
refreshToken: '',
};

const obj = {
accessToken: accessToken,
code: '',
me: JSON.parse(meJson),
storage,
};

return JSON.stringify(obj);
}

for (const vp of VIEWPORTS) {
test.describe(`responsive — ${vp.name}`, () => {
test.beforeEach(async ({ page, context }, testInfo) => {
const at = process.env.TEST_SPOTIFY_ACCESS_TOKEN;
const me = process.env.TEST_SPOTIFY_ME_JSON;

if (!at || !me) {
test.skip(true, 'Set TEST_SPOTIFY_ACCESS_TOKEN and TEST_SPOTIFY_ME_JSON env variables to run these tests');
}

// Inject auth BEFORE the app initializes
const authString = makeAuthPayload(at, me!);
await context.addInitScript(
(value) => {
localStorage.setItem('beardify-auth', value);
},
authString,
);

// visit root
await page.goto('/');
// wait for some app rendering time
await page.waitForTimeout(500);
});

test(`no horizontal overflow at ${vp.width} (${vp.name})`, async ({ page }) => {
await page.setViewportSize({ width: vp.width, height: vp.height });
await page.waitForTimeout(400);

// Check body/document widths
const dims = await page.evaluate(() => ({
scrollW: document.documentElement.scrollWidth,
innerW: window.innerWidth,
bodyW: document.body.getBoundingClientRect().width,
}));

// Allow a 2px tolerance (subpixel / rounding)
expect(dims.scrollW).toBeLessThanOrEqual(dims.innerW + 2);
expect(Math.round(dims.bodyW)).toBeLessThanOrEqual(dims.innerW + 2);

// Save a screenshot for visual review
await page.screenshot({ path: `test-results/responsive-${vp.name}.png`, fullPage: false });

// Visual snapshot for regression tests (Playwright will create baseline on first run)
expect(await page.screenshot({ fullPage: false })).toMatchSnapshot(`topbar-${vp.name}.png`);
});

test(`top header should be visible and not clipped at ${vp.name}`, async ({ page }) => {
await page.setViewportSize({ width: vp.width, height: vp.height });
await page.waitForTimeout(400);

// Example selector that should be present on most pages
await expect(page.locator('header, .app-header, .topbar, .login')).toBeVisible({ timeout: 2000 });
});
});
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading