From ef929eaaf600b358819ff4cd6194aa14ceaa3dcb Mon Sep 17 00:00:00 2001 From: Kasif Gilbert Date: Sat, 9 May 2026 13:22:54 -0500 Subject: [PATCH 01/12] chore: add Playwright image test tooling Add a repo-level Playwright dependency and npm script so image verification can be run consistently from the repository root with npm run test:images. Keep this tooling outside the Svelte app package because Playwright is only used for repository-level verification and the app itself does not import it at runtime. Ignore Playwright report and test-results directories across package workspaces so failed local runs do not leave ephemeral artifacts available for accidental commits. --- .gitignore | 2 ++ package-lock.json | 75 +++++++++++++++++++++++++++++++++++++++++++++++ package.json | 8 +++++ 3 files changed, 85 insertions(+) create mode 100644 package-lock.json create mode 100644 package.json diff --git a/.gitignore b/.gitignore index da8e441..fd0a7c1 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,8 @@ node_modules */build */.svelte-kit */package +*/playwright-report +*/test-results .env .env.* !.env.example diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..3214911 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,75 @@ +{ + "name": "asmbly-classes", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "devDependencies": { + "@playwright/test": "^1.59.1" + } + }, + "node_modules/@playwright/test": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", + "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..a2a41f5 --- /dev/null +++ b/package.json @@ -0,0 +1,8 @@ +{ + "scripts": { + "test:images": "playwright test -c app/playwright.config.js" + }, + "devDependencies": { + "@playwright/test": "^1.59.1" + } +} From 3d3317eb4d73f7cf822503fe389755e345986ae0 Mon Sep 17 00:00:00 2001 From: Kasif Gilbert Date: Sat, 9 May 2026 13:23:13 -0500 Subject: [PATCH 02/12] test: add Playwright class image verification Add a gated SvelteKit /__image-check route that renders every class image through the same enhanced image glob used by the class card UI. The route is available in dev and in CI only when PLAYWRIGHT_IMAGE_CHECK=true, keeping the diagnostic page out of normal production routing. Add Playwright coverage for three image safety checks: required fallback images must exist, changed image filenames must not delete or rename existing lookup-contract paths, and newly added image files must not use generated duplicate-style suffixes such as _2, copy, final, or (1). Add a browser-level decode check that visits the diagnostic route, watches image responses, calls img.decode() for every rendered image, and fails if any image response is not OK or any rendered image has zero natural dimensions. Configure Playwright to run the Svelte app from the app directory, use the dev server locally, use preview in CI after a build, and target Chromium as the minimal browser needed for image decoding coverage. --- app/playwright.config.js | 32 ++++ app/src/routes/__image-check/+page.server.js | 8 + app/src/routes/__image-check/+page.svelte | 36 ++++ app/tests/images.spec.js | 192 +++++++++++++++++++ 4 files changed, 268 insertions(+) create mode 100644 app/playwright.config.js create mode 100644 app/src/routes/__image-check/+page.server.js create mode 100644 app/src/routes/__image-check/+page.svelte create mode 100644 app/tests/images.spec.js diff --git a/app/playwright.config.js b/app/playwright.config.js new file mode 100644 index 0000000..0a18377 --- /dev/null +++ b/app/playwright.config.js @@ -0,0 +1,32 @@ +import { defineConfig, devices } from '@playwright/test'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const appDirectory = path.dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + testDir: path.join(appDirectory, 'tests'), + fullyParallel: true, + reporter: process.env.CI ? 'github' : 'list', + use: { + baseURL: 'http://127.0.0.1:4173', + trace: 'on-first-retry' + }, + webServer: { + command: process.env.CI + ? 'npm run preview -- --host 127.0.0.1 --port 4173' + : 'npm run dev -- --host 127.0.0.1 --port 4173', + cwd: appDirectory, + env: { + PLAYWRIGHT_IMAGE_CHECK: 'true' + }, + reuseExistingServer: !process.env.CI, + url: 'http://127.0.0.1:4173/__image-check' + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] } + } + ] +}); diff --git a/app/src/routes/__image-check/+page.server.js b/app/src/routes/__image-check/+page.server.js new file mode 100644 index 0000000..df79af7 --- /dev/null +++ b/app/src/routes/__image-check/+page.server.js @@ -0,0 +1,8 @@ +import { dev } from '$app/environment'; +import { error } from '@sveltejs/kit'; + +export function load() { + if (!dev && process.env.PLAYWRIGHT_IMAGE_CHECK !== 'true') { + error(404); + } +} diff --git a/app/src/routes/__image-check/+page.svelte b/app/src/routes/__image-check/+page.svelte new file mode 100644 index 0000000..6890d93 --- /dev/null +++ b/app/src/routes/__image-check/+page.svelte @@ -0,0 +1,36 @@ + + + + + + Image Check + + +
+

Image Check

+

{imageEntries.length} class images

