diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9d3162777..6541f873d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -119,6 +119,9 @@ importers: '@fontsource/redaction-10': specifier: ^5.0.2 version: 5.0.2 + '@playwright/test': + specifier: ^1.47.1 + version: 1.47.1 '@sveltejs/adapter-static': specifier: ^3.0.2 version: 3.0.2(@sveltejs/kit@2.5.19(@sveltejs/vite-plugin-svelte@3.1.1(svelte@4.2.18)(vite@5.3.5(@types/node@20.14.14)))(svelte@4.2.18)(vite@5.3.5(@types/node@20.14.14))) @@ -135,7 +138,7 @@ importers: specifier: ^2.1.25 version: 2.1.25 '@types/node': - specifier: ^20.14.10 + specifier: ^20.14.14 version: 20.14.14 compare-versions: specifier: ^6.1.0 @@ -561,6 +564,11 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@playwright/test@1.47.1': + resolution: {integrity: sha512-dbWpcNQZ5nj16m+A5UNScYx7HX5trIy7g4phrcitn+Nk83S32EBX/CLU4hiF4RGKX/yRc93AAqtfaXB7JWBd4Q==} + engines: {node: '>=18'} + hasBin: true + '@polka/url@1.0.0-next.25': resolution: {integrity: sha512-j7P6Rgr3mmtdkeDGTe0E/aYyWEWVtc5yFXtHCRHs28/jptDEWfaVOc5T7cblqy1XKPPfCxJc/8DwQ5YgLOZOVQ==} @@ -1320,6 +1328,11 @@ packages: fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1749,6 +1762,16 @@ packages: resolution: {integrity: sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==} engines: {node: '>= 6'} + playwright-core@1.47.1: + resolution: {integrity: sha512-i1iyJdLftqtt51mEk6AhYFaAJCDx0xQ/O5NU8EKaWFgMjItPVma542Nh/Aq8aLCjIJSzjaiEQGW/nyqLkGF1OQ==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.47.1: + resolution: {integrity: sha512-SUEKi6947IqYbKxRiqnbUobVZY4bF1uu+ZnZNJX9DfU1tlf2UhWfvVjLf01pQx9URsOr18bFVUKXmanYWhbfkw==} + engines: {node: '>=18'} + hasBin: true + postcss-load-config@6.0.1: resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} engines: {node: '>= 18'} @@ -2525,6 +2548,10 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true + '@playwright/test@1.47.1': + dependencies: + playwright: 1.47.1 + '@polka/url@1.0.0-next.25': {} '@rollup/rollup-android-arm-eabi@4.19.2': @@ -3342,6 +3369,9 @@ snapshots: fs.realpath@1.0.0: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -3709,6 +3739,14 @@ snapshots: pirates@4.0.6: {} + playwright-core@1.47.1: {} + + playwright@1.47.1: + dependencies: + playwright-core: 1.47.1 + optionalDependencies: + fsevents: 2.3.2 + postcss-load-config@6.0.1(postcss@8.4.40): dependencies: lilconfig: 3.1.2 diff --git a/web/.gitignore b/web/.gitignore index 52516b880..a7705b208 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -6,3 +6,8 @@ # vite vite.config.js.timestamp-* vite.config.ts.timestamp-* +node_modules/ +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/web/package.json b/web/package.json index 67c779bcc..ca9bd56b7 100644 --- a/web/package.json +++ b/web/package.json @@ -8,7 +8,10 @@ "build": "vite build", "preview": "vite preview", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", - "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "test": "playwright test", + "test:headfull": "playwright test --headed", + "test:withui": "playwright test --ui" }, "license": "CC-BY-NC-SA-4.0", "engines": { @@ -26,12 +29,13 @@ "devDependencies": { "@eslint/js": "^9.5.0", "@fontsource/redaction-10": "^5.0.2", + "@playwright/test": "^1.47.1", "@sveltejs/adapter-static": "^3.0.2", "@sveltejs/kit": "^2.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0", "@types/eslint__js": "^8.42.3", "@types/fluent-ffmpeg": "^2.1.25", - "@types/node": "^20.14.10", + "@types/node": "^20.14.14", "compare-versions": "^6.1.0", "eslint": "^8.57.0", "glob": "^10.4.5", diff --git a/web/playwright.config.ts b/web/playwright.config.ts new file mode 100644 index 000000000..f44e1d25b --- /dev/null +++ b/web/playwright.config.ts @@ -0,0 +1,44 @@ +import { defineConfig, devices } from '@playwright/test'; + +// See https://playwright.dev/docs/test-configuration. +export default defineConfig({ + testDir: './tests', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: 'html', + use: { + trace: 'on-first-retry', + baseURL: 'https://localhost:5173', + ignoreHTTPSErrors: true, // Cobalt uses a self-signed certificate in development + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, + + // Test on mobile devices (might be useful in the future)? + // { + // name: 'Mobile Chrome', + // use: { ...devices['Pixel 5'] }, + // }, + // { + // name: 'Mobile Safari', + // use: { ...devices['iPhone 12'] }, + // }, + ], +}); diff --git a/web/src/lib/api/api-url.ts b/web/src/lib/api/api-url.ts index d23af3eca..cd137c5c7 100644 --- a/web/src/lib/api/api-url.ts +++ b/web/src/lib/api/api-url.ts @@ -1,13 +1,16 @@ -import { get } from "svelte/store"; +import { get } from 'svelte/store'; -import env, { apiURL } from "$lib/env"; -import settings from "$lib/state/settings"; +import env, { apiURL } from '$lib/env'; +import settings from '$lib/state/settings'; export const currentApiURL = () => { const processingSettings = get(settings).processing; const customInstanceURL = processingSettings.customInstanceURL; - if (processingSettings.enableCustomInstances && customInstanceURL.length > 0) { + if ( + processingSettings.enableCustomInstances && + customInstanceURL.length > 0 + ) { return new URL(customInstanceURL).origin; } @@ -16,4 +19,4 @@ export const currentApiURL = () => { } return new URL(apiURL).origin; -} +}; diff --git a/web/tests/home.spec.ts b/web/tests/home.spec.ts new file mode 100644 index 000000000..db9857ade --- /dev/null +++ b/web/tests/home.spec.ts @@ -0,0 +1,120 @@ +import { test, expect } from '@playwright/test'; + +const VIDEO_TEST_LINK = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'; // :3 + +// Before each test, open the page (/) in the browser and wait for the +// page to load. +test.beforeEach(async ({ page }) => { + await page.goto('/'); + await page.waitForLoadState('networkidle', { timeout: 10000 }); +}); + +// Test the branding of the page. +test('branding', async ({ page }) => { + // Page has a tile of "cobalt" + const title = await page.title(); + expect(title).toBe('cobalt'); + + /* + * Check if the omnibar is present on the page and has correct branding. + * IDs: omnibox, meowbalt (branding), cobalt-save (root main tag) + */ + const requiredOmniBarElements = ['omnibox', 'cobalt-save']; + for (const element of requiredOmniBarElements) { + expect(await page.isVisible(`#${element}`)).toBe(true); + } + + // Make sure meowbalt is in the correct default state (visible, class set to "meowbalt smile") + // note: meowbalt is a class, not an id + const meowbalt = await page.$('.meowbalt'); + expect(await meowbalt?.isVisible()).toBe(true); + expect(await meowbalt?.getAttribute('class')).toContain('meowbalt smile'); + expect(await meowbalt?.getAttribute('src')).toBe('/meowbalt/smile.png'); + + // Aria-hidden attribute is set to true, alt = "meowbalt" + expect(await meowbalt?.getAttribute('aria-hidden')).toBe('true'); + expect(await meowbalt?.getAttribute('alt')).toBe('meowbalt'); +}); + +// Omnibar related tests +test('omnibar', async ({ page }) => { + // Check if the omnibar is present on the page + expect(await page.isVisible('#omnibox')).toBe(true); + + // Input field is present and has the correct placeholder + const input = await page.$('#omnibox input'); + expect(await input?.isVisible()).toBe(true); + expect(await input?.getAttribute('placeholder')).toBe( + 'paste the link here', + ); + + // Check that all buttons can be clicked #action-container (besides the first one and the last one) + const buttons = await ( + await page.$$('#action-container button') + ).slice(1, -1); + for (const button of buttons) { + await button.click(); + + // Check if the button has the "selected" class after clicking + expect(await button.getAttribute('class')).toContain('active'); + + // Make sure the aria-pressed attribute is set to true + expect(await button.getAttribute('aria-pressed')).toBe('true'); + } +}); + +test('supported services', async ({ page }) => { + // Check if the supported services are present on the page + expect(await page.isVisible('#supported-services')).toBe(true); + + // Click the services-button and expect the dropdown to be visible (services-popover) + // Visibility of the popover is when the popover has the "expanded" class + const servicesButton = await page.$('#services-button'); + const servicesPopover = await page.$('#services-popover'); + await servicesButton?.click(); + + // Check if the services popover is visible + expect(await servicesPopover?.getAttribute('class')).toContain('expanded'); + await servicesButton?.click(); + expect(await servicesPopover?.getAttribute('class')).not.toContain( + 'expanded', + ); + + // Now open the services popover and ensure skeleton is present + await servicesButton?.click(); + + // Get all the skeleton elements + const skeletonElements = await page.$$('#services-popover .skeleton'); + expect(skeletonElements.length).toBeGreaterThan(0); + + // Now wait for the services to load and ensure the skeleton is gone (max of 10 seconds) + await page.waitForSelector('#services-popover .service-item', { + state: 'attached', + timeout: 10000, + }); +}); + +test('download example', async ({ page }) => { + // Get the input field and set the value to the test link + const input = await page.$('#omnibox input'); + await input?.fill(VIDEO_TEST_LINK); + + // Click the download button + const downloadButton = await page.$('#download-button'); + await downloadButton?.click(); + + // Wait for the network request to api.cobalt.tools to finish + const req = await page.waitForRequest('**/api.cobalt.tools/'); + const rsp = await req.response(); + expect(rsp).not.toBe(null); + + // Ensure we have a 200 OK and a valid JSON response + expect(rsp!.status()).toBe(200); + const json = await rsp!.json(); + + // Json should contain the following keys: "status", "url", "and filename" + const props = ['status', 'url', 'filename']; + for (const prop of props) { + expect(json).toHaveProperty(prop); + } +});