+ + {#each imageEntries as image} + + {/each} +
diff --git a/app/tests/images.spec.js b/app/tests/images.spec.js new file mode 100644 index 0000000..1cdd40a --- /dev/null +++ b/app/tests/images.spec.js @@ -0,0 +1,192 @@ +import { expect, test } from '@playwright/test'; +import { execFileSync } from 'node:child_process'; +import { readdir } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const testDirectory = path.dirname(fileURLToPath(import.meta.url)); +const repositoryRoot = path.resolve(testDirectory, '../..'); +const imageDirectory = path.resolve(testDirectory, '../src/lib/images'); +const imagePathPrefix = 'app/src/lib/images/'; +const supportedImageExtension = /\.(avif|gif|heif|jpeg|jpg|png|tiff|webp)$/; +const requiredFallbackImages = [ + '3dprintingDefault.jpg', + 'ceramicsDefault.jpg', + 'classDefault.jpg', + 'electronicsDefault.jpg', + 'lasersDefault.jpg', + 'metalworkingDefault.jpg', + 'textilesDefault.jpg', + 'woodworkingDefault.jpg' +]; + +function runGit(args) { + try { + const output = execFileSync('git', ['-C', repositoryRoot, ...args], { + encoding: 'utf8' + }); + + return output.replace(/\s+$/, ''); + } catch { + return ''; + } +} + +function parseNameStatus(output) { + return output + .split('\n') + .filter(Boolean) + .map((line) => { + const [status, firstPath, secondPath] = line.split('\t'); + + if (status.startsWith('R') || status.startsWith('C')) { + return { + status: status[0], + path: firstPath, + newPath: secondPath + }; + } + + return { + status, + path: firstPath + }; + }) + .filter((change) => change.path?.startsWith(imagePathPrefix)); +} + +function parsePorcelainStatus(output) { + return output + .split('\n') + .filter(Boolean) + .map((line) => { + const status = line.slice(0, 2).trim() || line.slice(0, 2); + const pathText = line.slice(3); + const [oldPath, newPath] = pathText.split(' -> '); + + return { + status, + path: oldPath, + newPath + }; + }) + .filter((change) => change.path?.startsWith(imagePathPrefix)); +} + +function getImageChanges() { + if (process.env.GITHUB_BASE_REF) { + const baseDiff = runGit([ + 'diff', + '--name-status', + '--find-renames=50%', + `origin/${process.env.GITHUB_BASE_REF}...HEAD`, + '--', + imagePathPrefix + ]); + + if (baseDiff) { + return parseNameStatus(baseDiff); + } + } + + return parsePorcelainStatus(runGit(['status', '--porcelain', '--', imagePathPrefix])); +} + +function isAddedOrRenamed(change) { + return change.status === 'A' || change.status === '??' || change.status === 'R'; +} + +function isDeletedOrRenamed(change) { + return change.status === 'D' || change.status === 'R'; +} + +function hasGeneratedVariantSuffix(filename) { + const basename = filename.replace(supportedImageExtension, ''); + + return [ + /^copy of /i, + /\(\d+\)$/i, + /(^|[ _-])copy([ _-]?\d+)?$/i, + /(^|[ _-])(bak|backup|final|new|old|temp|tmp)$/i, + /(^|[ _-])v\d+$/i, + /[ _-]\d+$/ + ].some((pattern) => pattern.test(basename)); +} + +test('required fallback class images exist', async () => { + const actualImageFilenames = await readdir(imageDirectory); + + expect(actualImageFilenames).toEqual(expect.arrayContaining(requiredFallbackImages)); +}); + +test('class image changes do not remove or rename existing filenames', () => { + const destructiveImageChanges = getImageChanges() + .filter(isDeletedOrRenamed) + .map((change) => { + if (change.newPath) { + return `${change.path} -> ${change.newPath}`; + } + + return change.path; + }); + + expect( + destructiveImageChanges, + [ + 'Existing class image filenames are part of the app lookup contract.', + 'Add new image files freely, but do not delete or rename existing ones without updating the lookup behavior and tests.' + ].join('\n') + ).toEqual([]); +}); + +test('new class image filenames do not look like generated duplicate variants', () => { + const generatedVariantFilenames = getImageChanges() + .filter(isAddedOrRenamed) + .map((change) => change.newPath ?? change.path) + .filter((changedPath) => supportedImageExtension.test(changedPath)) + .map((changedPath) => path.basename(changedPath)) + .filter(hasGeneratedVariantSuffix); + + expect( + generatedVariantFilenames, + [ + 'New image filenames should match class names instead of generated duplicate names.', + 'Examples that should fail: Woodshop_Safety_2.jpg, Woodshop_Safety copy.jpg, Woodshop_Safety (1).jpg.' + ].join('\n') + ).toEqual([]); +}); + +test('class images load and decode in the browser', async ({ page }) => { + const failedImageResponses = []; + + page.on('response', (response) => { + if (response.request().resourceType() === 'image' && !response.ok()) { + failedImageResponses.push(`${response.status()} ${response.url()}`); + } + }); + + await page.goto('/__image-check'); + await expect(page.getByRole('heading', { name: 'Image Check' })).toBeVisible(); + + const brokenImages = await page.locator('[data-testid="class-image"]').evaluateAll(async (images) => { + const failures = []; + + for (const image of images) { + try { + await image.decode(); + } catch { + failures.push(`${image.dataset.sourcePath}: decode failed`); + continue; + } + + if (!image.complete || image.naturalWidth === 0 || image.naturalHeight === 0) { + failures.push(`${image.dataset.sourcePath}: loaded with zero dimensions`); + } + } + + return failures; + }); + + expect(failedImageResponses).toEqual([]); + expect(brokenImages).toEqual([]); +}); From 4d1880171811ce76ca69f982e5d3c84e476dac8c Mon Sep 17 00:00:00 2001 From: Kasif Gilbert Date: Sat, 9 May 2026 13:23:37 -0500 Subject: [PATCH 03/12] ci: run image verification for relevant PRs Add a focused Image Check workflow that runs on pull requests touching class images, image lookup code, the diagnostic image-check route, Playwright tests, Playwright config, or package files that affect the test runner. Use a full checkout so the Playwright spec can compare changed image files against the pull request base with git diff and detect deletions or renames instead of relying on a manually maintained image manifest. Install both the repository-level Playwright package and the Svelte app dependencies, install the Chromium browser with system dependencies, build the app, and then run npm run test:images so CI exercises both build-time image processing and browser image decoding. --- .github/workflows/image-check.yml | 43 +++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 .github/workflows/image-check.yml diff --git a/.github/workflows/image-check.yml b/.github/workflows/image-check.yml new file mode 100644 index 0000000..77622cd --- /dev/null +++ b/.github/workflows/image-check.yml @@ -0,0 +1,43 @@ +name: Image Check + +on: + pull_request: + paths: + - 'app/src/lib/images/**' + - 'app/src/lib/components/classCard.svelte' + - 'app/src/lib/components/myClassCard.svelte' + - 'app/src/lib/models/neonEventInstance.js' + - 'app/src/routes/__image-check/**' + - 'app/tests/**' + - 'app/package.json' + - 'app/package-lock.json' + - 'app/playwright.config.js' + - 'package.json' + - 'package-lock.json' + - '.github/workflows/image-check.yml' + +jobs: + image-check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: | + package-lock.json + app/package-lock.json + + - run: npm ci + + - run: npm ci --prefix app + + - run: npx playwright install --with-deps chromium + + - run: npm run build --prefix app + + - run: npm run test:images From 1c1ef412dd6be32eb7558467d9102476d7c343e7 Mon Sep 17 00:00:00 2001 From: Kasif Gilbert Date: Sat, 9 May 2026 13:25:41 -0500 Subject: [PATCH 04/12] chore: normalize app npm lockfile metadata Capture the existing app/package-lock.json metadata rewrite separately from the image verification implementation so dependency-lock churn is easy to inspect on its own. The app package declarations are unchanged; this commit only removes stale peer metadata fields from resolved lockfile entries as produced by the npm tooling used during the image verification setup. Keeping this in a separate commit avoids hiding lockfile noise inside the Playwright test or CI commits and makes it straightforward to revert independently if the team chooses to regenerate the app lockfile differently. --- app/package-lock.json | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/app/package-lock.json b/app/package-lock.json index a540d58..fdf5f19 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -1697,7 +1697,6 @@ "integrity": "sha512-dCYqelr2RVnWUuxc+Dk/dB/SjV/8JBndp1UovCyCZdIQezd8TRwFLNZctYkzgHxRJtaNvseCSRsuuHPeUgIN/A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", @@ -1741,7 +1740,6 @@ "integrity": "sha512-Txsm1tJvtiYeLUVRNqxZGKR/mI+CzuIQuc2gn+YCs9rMTowpNZ2Nqt53JdL8KF9bLhAf2ruR/dr9eZCwdTriRA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^2.1.0", "debug": "^4.3.4", @@ -1937,7 +1935,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2212,7 +2209,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.9", "caniuse-lite": "^1.0.30001746", @@ -2750,7 +2746,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -3579,7 +3574,6 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -4311,7 +4305,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -4516,7 +4509,6 @@ "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -4533,7 +4525,6 @@ "integrity": "sha512-pn1ra/0mPObzqoIQn/vUTR3ZZI6UuZ0sHqMK5x2jMLGrs53h0sXhkVuDcrlssHwIMk7FYrMjHBPoUSyyEEDlBQ==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "prettier": "^3.0.0", "svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0" @@ -4648,7 +4639,6 @@ "integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==", "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@prisma/engines": "5.22.0" }, @@ -4823,7 +4813,6 @@ "integrity": "sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -5287,7 +5276,6 @@ "integrity": "sha512-eeEgGc2DtiUil5ANdtd8vPwt9AgaMdnuUFnPft9F5oMvU/FHu5IHFic+p1dR/UOB7XU2mX2yHW+NcTch4DCh5Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.1", "@jridgewell/sourcemap-codec": "^1.4.15", @@ -5425,7 +5413,6 @@ "integrity": "sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -5720,7 +5707,6 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -6073,7 +6059,6 @@ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } From e51e4bd702e4bc9e4a2f0f8e049b67cfe5244706 Mon Sep 17 00:00:00 2001 From: Kasif Gilbert Date: Sat, 9 May 2026 13:26:11 -0500 Subject: [PATCH 05/12] test: demonstrate detection of broken image rename Rename Woodshop_Safety.jpg to Woodshop_Safety_2.jpg to preserve the exact manual failure case used to validate the new image verification checks. This mirrors the UI-risk scenario where an existing class image lookup path disappears and a generated duplicate-style filename appears in app/src/lib/images. The Playwright spec intentionally rejects both sides of that change: the deleted lookup-contract filename and the _2 suffix on the new file. Keep this as an isolated commit so the failing example can be reviewed, pushed, and later reverted without touching the reusable Playwright route, tests, workflow, or package tooling. --- .../{Woodshop_Safety.jpg => Woodshop_Safety_2.jpg} | Bin 1 file changed, 0 insertions(+), 0 deletions(-) rename app/src/lib/images/{Woodshop_Safety.jpg => Woodshop_Safety_2.jpg} (100%) diff --git a/app/src/lib/images/Woodshop_Safety.jpg b/app/src/lib/images/Woodshop_Safety_2.jpg similarity index 100% rename from app/src/lib/images/Woodshop_Safety.jpg rename to app/src/lib/images/Woodshop_Safety_2.jpg From 4ce8c3fcd04f29516fd61df383c46e893f21f32f Mon Sep 17 00:00:00 2001 From: Kasif Gilbert Date: Sat, 9 May 2026 13:46:48 -0500 Subject: [PATCH 06/12] ci: fix image check clean install Add yaml as an explicit app dev dependency so npm ci has a concrete lockfile entry for Tailwind's nested postcss-load-config optional yaml peer. GitHub Actions was resolving yaml@2.8.4 during strict clean-install validation and failing because the app lockfile only contained the older yaml 1.x entry required by the app-level PostCSS loader. Regenerate app/package-lock.json so yaml@2.8.4 is available at the app root while yaml@1.10.3 remains nested under the older postcss-load-config package that still requires the 1.x range. This keeps the lockfile complete for npm ci without changing application runtime code. Generate the Prisma client explicitly in the Image Check workflow after npm ci --prefix app and before npm run build --prefix app. A clean CI install does not leave the generated client in place, and the SvelteKit build imports Prisma during server analysis. Verified locally with npm ci, npm ci --prefix app, npm exec --prefix app -- prisma generate --schema app/prisma/schema.prisma, and npm run build --prefix app. --- .github/workflows/image-check.yml | 2 ++ app/package-lock.json | 25 +++++++++++++++++++++---- app/package.json | 1 + 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/.github/workflows/image-check.yml b/.github/workflows/image-check.yml index 77622cd..0a78906 100644 --- a/.github/workflows/image-check.yml +++ b/.github/workflows/image-check.yml @@ -38,6 +38,8 @@ jobs: - run: npx playwright install --with-deps chromium + - run: npm exec --prefix app -- prisma generate --schema app/prisma/schema.prisma + - run: npm run build --prefix app - run: npm run test:images diff --git a/app/package-lock.json b/app/package-lock.json index fdf5f19..60a6945 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -39,6 +39,7 @@ "vite": "^5.0.0", "vite-node": "^1.1.3", "vitest": "^1.0.0", + "yaml": "^2.8.4", "zod": "^3.22.4" } }, @@ -4388,6 +4389,16 @@ } } }, + "node_modules/postcss-load-config/node_modules/yaml": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, "node_modules/postcss-nested": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", @@ -6031,13 +6042,19 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", - "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "version": "2.8.4", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.4.tgz", + "integrity": "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==", "dev": true, "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, "engines": { - "node": ">= 6" + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" } }, "node_modules/yocto-queue": { diff --git a/app/package.json b/app/package.json index 1167b73..02bd937 100644 --- a/app/package.json +++ b/app/package.json @@ -35,6 +35,7 @@ "vite": "^5.0.0", "vite-node": "^1.1.3", "vitest": "^1.0.0", + "yaml": "^2.8.4", "zod": "^3.22.4" }, "dependencies": { From 251269ca65c166ca3dbbf42163a3a5de9e1de6af Mon Sep 17 00:00:00 2001 From: Kasif Gilbert Date: Thu, 14 May 2026 14:54:34 -0500 Subject: [PATCH 07/12] feat(test): add compose-based Postgres test database Add compose.test.yaml at the repository root, a docker-compose stack that provisions a disposable Postgres instance preloaded with dev/dev-db.sql so the SvelteKit app can be exercised end-to-end against realistic class data without depending on Neon CRM, AWS SSM secrets, or production database access. Use the postgres:17 image because dev/dev-db.sql is produced by pg_dump 18.x, which emits "SET transaction_timeout = 0". That GUC was introduced in Postgres 17, so the older postgres:16 image rejects the init script on first start with "unrecognized configuration parameter" and the container exits with code 3. Mount dev/dev-db.sql read-only at /docker-entrypoint-initdb.d/01-dev-db.sql. The official Postgres image executes every .sql and .sh file in that directory exactly once on the first start of an empty data volume, so the seed loads automatically on `docker compose up` with no shell wrapper. The "01-" prefix reserves alphabetical room for additional ordered init scripts in the future without having to rename this file. Match dev/load-dev-db.sh's existing convention: - container_name: asmbly-dev-db - POSTGRES_PASSWORD: localdev - host port 5432 so DATABASE_URL is identical for both database sources and a developer can switch between the shell script and this compose file without reconfiguring anything. The two cannot run concurrently (same container name, same port); that is intentional. They are alternative database sources, not parallel ones, and forcing the conflict surfaces the choice rather than silently routing traffic to the wrong one. Include a pg_isready healthcheck so `docker compose up -d --wait` blocks until the database is actually accepting connections after the init script has finished applying the dump. Without this, tests that run immediately after bring-up race against the seed load and intermittently fail before the database is ready. This file is consumed by upcoming Playwright UI tests and by the matching CI workflow. It is independent of, and does not modify, dev/load-dev-db.sh; that script remains the recommended local development path. Co-Authored-By: Claude Opus 4.7 (1M context) --- compose.test.yaml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 compose.test.yaml diff --git a/compose.test.yaml b/compose.test.yaml new file mode 100644 index 0000000..e1fb25e --- /dev/null +++ b/compose.test.yaml @@ -0,0 +1,15 @@ +services: + postgres: + image: postgres:17 + container_name: asmbly-dev-db + environment: + POSTGRES_PASSWORD: localdev + ports: + - "5432:5432" + volumes: + - ./dev/dev-db.sql:/docker-entrypoint-initdb.d/01-dev-db.sql:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d postgres"] + interval: 2s + timeout: 5s + retries: 30 From 525852ebf42e420f4e6ff6120efd39b97b9074be Mon Sep 17 00:00:00 2001 From: Kasif Gilbert Date: Thu, 14 May 2026 14:54:48 -0500 Subject: [PATCH 08/12] test: add stable selectors to class card root Add two inert data attributes to the card root
in app/src/lib/components/classCard.svelte so upcoming Playwright UI tests can locate class cards without coupling assertions to DaisyUI class names (.card, .card-side, lg:max-h-72) that may legitimately change with future styling work. - data-testid="class-card" gives tests a single selector for counting visible cards, iterating their contents, and asserting filter / sort / search behavior end-to-end. Mirrors the data-testid="class-image" pattern already used by the existing /__image-check diagnostic route. - data-category={event.category} lets tests assert category-filter correctness directly. After checking the "3D Printing" sidebar box, a test can assert that every visible card carries data-category="3D Printing" without having to decode the embedded AsmblyIcon SVG or hand-maintain a class-name-to-category mapping inside the test. Both attributes are inert in production: no JavaScript reads them, no CSS targets them, and they add a few bytes per attribute per card to the rendered HTML. They cost nothing at runtime and the test surface is much more stable than locating cards by their visual styling. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/src/lib/components/classCard.svelte | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/src/lib/components/classCard.svelte b/app/src/lib/components/classCard.svelte index 28fce8d..3892691 100644 --- a/app/src/lib/components/classCard.svelte +++ b/app/src/lib/components/classCard.svelte @@ -19,7 +19,11 @@ {#if event} -
+
Date: Thu, 14 May 2026 14:55:24 -0500 Subject: [PATCH 09/12] test: add Playwright UI suite and remove __image-check route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add app/tests/class-list.spec.js, a Playwright suite that drives the real `/` class list page end-to-end against the compose-backed Postgres database from compose.test.yaml. Replace the existing diagnostic-route-based image-check coverage with assertions made against the actual production rendering path. Group assertions by concern into five tests so each test name is a contract statement and a failure points to the affected area: 1. renders correctly — page title, card count > 0, no empty state, and for every visible card: an whose naturalWidth > 0 and that successfully passes img.decode(), a heading, a "View Schedule" anchor whose href matches /event/?eventId=, and visible "Price:" text. This test also installs a response listener that fails the test if any image request returns a non-2xx status. 2. search works — typing a real word taken from the first card's heading narrows the list to fuzzy-matching cards; typing nonsense ("zzzqxnonsense") surfaces the "Sorry, no results found" empty-state card with its Clear Search button; clicking Clear Search restores the baseline card count. Exercises both Fuse.js fuzzy match and the URL -> filters reactivity chain. 3. sort works — switching Sort By to "Name" sorts cards ascending case-insensitively (matching the comparator in NeonEventInstance.compare); toggling Sort Order reverses the order. Verifies both the select-driven sortBy and the swap-checkbox-driven sortAsc paths through updateSearchParams. 4. filters work — checking a category checkbox restricts visible cards to that category via data-category; toggling Group By Class Type off does not decrease the count (each instance now stands alone instead of being collapsed under its type); toggling Show Unscheduled Classes off does not increase the count. 5. navigation works — header links to Mentor Series and Classes FAQ have the expected external asmbly.org hrefs; direct navigation to /?sortBy=Name&archCategories=X restores the corresponding UI state from URL parameters via the afterNavigate hook; clicking a View Schedule link lands on /event/?eventId=. Assertions are shape-/invariant-based and derive class names from the rendered page where possible. Regenerating dev/dev-db.sql (adding or removing classes) does not require touching the spec unless the renderer itself changes. Run the suite serially via test.describe.configure({ mode: 'serial' }) because all five tests share one vite dev server. Running them in parallel produced intermittent 30s timeouts on selectOption and fill calls when multiple workers triggered concurrent SvelteKit navigations against the same Prisma connection. Serial execution finishes in ~35s, parallel execution in this configuration was flaky and not faster. Modify app/playwright.config.js: - Inject DATABASE_URL into webServer.env so vite dev / vite preview reach the compose-backed Postgres without the caller having to export anything. Mirrors the DATABASE_URL that dev/load-dev-db.sh exports. - Drop the PLAYWRIGHT_IMAGE_CHECK env var; no consumer remains after the route deletion below. - Replace `url: 'http://127.0.0.1:4173/__image-check'` with `port: 4173`. The port-based readiness probe is a plain TCP poll that does not require any route to be live or the database to be reachable; tests surface database failures via their own assertions instead. Delete app/src/routes/__image-check/+page.{server.js,svelte}. The diagnostic route was originally added to render every globbed image in a single grid for browser-level decode coverage. Two reasons to remove it: - Browser-level decode coverage now lives in the "renders correctly" test above, which calls img.decode() on every actually shown by `/`. enhanced:img processes the same glob in both routes, so any decode regression that would have surfaced on /__image-check surfaces here too for every image used by a visible class. Images not referenced by any class would no longer be exercised, but the CI build step still fails if enhanced:img cannot process any file in the glob, so that gap is covered earlier in the pipeline. - Gating a route on an env var (PLAYWRIGHT_IMAGE_CHECK) is fragile: a misconfiguration in production could leak the diagnostic page. The only zero-risk option is to not ship the route at all. Rewrite app/tests/images.spec.js to remove the browser-based "class images load and decode in the browser" test that depended on the deleted route. Preserve the three node-only hygiene checks unchanged because each addresses a regression the UI suite cannot catch on its own: - required fallback class images exist — pure readdir check against app/src/lib/images. Catches accidental deletion of a category default that the lookup logic would silently fall back to. - class image changes do not remove or rename existing filenames — runs `git diff --name-status` against origin/ in CI or `git status --porcelain` locally. Catches rename-into-fallback regressions (e.g., Woodshop_Safety.jpg -> Woodshop_Safety_2.jpg) where the page still renders, just with the wrong image. The UI suite cannot detect this because the fallback default decodes successfully. - new class image filenames do not look like generated duplicate variants — flags additions like "Copy of …", "…_2", "… (1)" before they're merged. These three run without a browser or a database; they are plain node tests that happen to use Playwright's runner. Replace the test:images and test:ui scripts in package.json with a single `test` script: "test": "playwright test -c app/playwright.config.js" It runs every spec in app/tests/. Add test:db:up and test:db:down helpers wrapping the compose lifecycle so the local workflow is two single commands: npm run test:db:up # provision the test DB npm test # run all 8 tests (3 hygiene + 5 UI) npm run test:db:down # tear down volume (optional) The 8-test suite passes locally in ~36s on a Mac M-series: 3 hygiene tests in <50 ms total (parallel, no browser) and 5 UI tests serially against the dev server. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/playwright.config.js | 4 +- app/src/routes/__image-check/+page.server.js | 8 - app/src/routes/__image-check/+page.svelte | 36 ---- app/tests/class-list.spec.js | 193 +++++++++++++++++++ app/tests/images.spec.js | 35 ---- package.json | 4 +- 6 files changed, 198 insertions(+), 82 deletions(-) delete mode 100644 app/src/routes/__image-check/+page.server.js delete mode 100644 app/src/routes/__image-check/+page.svelte create mode 100644 app/tests/class-list.spec.js diff --git a/app/playwright.config.js b/app/playwright.config.js index 0a18377..e7d9f6c 100644 --- a/app/playwright.config.js +++ b/app/playwright.config.js @@ -18,10 +18,10 @@ export default defineConfig({ : 'npm run dev -- --host 127.0.0.1 --port 4173', cwd: appDirectory, env: { - PLAYWRIGHT_IMAGE_CHECK: 'true' + DATABASE_URL: 'postgresql://postgres:localdev@127.0.0.1:5432/postgres' }, reuseExistingServer: !process.env.CI, - url: 'http://127.0.0.1:4173/__image-check' + port: 4173 }, projects: [ { diff --git a/app/src/routes/__image-check/+page.server.js b/app/src/routes/__image-check/+page.server.js deleted file mode 100644 index df79af7..0000000 --- a/app/src/routes/__image-check/+page.server.js +++ /dev/null @@ -1,8 +0,0 @@ -import { dev } from '$app/environment'; -import { error } from '@sveltejs/kit'; - -export function load() { - if (!dev && process.env.PLAYWRIGHT_IMAGE_CHECK !== 'true') { - error(404); - } -} diff --git a/app/src/routes/__image-check/+page.svelte b/app/src/routes/__image-check/+page.svelte deleted file mode 100644 index 6890d93..0000000 --- a/app/src/routes/__image-check/+page.svelte +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - Image Check - - -
-

Image Check

-

{imageEntries.length} class images

- - {#each imageEntries as image} - - {/each} -
diff --git a/app/tests/class-list.spec.js b/app/tests/class-list.spec.js new file mode 100644 index 0000000..93a3081 --- /dev/null +++ b/app/tests/class-list.spec.js @@ -0,0 +1,193 @@ +import { expect, test } from '@playwright/test'; + +// Requires the test database to be running locally: +// npm run test:db:up +// Then: +// npm run test:ui + +const HOME = '/'; + +test.describe.configure({ mode: 'serial' }); + +async function visibleCardCount(page) { + return page.locator('[data-testid="class-card"]').count(); +} + +async function cardHeadings(page) { + return page.locator('[data-testid="class-card"] h2').allTextContents(); +} + +async function visibleCategories(page) { + return page + .locator('[data-testid="class-card"]') + .evaluateAll((cards) => cards.map((c) => c.dataset.category)); +} + +test.describe('class list page', () => { + test('renders correctly', async ({ page }) => { + const failedImageResponses = []; + page.on('response', (response) => { + if (response.request().resourceType() === 'image' && !response.ok()) { + failedImageResponses.push(`${response.status()} ${response.url()}`); + } + }); + + await page.goto(HOME); + + await expect(page).toHaveTitle('Asmbly | Classes'); + + const cards = page.locator('[data-testid="class-card"]'); + await expect(cards.first()).toBeVisible(); + const count = await cards.count(); + expect(count).toBeGreaterThan(0); + await expect(page.getByText('Sorry, no results found')).toBeHidden(); + + for (let i = 0; i < count; i++) { + const card = cards.nth(i); + await expect(card.locator('img[alt$=" image"]')).toBeVisible(); + await expect(card.getByRole('heading', { level: 2 })).toBeVisible(); + await expect(card.getByRole('link', { name: /^learn more about/i })).toHaveAttribute( + 'href', + /^\/event\/\d+\?eventId=\d+$/ + ); + await expect(card.getByText(/^Price:/)).toBeVisible(); + } + + const brokenImages = await page + .locator('[data-testid="class-card"] img[alt$=" image"]') + .evaluateAll(async (images) => { + const failures = []; + for (const image of images) { + try { + await image.decode(); + } catch { + failures.push(`${image.alt}: decode failed`); + continue; + } + if (!image.complete || image.naturalWidth === 0 || image.naturalHeight === 0) { + failures.push(`${image.alt}: loaded with zero dimensions`); + } + } + return failures; + }); + + expect(failedImageResponses).toEqual([]); + expect(brokenImages).toEqual([]); + }); + + test('search works', async ({ page }) => { + await page.goto(HOME); + const baseline = await visibleCardCount(page); + expect(baseline).toBeGreaterThan(0); + + const firstWord = (await cardHeadings(page))[0].match(/[A-Za-z0-9]+/)?.[0]; + expect(firstWord, 'first card heading should contain a word').toBeTruthy(); + + const searchInput = page.getByPlaceholder('Search by class name...'); + + await searchInput.fill(firstWord); + await expect + .poll(async () => { + const current = await cardHeadings(page); + return ( + current.length > 0 && + current.some((h) => h.toLowerCase().includes(firstWord.toLowerCase())) + ); + }) + .toBe(true); + + await searchInput.fill('zzzqxnonsense'); + await expect(page.getByText('Sorry, no results found')).toBeVisible(); + + const clearButton = page.getByRole('button', { name: 'Clear Search' }); + await expect(clearButton).toBeVisible(); + await clearButton.click(); + await expect.poll(async () => visibleCardCount(page)).toBe(baseline); + }); + + test('sort works', async ({ page }) => { + await page.goto(HOME); + + await page.locator('select').selectOption('Name'); + + await expect + .poll(async () => { + const current = await cardHeadings(page); + const sorted = [...current].sort((a, b) => + a.toLowerCase().localeCompare(b.toLowerCase()) + ); + return current.length > 1 && JSON.stringify(current) === JSON.stringify(sorted); + }) + .toBe(true); + + const asc = await cardHeadings(page); + const expectedDesc = [...asc].reverse(); + + await page.locator('label').filter({ hasText: 'Sort Order' }).click(); + + await expect + .poll(async () => { + const current = await cardHeadings(page); + return JSON.stringify(current) === JSON.stringify(expectedDesc); + }) + .toBe(true); + }); + + test('filters work', async ({ page }) => { + await page.goto(HOME); + const baseline = await visibleCardCount(page); + expect(baseline).toBeGreaterThan(0); + + const targetCategory = [...new Set(await visibleCategories(page))].find(Boolean); + expect(targetCategory, 'expected at least one category among rendered cards').toBeTruthy(); + const categoryCheckbox = page.locator(`input[name="${targetCategory}"]`); + + await categoryCheckbox.check(); + await page.waitForURL(/archCategories=/); + const filteredCategories = await visibleCategories(page); + expect(filteredCategories.length).toBeGreaterThan(0); + expect(filteredCategories.every((c) => c === targetCategory)).toBe(true); + + await categoryCheckbox.uncheck(); + await expect.poll(async () => visibleCardCount(page)).toBe(baseline); + + await page.getByLabel('Group By Class Type').uncheck(); + await expect.poll(async () => visibleCardCount(page)).toBeGreaterThanOrEqual(baseline); + await page.getByLabel('Group By Class Type').check(); + await expect.poll(async () => visibleCardCount(page)).toBe(baseline); + + await page.getByLabel('Show Unscheduled Classes').uncheck(); + await expect.poll(async () => visibleCardCount(page)).toBeLessThanOrEqual(baseline); + }); + + test('navigation works', async ({ page }) => { + await page.goto(HOME); + + await expect(page.getByRole('link', { name: 'Mentor Series' }).first()).toHaveAttribute( + 'href', + 'https://asmbly.org/classes-events/mentor-series/' + ); + await expect(page.getByRole('link', { name: 'Classes FAQ' }).first()).toHaveAttribute( + 'href', + 'https://asmbly.org/faq/#classfaq' + ); + + const targetCategory = [...new Set(await visibleCategories(page))].find(Boolean); + expect(targetCategory).toBeTruthy(); + await page.goto(`/?sortBy=Name&archCategories=${encodeURIComponent(targetCategory)}`); + + await expect(page.locator('select')).toHaveValue('Name'); + await expect(page.locator(`input[name="${targetCategory}"]`)).toBeChecked(); + const restored = await visibleCategories(page); + expect(restored.length).toBeGreaterThan(0); + expect(restored.every((c) => c === targetCategory)).toBe(true); + + await page.goto(HOME); + await page + .locator('[data-testid="class-card"]') + .first() + .getByRole('link', { name: /^learn more about/i }) + .click(); + await expect(page).toHaveURL(/^.*\/event\/\d+\?eventId=\d+$/); + }); +}); diff --git a/app/tests/images.spec.js b/app/tests/images.spec.js index 1cdd40a..f6372a4 100644 --- a/app/tests/images.spec.js +++ b/app/tests/images.spec.js @@ -155,38 +155,3 @@ test('new class image filenames do not look like generated duplicate variants', ].join('\n') ).toEqual([]); }); - -test('class images load and decode in the browser', async ({ page }) => { - const failedImageResponses = []; - - page.on('response', (response) => { - if (response.request().resourceType() === 'image' && !response.ok()) { - failedImageResponses.push(`${response.status()} ${response.url()}`); - } - }); - - await page.goto('/__image-check'); - await expect(page.getByRole('heading', { name: 'Image Check' })).toBeVisible(); - - const brokenImages = await page.locator('[data-testid="class-image"]').evaluateAll(async (images) => { - const failures = []; - - for (const image of images) { - try { - await image.decode(); - } catch { - failures.push(`${image.dataset.sourcePath}: decode failed`); - continue; - } - - if (!image.complete || image.naturalWidth === 0 || image.naturalHeight === 0) { - failures.push(`${image.dataset.sourcePath}: loaded with zero dimensions`); - } - } - - return failures; - }); - - expect(failedImageResponses).toEqual([]); - expect(brokenImages).toEqual([]); -}); diff --git a/package.json b/package.json index a2a41f5..15f39c9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,8 @@ { "scripts": { - "test:images": "playwright test -c app/playwright.config.js" + "test": "playwright test -c app/playwright.config.js", + "test:db:up": "docker compose -f compose.test.yaml up -d --wait", + "test:db:down": "docker compose -f compose.test.yaml down -v" }, "devDependencies": { "@playwright/test": "^1.59.1" From 9cbcec4cf39b4c96123c55a3db9b535705fb5dd7 Mon Sep 17 00:00:00 2001 From: Kasif Gilbert Date: Thu, 14 May 2026 14:55:44 -0500 Subject: [PATCH 10/12] ci: replace image-check workflow with unified ui-tests workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename .github/workflows/image-check.yml to ui-tests.yml and rewrite it to run the full consolidated test suite (3 hygiene + 5 UI tests) instead of just the image-only spec. Git detects the rename, so existing references to image-check.yml in history remain navigable. Broaden the trigger to anything that can affect the rendered class list. The previous workflow ran only on changes to the image directory, classCard.svelte, the deleted __image-check route, and a handful of build files; the new workflow runs on: - app/** (any source change that can break the page render — routes, components, models, image files) - compose.test.yaml (database provisioning changes) - dev/dev-db.sql (test fixture changes that can legitimately move card counts and sort orders) - package.json, package-lock.json (test runner or dependency changes) - .github/workflows/ ui-tests.yml (the workflow itself) Restore `fetch-depth: 0` on the checkout step. The hygiene tests in app/tests/images.spec.js run `git diff --name-status origin/$GITHUB_BASE_REF...HEAD` to find image renames and deletions; a shallow clone (the default) does not contain the base ref and would either fail the diff or silently produce empty change sets, masking exactly the rename-into-fallback regressions the hygiene tests are designed to catch. Switch from `npm run test:ui` (and the previously separate `npm run test:images`) to `npm test`, which now runs all specs under app/tests/. The Postgres test database is provisioned via: docker compose -f compose.test.yaml up -d --wait The remaining steps mirror what a local contributor would run: npm ci && npm ci --prefix app npx playwright install --with-deps chromium npm exec --prefix app -- prisma generate --schema app/prisma/schema.prisma npm run build --prefix app npm test The build step is required because CI uses `npm run preview` rather than `npm run dev` (selected by the `process.env.CI` branch in app/playwright.config.js). It also serves as an implicit "no broken images" gate: enhanced:img processes every image in $lib/images/* during the build, so a corrupt or unprocessable file fails the workflow before tests even start. Naming the workflow ui-tests.yml (rather than tests.yml) leaves room for a future unit-tests.yml that would run vitest against the app package without provisioning a database or a browser. The two runners would then sit side by side under distinct workflow names rather than being conflated in one file. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../{image-check.yml => ui-tests.yml} | 32 ++++++------------- 1 file changed, 10 insertions(+), 22 deletions(-) rename .github/workflows/{image-check.yml => ui-tests.yml} (53%) diff --git a/.github/workflows/image-check.yml b/.github/workflows/ui-tests.yml similarity index 53% rename from .github/workflows/image-check.yml rename to .github/workflows/ui-tests.yml index 0a78906..849bb1f 100644 --- a/.github/workflows/image-check.yml +++ b/.github/workflows/ui-tests.yml @@ -1,29 +1,22 @@ -name: Image Check +name: UI Tests on: pull_request: paths: - - 'app/src/lib/images/**' - - 'app/src/lib/components/classCard.svelte' - - 'app/src/lib/components/myClassCard.svelte' - - 'app/src/lib/models/neonEventInstance.js' - - 'app/src/routes/__image-check/**' - - 'app/tests/**' - - 'app/package.json' - - 'app/package-lock.json' - - 'app/playwright.config.js' - - 'package.json' - - 'package-lock.json' - - '.github/workflows/image-check.yml' + - "app/**" + - "compose.test.yaml" + - "dev/dev-db.sql" + - "package.json" + - "package-lock.json" + - ".github/workflows/ui-tests.yml" jobs: - image-check: + ui-tests: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - - uses: actions/setup-node@v4 with: node-version: 20 @@ -31,15 +24,10 @@ jobs: cache-dependency-path: | package-lock.json app/package-lock.json - + - run: docker compose -f compose.test.yaml up -d --wait - run: npm ci - - run: npm ci --prefix app - - run: npx playwright install --with-deps chromium - - run: npm exec --prefix app -- prisma generate --schema app/prisma/schema.prisma - - run: npm run build --prefix app - - - run: npm run test:images + - run: npm test From 550c61ab58ce6ed2d16a61384b497404d485ef3a Mon Sep 17 00:00:00 2001 From: Kasif Gilbert Date: Thu, 14 May 2026 15:08:11 -0500 Subject: [PATCH 11/12] Revert "test: demonstrate detection of broken image rename" This reverts commit e51e4bd702e4bc9e4a2f0f8e049b67cfe5244706. --- .../{Woodshop_Safety_2.jpg => Woodshop_Safety.jpg} | Bin 1 file changed, 0 insertions(+), 0 deletions(-) rename app/src/lib/images/{Woodshop_Safety_2.jpg => Woodshop_Safety.jpg} (100%) diff --git a/app/src/lib/images/Woodshop_Safety_2.jpg b/app/src/lib/images/Woodshop_Safety.jpg similarity index 100% rename from app/src/lib/images/Woodshop_Safety_2.jpg rename to app/src/lib/images/Woodshop_Safety.jpg From 3c6e9379826fcdd8e0e2eb2c560dfdc89bedaad7 Mon Sep 17 00:00:00 2001 From: robz Date: Sun, 14 Jun 2026 10:14:47 -0500 Subject: [PATCH 12/12] revert test case about removing/renaming images --- app/tests/images.spec.js | 24 ------------------------ 1 file changed, 24 deletions(-) diff --git a/app/tests/images.spec.js b/app/tests/images.spec.js index f6372a4..171666c 100644 --- a/app/tests/images.spec.js +++ b/app/tests/images.spec.js @@ -96,10 +96,6 @@ function isAddedOrRenamed(change) { return change.status === 'A' || change.status === '??' || change.status === 'R'; } -function isDeletedOrRenamed(change) { - return change.status === 'D' || change.status === 'R'; -} - function hasGeneratedVariantSuffix(filename) { const basename = filename.replace(supportedImageExtension, ''); @@ -119,26 +115,6 @@ test('required fallback class images exist', async () => { expect(actualImageFilenames).toEqual(expect.arrayContaining(requiredFallbackImages)); }); -test('class image changes do not remove or rename existing filenames', () => { - const destructiveImageChanges = getImageChanges() - .filter(isDeletedOrRenamed) - .map((change) => { - if (change.newPath) { - return `${change.path} -> ${change.newPath}`; - } - - return change.path; - }); - - expect( - destructiveImageChanges, - [ - 'Existing class image filenames are part of the app lookup contract.', - 'Add new image files freely, but do not delete or rename existing ones without updating the lookup behavior and tests.' - ].join('\n') - ).toEqual([]); -}); - test('new class image filenames do not look like generated duplicate variants', () => { const generatedVariantFilenames = getImageChanges() .filter(isAddedOrRenamed)