diff --git a/AGENTS.md b/AGENTS.md index 0b921e22c..369c05da2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,14 +60,17 @@ do not choose a new design merely to make a check pass. ## Required verification Work in approved page/asset batches. During editing, run affected focused checks -and record a preview as an iteration, not a release candidate. Run the complete -`pnpm validate:development` gate for the exact final review candidate before -commit/PR and again on the integrated tree after conflicts. Use the same gate on -contributor and maintainer sides. This comprehensive runner remains an explicit -command; do not add it to ordinary build, development, or commit-hook commands. -Public builds keep their translation and shared-visual checks, and the commit -hook keeps attribution and staged lint checks. Avoid repeating the full gate -immediately before `pnpm build`. +and record a preview as an iteration, not a release candidate. Use the reviewed +combined test engine for the exact final candidate, with repository-owned tests +and supplemental checks discovered for that source revision. Use the same +source-specific scope on contributor and maintainer sides. The retired archived +comprehensive suite is not a second mandatory prebuild run. Compilation follows +passing tests and is a separate action; build guards remain required. Existing +public commands and commit hooks are not changed by these instructions. Inspect +their actual bindings and reconcile obsolete gate instructions explicitly before +integration; do not disable hooks or bypass a failure. Avoid repeating a complete +suite or production compile for an unchanged candidate merely because two stages +invoke it; report any still-required duplicate binding rather than hiding it. Follow the fix-it audit too: prose instructions are not executable tests. Inspect the plan/inventory for connected native/source/DOM/style suites; report actual execution, counts, skips and exclusions separately from discovery. @@ -102,6 +105,53 @@ collections, watchlists, or databases. A mocked provider pass is not a live round-trip pass. Perform desktop/narrow and interaction review of changed roles; John's visual acceptance remains a release gate for this interface work. +## Test-engine maintenance for contributors and maintainers + +Read `tools/validation-engine/README.md` and the extracted engine setup guide +before using the saved engine. This is a preserved reusable implementation and +reference packet, not an automatically installed package command. Extract outside +test discovery paths. Do not copy archived test files over the chosen source. + +The engine runs the repository's existing test files through their compatible +runners; tests do not need rewriting into an engine-specific format. On every +preview change or upstream merge: + +1. Identify changed behavior and all affected test owners, fixtures, mocks, + selectors and shared contracts. Inspect workflow, package and runner discovery + too; a test not used by GitHub can still be required local coverage. +2. Maintain affected tests and fixtures with the implementation. Preserve valid + canonical GitHub assertions. When an approved behavior supersedes an obsolete + local expectation, document the replacement and retain meaningful positive + and negative coverage. Never change application styling or weaken a test merely + to obtain a pass. Update the Fix-it guide for a proven reusable failure mode. +3. Refresh and review discovery, ownership, expected case identities, dependency + impact mappings and source/lock/runtime/recipe pins for the actual candidate. + Add newly introduced tests; remove duplicates only with proved equivalent + coverage on our supplemental side. Do not reuse reference file counts, pass + receipts or a previous revision's inventory as current acceptance. +4. Run focused affected checks during development. For final verification, queue + independent files concurrently using detected effective CPU capacity and a + sealed worker budget, dependency-aware priorities and staggered setup types. + Preserve timing-sensitive/global-state barriers and per-file disposable + fixtures. Share only proven immutable cached inputs, never mutable databases, + mocks or test state. Unknown setup requirements keep the conservative path. +5. Queue genuine failures for an authorized agent or maintainer to diagnose and + repair. The engine does not itself invent fixes. Coordinate conflicting writes + per file, preserve unrelated edits, verify base/after hashes, then freeze a new + candidate and rerun failed tests plus transitive affected checks. Retain green + results only when complete input closures prove them unchanged; unknown impact + requires broader verification. Record raw failures and actual retest receipts. +6. Compile the unchanged passing candidate once at the authorized build gate. + Keep CSS/i18n/security/network guards and separate browser, CodeQL, platform, + packaging and deployment gates visible. A test-engine pass is not the entire + GitHub pipeline. Report files, cases, failures, skips, elapsed time, worker + budget, repairs and remaining gates; visual approval occurs during development + and review, not an exhaustive visual crawl before every compile. + +The saved 3.48.1 reference packet does not establish acceptance of this preview or +a later merge. A version-specific compiler experiment is optional, not authority +to patch dependencies or transplant configuration into a different revision. + ## Safe collaboration and records Recommend a helper when an independent task can proceed while the user reviews diff --git a/bin/local-validation.mjs b/bin/local-validation.mjs index 0c9f32ddf..e9c689ffd 100644 --- a/bin/local-validation.mjs +++ b/bin/local-validation.mjs @@ -96,7 +96,7 @@ export function validateGovernanceSources(agents, hook) { 'docs/maintainers/ui-style-standard.md', 'docs/maintainers/ui-fix-it.md', 'docs/maintainers/ui-forward-merge-guide.md', - 'pnpm validate:development', + 'tools/validation-engine/README.md', ]) { if (!agents.includes(route)) throw new Error( @@ -187,6 +187,7 @@ export function preflight( 'docs/maintainers/ui-style-standard.md', 'docs/maintainers/ui-fix-it.md', 'docs/maintainers/ui-forward-merge-guide.md', + 'tools/validation-engine/README.md', ]) requireFile(root, file); validateGovernanceSources( diff --git a/bin/local-validation.test.mjs b/bin/local-validation.test.mjs index 48347021a..4e0355bb0 100644 --- a/bin/local-validation.test.mjs +++ b/bin/local-validation.test.mjs @@ -297,9 +297,9 @@ test('the comprehensive gate is an explicit package command while ordinary scrip ); }); -test('agent routes require the manual full gate while the normal hook stays bounded', () => { +test('agent routes require the current engine while the normal hook stays bounded', () => { const agents = - 'Read docs/maintainers/ui-style-standard.md docs/maintainers/ui-fix-it.md docs/maintainers/ui-forward-merge-guide.md and run pnpm validate:development'; + 'Read docs/maintainers/ui-style-standard.md docs/maintainers/ui-fix-it.md docs/maintainers/ui-forward-merge-guide.md and follow tools/validation-engine/README.md'; const hook = '[ -n "$HUSKY_BYPASS" ] || pnpm attribution:check || exit $?\npnpm exec lint-staged || exit $?\n'; assert.doesNotThrow(() => validateGovernanceSources(agents, hook)); @@ -307,7 +307,7 @@ test('agent routes require the manual full gate while the normal hook stays boun 'docs/maintainers/ui-style-standard.md', 'docs/maintainers/ui-fix-it.md', 'docs/maintainers/ui-forward-merge-guide.md', - 'pnpm validate:development', + 'tools/validation-engine/README.md', ]) assert.throws( () => validateGovernanceSources(agents.replace(missing, ''), hook), @@ -376,7 +376,7 @@ test('runtime preflight rejects unsupported engines, dependency drift, and incom ); f.write( 'AGENTS.md', - 'docs/maintainers/ui-style-standard.md docs/maintainers/ui-fix-it.md docs/maintainers/ui-forward-merge-guide.md pnpm validate:development' + 'docs/maintainers/ui-style-standard.md docs/maintainers/ui-fix-it.md docs/maintainers/ui-forward-merge-guide.md tools/validation-engine/README.md' ); f.write( '.husky/pre-commit', @@ -386,6 +386,7 @@ test('runtime preflight rejects unsupported engines, dependency drift, and incom 'docs/maintainers/ui-style-standard.md', 'docs/maintainers/ui-fix-it.md', 'docs/maintainers/ui-forward-merge-guide.md', + 'tools/validation-engine/README.md', ]) f.write(path, '# Required source document'); assert.doesNotThrow(() => diff --git a/cypress/e2e/discover.cy.ts b/cypress/e2e/discover.cy.ts index a9cf7e818..1d2d2594d 100644 --- a/cypress/e2e/discover.cy.ts +++ b/cypress/e2e/discover.cy.ts @@ -53,21 +53,21 @@ describe('Discover', () => { }); it('loads a trending item', () => { + const movie = { + id: 438148, + mediaType: 'movie', + title: 'Minions: The Rise of Gru', + overview: '', + releaseDate: '2022-06-29', + posterPath: null, + }; cy.intercept('GET', '/api/v1/discover/trending*', { page: 1, totalPages: 1, totalResults: 1, - results: [ - { - id: 438148, - mediaType: 'movie', - title: 'Minions: The Rise of Gru', - overview: '', - releaseDate: '2022-06-29', - posterPath: null, - }, - ], + results: [movie], }); + stubTitleDetails('movie', movie); cy.visit('/'); cy.contains('.slider-header', 'Trending').scrollIntoView(); cy.contains('[data-testid=title-card-title]', 'Minions: The Rise of Gru', { @@ -76,21 +76,21 @@ describe('Discover', () => { }); it('loads popular movies', () => { + const movie = { + id: 438148, + mediaType: 'movie', + title: 'Minions: The Rise of Gru', + overview: '', + releaseDate: '2022-06-29', + posterPath: null, + }; cy.intercept('GET', '/api/v1/discover/movies*', { page: 1, totalPages: 1, totalResults: 1, - results: [ - { - id: 438148, - mediaType: 'movie', - title: 'Minions: The Rise of Gru', - overview: '', - releaseDate: '2022-06-29', - posterPath: null, - }, - ], + results: [movie], }); + stubTitleDetails('movie', movie); cy.visit('/'); cy.contains('.slider-header', 'Popular Movies').scrollIntoView(); cy.contains('[data-testid=title-card-title]', 'Minions: The Rise of Gru', { diff --git a/cypress/e2e/library-discover-parity.cy.ts b/cypress/e2e/library-discover-parity.cy.ts index c7ca4c51b..2d7fcd727 100644 --- a/cypress/e2e/library-discover-parity.cy.ts +++ b/cypress/e2e/library-discover-parity.cy.ts @@ -132,9 +132,18 @@ describe('Books and Music discover parity', () => { }); themePalettes.forEach((palette) => { + // Palettes now belong to the signed-in account. Save through the real + // preference API before reload; a legacy browser value must not win. + cy.request('/api/v1/auth/me').then(({ body: user }) => { + cy.request('POST', `/api/v1/user/${user.id}/settings/theme`, { + palette, + }) + .its('body.themePalette') + .should('eq', palette); + }); cy.visit('/discover/movies', { onBeforeLoad(win) { - win.localStorage.setItem('seerr-theme-palette', palette); + win.localStorage.setItem('seerr-theme-palette', 'classic'); win.localStorage.setItem('seerr-theme-mode', 'dark'); }, }); @@ -160,7 +169,7 @@ describe('Books and Music discover parity', () => { cy.visit('/discover/movies', { onBeforeLoad(win) { - win.localStorage.setItem('seerr-theme-palette', palette); + win.localStorage.setItem('seerr-theme-palette', 'classic'); win.localStorage.setItem('seerr-theme-mode', 'light'); }, }); diff --git a/docs/maintainers/interface-integration-checkpoint.md b/docs/maintainers/interface-integration-checkpoint.md index e855ede62..244d26855 100644 --- a/docs/maintainers/interface-integration-checkpoint.md +++ b/docs/maintainers/interface-integration-checkpoint.md @@ -299,3 +299,63 @@ Fill these fields with observed facts; unknown means pending, never assumed. The contributor and Keith's AI must use the same checked-in instructions and gate, adapted to the newer target without discarding its valid security/backend fixes. If the target advances, pin the new head and repeat invalidated checks. + +## Preview merge and validation — October 4, 2026 + +This section supersedes earlier descriptions of the current command bindings and +publication authority; historical receipts above remain unchanged. John selected +the accepted preview and authorized integration with Keith's current main, +scoped Fix-it repairs, source-specific engine validation, separate guarded +compilation, and a laptop preview on port 5071 with an independent copy of the +5070 configuration/database. The final preview is shown only after those checks +complete. John's visual review remains required before any PR. + +The pinned upstream base is `a96fafa07c77a2d6d95badeb9f60c32a6b4a47c9` +(3.48.3). The exact accepted working-byte checkpoint is +`d9338138acf269a37018d2fedc7b7daa8dce2d2d`, whose common ancestor with upstream +is `e7305281797cd7527c3b1c0a83ff144218ad506a`. The three-way inventory contains +311 accepted paths and 361 upstream paths, 308 overlapping paths, and eight +manual conflicts. The accepted screen semantics are already present upstream; +MediaSlider retains its newer TMDB stale/error retry notice and poster fallback. +Testing instructions are reconciled to the reviewed engine, while actual public +package commands and hooks preserve their existing bindings. + +The reviewed combined engine uses this integrated candidate's existing tests and +supplemental checks with fresh inventory, case ledger and source/environment +pins. The archived comprehensive runner is not an additional mandatory test run. +`pnpm build` runs translation/shared-visual guards and compilation separately. +The saved 3.48.1 engine receipt remains historical and cannot certify this source. +All iterative source, dependencies, evidence, caches, test and compile work remain +in Docker-managed Linux storage. The original accepted preview remains preserved. + +Current evidence and the bounded Fix-it repair ledger are being recorded in the +owned Linux evidence volume. Final tests, compile, deployment health and visual +acceptance remain pending until their actual receipts are verified. No live +provider writes are authorized by this validation. + +## Theme adoption and publication authority — October 4, 2026 + +John subsequently requested a one-time switch to SeerrNG for new installations +and existing accounts upgrading to this build or a later build containing this +migration. The stable migration adds an account palette default and clears old +active advanced-theme overrides once. After login, another supported palette +can be selected and saved to that account. Later logins and upgrades retain that +choice; the migration is not a per-version or per-login reset. Appearance mode +and unrelated account preferences are preserved. + +John explicitly authorized submission to `snapetech/seerrng` after passing tests, +using his previously approved AI disclosure, and automatic follow-up repairs +for relevant PR failures. This supersedes the earlier pending preview-review +publication gate. It does not certify new physical drag/touch, browser/Cypress, +live-provider or PostgreSQL-service testing. The accepted visual decisions remain +his; no new whole-site visual acceptance is claimed. + +The pre-theme integrated tree passed the full reviewed engine (3,994 passed, +four existing conditional PostgreSQL skips, zero failures; 555 files) in +344.227 seconds. Its separate guarded compilation completed in 117.572 seconds, +after 11.150 seconds of prebuild guards. Those are historical receipts for tree +`473af0fd9c7479c9e58c043115e285727691ccd7`, not proof for the theme amendment. +John explicitly waived another compile and requested tests only for the amended +source. A fresh source-bound engine run and static checks must establish that +amendment's actual result before publication. No production deployment or merge +of Keith's PR is authorized by this overnight finalization request. diff --git a/docs/maintainers/ui-fix-it.md b/docs/maintainers/ui-fix-it.md index 569f14730..53f4522e0 100644 --- a/docs/maintainers/ui-fix-it.md +++ b/docs/maintainers/ui-fix-it.md @@ -98,7 +98,7 @@ Use a suitable disposable toolchain and record its versions. Plan discovery does not certify these external prerequisites. Missing inputs block finalization, not permission to skip suites, change CI or install tools into the live preview. -1. Inspect `pnpm validate:development --plan`. During an approved edit batch run affected focused checks and label preview evidence as iterative. Run the full gate on the exact final review candidate before commit/PR and on the integrated tree after conflicts. The comprehensive runner is explicit; ordinary build/development scripts and the commit hook retain their existing lightweight checks. `pnpm build` runs translation and shared-visual checks before production compilation. Internal compile aliases are not finalization shortcuts. `pnpm test` shares test partitions only; `pnpm test:ci` is Vitest-only. Browser/Cypress checks are separate. Retain discovery and executed results separately, including counts/skips/platform exclusions; Linux is required for full POSIX tooling parity. Existing CI remains unchanged. +1. Read `tools/validation-engine/README.md` and the extracted engine setup instructions. Regenerate and review the selected source's test inventory, expected cases, ownership, dependency closure, source/dependency/runtime hashes and invocation packets. During repairs, run affected focused checks and label preview evidence as iterative. Run the reviewed combined engine against existing repository tests and source-specific supplemental checks on the exact final candidate and the integrated tree after conflicts. Keep mutable config/database fixtures in disposable scratch storage; preserve provider/network guards and global-state barriers. Queue failures for reviewed diagnosis and repair, then rerun failed checks and their affected dependency closure. The saved 3.48.1 receipt is historical evidence, not acceptance of this candidate. Compile after passing tests with required translation/shared-visual build guards. Public package commands and hooks remain unchanged; the archived comprehensive runner is not a redundant second mandatory run. Browser/Cypress and CI remain separate gates. Retain discovery and executed results separately, including counts/skips/platform exclusions; Linux is required for full POSIX tooling parity. 2. Run focused source/AST checks, effective CSS checks and behavioral tests for changed owners and affected consumers. Reuse existing focused tests instead of building a second verifier architecture or performing an unrelated whole-site audit. 3. Assertions target roles, relationships and actual behavior. Parse CSS correctly across grouped/nested rules; do not use stale utility-name assertions, naive first-brace extraction, or exact text examples as proof of styling. 4. Run proportional lint/type checking and compilation for changed source types/style owners. Check service health only after authorized publication. Compile/type/HTTP success is not human visual acceptance or a successful provider round trip. diff --git a/docs/maintainers/ui-forward-merge-guide.md b/docs/maintainers/ui-forward-merge-guide.md index a3f63a423..fa8bd429a 100644 --- a/docs/maintainers/ui-forward-merge-guide.md +++ b/docs/maintainers/ui-forward-merge-guide.md @@ -66,12 +66,14 @@ workflows or waive required checks. Retain the source manifest and tool versions 1. Follow the fix-it audit against changed assets and their affected consumers, including effective CSS cascade, structural ownership, dynamic/secondary class props, hit areas, fixed headers versus scrollers, state, and narrow layouts. - Run `pnpm validate:development --plan` to inspect connected suites, then run - `pnpm validate:development` and one `pnpm build` on the exact integrated tree. - The full runner is explicit; `pnpm build` runs translation and shared-visual - checks before production compilation. Keep an unchanged-source receipt between - validation and build. Do not run the full gate twice. Any failed required - check blocks finalization; do not bypass a hook or redefine success. + Read `tools/validation-engine/README.md` and the extracted setup instructions. + Regenerate and review the source-specific inventory and invocation plan, then + run the combined engine on the exact integrated candidate. Preserve an + unchanged-source receipt, then run one guarded `pnpm build`. The current build + runs translation/shared-visual guards and compilation; it does not run the + engine. Do not repeat the archived comprehensive gate after engine acceptance. + Any failed required check blocks finalization; do not bypass a hook or redefine + success. 2. Retain validation and compilation results with the target's pinned runtime/dependencies. If validation and compilation are separate phases, retain an exact unchanged-source receipt before using `build:compile`; diff --git a/docs/maintainers/ui-style-standard.md b/docs/maintainers/ui-style-standard.md index 1f9cd2e26..6a8672571 100644 --- a/docs/maintainers/ui-style-standard.md +++ b/docs/maintainers/ui-style-standard.md @@ -159,7 +159,7 @@ These are implementation contracts, not a statement that a staged control has be ## 12. Verification, acceptance and records -1. Use affected focused checks during approved page/asset iterations; label their previews as iterations, not completed review candidates. Inspect discovery with `pnpm validate:development --plan`, then run the complete gate on the exact final candidate before commit/PR and on the integrated tree after conflicts. The comprehensive runner stays explicit; ordinary build, development and commit-hook commands retain their existing lightweight checks. `pnpm build` runs translation and shared-visual checks before production compilation. Internal compile aliases and bypassable local hooks are not finalization evidence. Existing CI workflows remain unchanged. +1. Use affected focused checks during approved page/asset iterations; label their previews as iterations, not completed review candidates. For the exact final candidate and the integrated tree after conflicts, use the reviewed combined test engine with repository-owned tests and supplemental checks discovered for that source revision; refresh and inspect its source-specific plan and inventory before the cumulative run. Compilation follows passing tests as a separate action, with required build guards, and an unchanged candidate does not repeat a complete suite or production compile merely because multiple stages invoke it. Existing public build/development commands, local commit hooks, and CI workflows remain unchanged unless their actual bindings are deliberately revised and validated. Internal compile aliases and bypassable local hooks are not finalization evidence. 2. Separate source ownership checks, compiled/effective CSS checks, component behavior tests, type/lint results, HTTP health, human visual acceptance and real provider round trips. None substitutes for another. State the audited dependency scope and exclusions explicitly. 3. Generic checks target roles/owners and relationships, not exact example headings, media names, routes or message text. Parse dynamic class composition, rendered branches and transitions; verify effective native CSS rather than stale Tailwind token strings or naive first-brace extraction. 4. A failed ownership/security gate blocks completion. Unknown semantics, conflicting standards or a new reusable family require discussion. Known unrelated baseline failures require an explicit scoped record, not deletion or weakening of the assertion. diff --git a/release-notes/20261004-seerrng-theme-adoption.md b/release-notes/20261004-seerrng-theme-adoption.md new file mode 100644 index 000000000..4beaac3d4 --- /dev/null +++ b/release-notes/20261004-seerrng-theme-adoption.md @@ -0,0 +1,9 @@ +--- +category: changed +audience: users, operators +area: themes +action: none +breaking: false +--- + +New installations and upgrades adopt the SeerrNG theme once, resetting older active theme customizations. After signing in, users can choose another theme or customize it; their new account preference survives future logins and upgrades. diff --git a/seerr-api.yml b/seerr-api.yml index 602fddc6b..2bc684a5d 100644 --- a/seerr-api.yml +++ b/seerr-api.yml @@ -1036,6 +1036,9 @@ components: UserSettings: type: object properties: + themePalette: + type: string + description: Account theme preference. SeerrNG is adopted once on installation or upgrade; subsequent user choices survive later upgrades. username: type: string nullable: true @@ -9954,6 +9957,44 @@ paths: type: object additionalProperties: type: string + /user/{userId}/settings/theme: + post: + summary: Save the signed-in user's theme preference + tags: [users] + parameters: + - in: path + name: userId + required: true + schema: + type: number + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: false + required: [palette] + properties: + palette: + type: string + maxLength: 32 + responses: + '200': + description: Confirmed account theme preference + content: + application/json: + schema: + type: object + additionalProperties: false + required: [themePalette] + properties: + themePalette: + type: string + '400': + description: Invalid theme palette + '403': + description: Only the signed-in user can change their theme /user/{userId}/settings/advanced-theme: post: summary: Save or clear a user's advanced theme overrides @@ -10448,9 +10489,6 @@ paths: totalResults: type: number example: 200 - stale: - type: boolean - description: True when saved results are served because TMDB is unavailable. results: type: array items: @@ -10772,6 +10810,9 @@ paths: totalResults: type: number example: 200 + stale: + type: boolean + description: True when saved results are served because TMDB is unavailable. results: type: array items: diff --git a/server/entity/UserSettings.ts b/server/entity/UserSettings.ts index 00fa7e797..65bc172d8 100644 --- a/server/entity/UserSettings.ts +++ b/server/entity/UserSettings.ts @@ -235,6 +235,9 @@ export class UserSettings { @Column({ type: 'simple-json', nullable: true }) public advancedThemeOverrides?: AdvancedThemeOverrides | null; + @Column({ default: 'seerr' }) + public themePalette: string = 'seerr'; + @Column({ type: 'simple-json', nullable: true }) public requestRootFolders?: UserRequestRootFolders; @@ -290,6 +293,7 @@ export class UserSettings { detailDisclosureOrder: this.detailDisclosureOrder, mediaFilterPins: this.mediaFilterPins, advancedThemeOverrides: this.advancedThemeOverrides, + themePalette: this.themePalette, }; } diff --git a/server/migration/postgres/1791080000000-AdoptSeerrngTheme.ts b/server/migration/postgres/1791080000000-AdoptSeerrngTheme.ts new file mode 100644 index 000000000..3f03b1614 --- /dev/null +++ b/server/migration/postgres/1791080000000-AdoptSeerrngTheme.ts @@ -0,0 +1,24 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AdoptSeerrngTheme1791080000000 implements MigrationInterface { + name = 'AdoptSeerrngTheme1791080000000'; + + async up(runner: QueryRunner): Promise { + // Never repeat the reset after a user has chosen their new default. + if (await runner.hasColumn('user_settings', 'themePalette')) return; + await runner.query( + 'ALTER TABLE "user_settings" ADD COLUMN "themePalette" varchar NOT NULL DEFAULT \'seerr\'' + ); + if (await runner.hasColumn('user_settings', 'advancedThemeOverrides')) { + await runner.query( + 'UPDATE "user_settings" SET "advancedThemeOverrides" = NULL' + ); + } + } + + async down(runner: QueryRunner): Promise { + await runner.query( + 'ALTER TABLE "user_settings" DROP COLUMN "themePalette"' + ); + } +} diff --git a/server/migration/sqlite/1791080000000-AdoptSeerrngTheme.ts b/server/migration/sqlite/1791080000000-AdoptSeerrngTheme.ts new file mode 100644 index 000000000..3f03b1614 --- /dev/null +++ b/server/migration/sqlite/1791080000000-AdoptSeerrngTheme.ts @@ -0,0 +1,24 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AdoptSeerrngTheme1791080000000 implements MigrationInterface { + name = 'AdoptSeerrngTheme1791080000000'; + + async up(runner: QueryRunner): Promise { + // Never repeat the reset after a user has chosen their new default. + if (await runner.hasColumn('user_settings', 'themePalette')) return; + await runner.query( + 'ALTER TABLE "user_settings" ADD COLUMN "themePalette" varchar NOT NULL DEFAULT \'seerr\'' + ); + if (await runner.hasColumn('user_settings', 'advancedThemeOverrides')) { + await runner.query( + 'UPDATE "user_settings" SET "advancedThemeOverrides" = NULL' + ); + } + } + + async down(runner: QueryRunner): Promise { + await runner.query( + 'ALTER TABLE "user_settings" DROP COLUMN "themePalette"' + ); + } +} diff --git a/server/migration/themeAdoption.test.ts b/server/migration/themeAdoption.test.ts new file mode 100644 index 000000000..564de9b12 --- /dev/null +++ b/server/migration/themeAdoption.test.ts @@ -0,0 +1,119 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { DataSource } from 'typeorm'; +import { AdoptSeerrngTheme1791080000000 as PostgresAdoption } from './postgres/1791080000000-AdoptSeerrngTheme'; +import { AdoptSeerrngTheme1791080000000 as SqliteAdoption } from './sqlite/1791080000000-AdoptSeerrngTheme'; + +const createLegacyDatabase = async ( + migrations = [] as (typeof SqliteAdoption)[] +) => { + const database = await new DataSource({ + type: 'better-sqlite3', + database: ':memory:', + migrations, + }).initialize(); + await database.query( + 'CREATE TABLE "user_settings" ("id" integer PRIMARY KEY, "advancedThemeOverrides" text, "locale" varchar)' + ); + return database; +}; + +test('both portable adoption migrations default new settings to SeerrNG', async () => { + // Execute both portable SQL implementations on isolated SQLite. This does + // not claim a PostgreSQL service/integration run. + for (const Migration of [SqliteAdoption, PostgresAdoption]) { + const database = await createLegacyDatabase(); + const runner = database.createQueryRunner(); + try { + await new Migration().up(runner); + await database.query('INSERT INTO "user_settings" ("id") VALUES (1)'); + assert.deepEqual( + await database.query('SELECT "themePalette" FROM "user_settings"'), + [{ themePalette: 'seerr' }] + ); + } finally { + await runner.release(); + await database.destroy(); + } + } +}); + +test('upgrade switches all old themes once and preserves later choices and unrelated settings', async () => { + for (const Migration of [SqliteAdoption, PostgresAdoption]) { + const database = await createLegacyDatabase(); + const runner = database.createQueryRunner(); + const migration = new Migration(); + try { + await database.query( + 'INSERT INTO "user_settings" VALUES (1, ?, ?), (2, ?, ?)', + ['{"preset":"legacy"}', 'en', '{"--theme-page-bg":"#123456"}', 'fr'] + ); + await migration.up(runner); + assert.deepEqual( + await database.query('SELECT * FROM "user_settings" ORDER BY "id"'), + [ + { + id: 1, + advancedThemeOverrides: null, + locale: 'en', + themePalette: 'seerr', + }, + { + id: 2, + advancedThemeOverrides: null, + locale: 'fr', + themePalette: 'seerr', + }, + ] + ); + await database.query( + 'UPDATE "user_settings" SET "themePalette" = ?, "advancedThemeOverrides" = ? WHERE "id" = 1', + ['aurora', '{"--theme-page-bg":"#abcdef"}'] + ); + await migration.up(runner); + assert.deepEqual( + await database.query( + 'SELECT "themePalette", "advancedThemeOverrides" FROM "user_settings" WHERE "id" = 1' + ), + [ + { + themePalette: 'aurora', + advancedThemeOverrides: '{"--theme-page-bg":"#abcdef"}', + }, + ] + ); + await migration.down(runner); + assert.equal( + await runner.hasColumn('user_settings', 'themePalette'), + false + ); + assert.deepEqual( + await database.query( + 'SELECT "locale" FROM "user_settings" ORDER BY "id"' + ), + [{ locale: 'en' }, { locale: 'fr' }] + ); + } finally { + await runner.release(); + await database.destroy(); + } + } +}); + +test('migration history prevents later upgrades from resetting a saved theme', async () => { + const database = await createLegacyDatabase([SqliteAdoption]); + try { + await database.runMigrations(); + await database.query( + 'INSERT INTO "user_settings" ("id", "themePalette") VALUES (1, ?)', + ['classic'] + ); + assert.equal((await database.runMigrations()).length, 0); + assert.deepEqual( + await database.query('SELECT "themePalette" FROM "user_settings"'), + [{ themePalette: 'classic' }] + ); + } finally { + await database.destroy(); + } +}); diff --git a/server/routes/discover.openapi.test.ts b/server/routes/discover.openapi.test.ts index 3590308a1..d81c5dc61 100644 --- a/server/routes/discover.openapi.test.ts +++ b/server/routes/discover.openapi.test.ts @@ -1,12 +1,35 @@ import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; import path from 'node:path'; import { describe, it } from 'node:test'; import type { Express } from 'express'; import express from 'express'; import * as OpenApiValidator from 'express-openapi-validator'; +import * as yaml from 'js-yaml'; import request from 'supertest'; +const api = yaml.load( + readFileSync(path.join(process.cwd(), 'seerr-api.yml'), 'utf8') +) as { + paths: Record< + string, + { + get: { + responses: { + '200': { + content: { + 'application/json': { + schema: { properties: Record }; + }; + }; + }; + }; + }; + } + >; +}; + const createValidatedApp = (): Express => { const app = express(); app.use( @@ -73,3 +96,22 @@ describe('book discovery responseVersion OpenAPI contract', () => { assert.match(response.body.message, /responseVersion/); }); }); + +describe('movie discovery outage fallback OpenAPI contract', () => { + it('documents stale results on movie discovery rather than generic search', () => { + const movieDiscoveryProperties = + api.paths['/discover/movies'].get.responses['200'].content[ + 'application/json' + ].schema.properties; + const searchProperties = + api.paths['/search'].get.responses['200'].content['application/json'] + .schema.properties; + + assert.deepStrictEqual(movieDiscoveryProperties.stale, { + type: 'boolean', + description: + 'True when saved results are served because TMDB is unavailable.', + }); + assert.strictEqual(searchProperties.stale, undefined); + }); +}); diff --git a/server/routes/user/usersettings.test.ts b/server/routes/user/usersettings.test.ts index afbab0e4b..a52a2b526 100644 --- a/server/routes/user/usersettings.test.ts +++ b/server/routes/user/usersettings.test.ts @@ -160,6 +160,72 @@ describe('POST /user/:id/settings/linked-accounts/jellyfin/quickconnect', () => }); }); +describe('POST /user/:id/settings/theme', () => { + it('starts on SeerrNG and preserves a newly saved account theme through later logins', async () => { + const { sessionCookie, userId } = await loginAs( + 'demo@seerr.dev', + 'test1234' + ); + const repository = getRepository(User); + const initial = await repository.findOneOrFail({ where: { id: userId } }); + assert.equal(initial.settings?.themePalette ?? 'seerr', 'seerr'); + const response = await request(app) + .post(`/user/${userId}/settings/theme`) + .set('X-Forwarded-Proto', 'https') + .set('Cookie', sessionCookie) + .send({ palette: 'aurora' }); + assert.equal(response.status, 200, JSON.stringify(response.body)); + assert.deepEqual(response.body, { themePalette: 'aurora' }); + const secondLogin = await loginAs('demo@seerr.dev', 'test1234'); + const current = await request(app) + .get('/auth/me') + .set('X-Forwarded-Proto', 'https') + .set('Cookie', secondLogin.sessionCookie); + assert.equal(current.status, 200); + assert.equal(current.body.settings.themePalette, 'aurora'); + const saved = await repository.findOneOrFail({ where: { id: userId } }); + assert.equal(saved.settings?.themePalette, 'aurora'); + }); + + it('rejects malformed and unsupported choices without changing the saved theme', async () => { + const { sessionCookie, userId } = await loginAs( + 'demo@seerr.dev', + 'test1234' + ); + for (const body of [ + {}, + { palette: 'unknown' }, + { palette: 1 }, + { palette: 'classic', extra: true }, + { palette: 'x'.repeat(100) }, + ]) { + const response = await request(app) + .post(`/user/${userId}/settings/theme`) + .set('X-Forwarded-Proto', 'https') + .set('Cookie', sessionCookie) + .send(body); + assert.equal(response.status, 400); + } + const saved = await getRepository(User).findOneOrFail({ + where: { id: userId }, + }); + assert.equal(saved.settings?.themePalette ?? 'seerr', 'seerr'); + }); + + it('does not allow one user to set another user theme', async () => { + const { sessionCookie, userId } = await loginAs( + 'demo@seerr.dev', + 'test1234' + ); + const response = await request(app) + .post(`/user/${userId + 1}/settings/theme`) + .set('X-Forwarded-Proto', 'https') + .set('Cookie', sessionCookie) + .send({ palette: 'classic' }); + assert.equal(response.status, 403); + }); +}); + describe('POST /user/:id/settings/advanced-theme', () => { it('saves validated overrides for the signed-in user and clears them on reset', async () => { const { sessionCookie, userId } = await loginAs( diff --git a/server/routes/user/usersettings.ts b/server/routes/user/usersettings.ts index dc2694fe0..99cd962f5 100644 --- a/server/routes/user/usersettings.ts +++ b/server/routes/user/usersettings.ts @@ -72,6 +72,7 @@ import { preserveRedactedSecrets, redactSecrets, } from '@server/utils/security'; +import { parseThemePalette } from '@server/utils/themePreference'; import { parseBoundedString, parseOptionalBodyBoolean, @@ -281,6 +282,48 @@ userSettingsRoutes.post<{ id: string; scope: string }>( } ); +userSettingsRoutes.post<{ id: string }>( + '/theme', + isOwnProfile(), + async (req, res, next) => { + const palette = + req.body && !Array.isArray(req.body) && Object.keys(req.body).length === 1 + ? parseThemePalette(req.body.palette) + : null; + if (!palette) + return next({ status: 400, message: 'Invalid theme palette.' }); + const userId = parseUserSettingsRouteId(req.params.id); + if (!userId) return next({ status: 404, message: 'User not found.' }); + try { + return await runUserSecurityMutationWithActor( + req.user!.id, + userId, + Permission.MANAGE_USERS, + async () => { + const repository = getRepository(User); + const user = await repository.findOne({ where: { id: userId } }); + if (!user) return next({ status: 404, message: 'User not found.' }); + if (!user.settings) user.settings = new UserSettings({ user }); + user.settings.themePalette = palette; + await repository.save(user); + return res.status(200).json({ themePalette: palette }); + }, + { + expectedCredentialVersion: + req.session?.userId === req.user!.id + ? (req.session.credentialVersion ?? 0) + : undefined, + } + ); + } catch (error) { + return next({ + status: error instanceof UserMutationActorUnauthorizedError ? 403 : 500, + message: 'Could not save theme preference.', + }); + } + } +); + userSettingsRoutes.post<{ id: string }>( '/advanced-theme', isOwnProfile(), diff --git a/server/utils/themePreference.ts b/server/utils/themePreference.ts new file mode 100644 index 000000000..6e0464c67 --- /dev/null +++ b/server/utils/themePreference.ts @@ -0,0 +1,34 @@ +// Stable account preferences, not a release-number reset. The adoption +// migration runs once, including installs that skip directly to a later build. +export const DEFAULT_THEME_PALETTE_ID = 'seerr'; +export const THEME_PALETTE_IDS = [ + 'seerr', + 'classic', + 'aurora', + 'ember', + 'lagoon', + 'orchid', + 'forest', + 'sapphire', + 'rosewood', + 'citrus', + 'arctic', + 'grape', + 'coral', + 'mint', + 'steel', + 'gold', + 'plum', + 'skyline', + 'moss', + 'flame', + 'violet', + 'ocean', + 'sietch-neon', +] as const; +export type ThemePaletteId = (typeof THEME_PALETTE_IDS)[number]; +export const parseThemePalette = (value: unknown): ThemePaletteId | null => + typeof value === 'string' && + THEME_PALETTE_IDS.includes(value as ThemePaletteId) + ? (value as ThemePaletteId) + : null; diff --git a/src/components/Layout/ThemePicker/index.tsx b/src/components/Layout/ThemePicker/index.tsx index 6ae533cc1..bd690feb5 100644 --- a/src/components/Layout/ThemePicker/index.tsx +++ b/src/components/Layout/ThemePicker/index.tsx @@ -1,4 +1,6 @@ import { themePalettes, useTheme } from '@app/context/ThemeContext'; +import { useToasts } from '@app/hooks/useToasts'; +import { useUser } from '@app/hooks/useUser'; import defineMessages from '@app/utils/defineMessages'; import { Menu } from '@headlessui/react'; import { @@ -7,6 +9,7 @@ import { PaintBrushIcon, SunIcon, } from '@heroicons/react/24/outline'; +import { useRef, useState } from 'react'; import { useIntl } from 'react-intl'; const messages = defineMessages('components.Layout.ThemePicker', { @@ -14,11 +17,32 @@ const messages = defineMessages('components.Layout.ThemePicker', { darkMode: 'Dark mode', lightMode: 'Light mode', toggle: 'Toggle', + saveError: 'Could not save your theme. Please try again.', }); const ThemePicker = () => { const intl = useIntl(); const { mode, palette, setPalette, toggleMode } = useTheme(); + const { user } = useUser(); + const { addToast } = useToasts(); + const [saving, setSaving] = useState(false); + const saveActive = useRef(false); + const choosePalette = async (nextPalette: string) => { + if (!user || saveActive.current) return; + saveActive.current = true; + setSaving(true); + try { + await setPalette(nextPalette); + } catch { + addToast(intl.formatMessage(messages.saveError), { + appearance: 'error', + autoDismiss: true, + }); + } finally { + saveActive.current = false; + setSaving(false); + } + }; return ( @@ -55,7 +79,9 @@ const ThemePicker = () => { key={themePalette.id} as="button" type="button" - onClick={() => setPalette(themePalette.id)} + onClick={() => void choosePalette(themePalette.id)} + disabled={!user || saving} + aria-busy={saving} className={({ active }) => `flex min-w-0 items-center rounded border px-2 py-2 text-left text-sm font-medium transition ${ palette === themePalette.id diff --git a/src/components/MediaSlider/providerNotice.test.mjs b/src/components/MediaSlider/providerNotice.test.mjs new file mode 100644 index 000000000..abc6c27f9 --- /dev/null +++ b/src/components/MediaSlider/providerNotice.test.mjs @@ -0,0 +1,272 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; +import ts from 'typescript'; + +const sourceText = readFileSync( + new URL('./index.tsx', import.meta.url), + 'utf8' +); +const parseSource = (text) => { + const source = ts.createSourceFile( + 'MediaSlider.tsx', + text, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TSX + ); + const nodes = []; + const visit = (node) => { + nodes.push(node); + ts.forEachChild(node, visit); + }; + visit(source); + return { source, nodes }; +}; +const contract = parseSource(sourceText); +const { source, nodes } = contract; + +const variableIn = (parsed, name) => + parsed.nodes.find( + (node) => + ts.isVariableDeclaration(node) && + node.name.getText(parsed.source) === name + )?.initializer; +const variable = (name) => variableIn(contract, name); +const attributeIn = (parsed, element, name) => + element.attributes.properties.find( + (property) => + ts.isJsxAttribute(property) && + property.name.getText(parsed.source) === name + )?.initializer; +const expressionIn = (parsed, element, name) => + attributeIn(parsed, element, name)?.expression; +const expression = (element, name) => expressionIn(contract, element, name); +const evaluate = (initializer, scope, parsedSource = source) => + new Function( + ...Object.keys(scope), + `return (${initializer.getText(parsedSource)});` + )(...Object.values(scope)); +const selfClosingElement = (parsed, name) => + parsed.nodes.find( + (node) => + ts.isJsxSelfClosingElement(node) && + node.tagName.getText(parsed.source) === name + ); + +const slider = selfClosingElement(contract, 'Slider'); +const pageError = selfClosingElement(contract, 'PageErrorMessage'); + +assert.ok(slider, 'MediaSlider renders the shared Slider'); +assert.ok(pageError, 'MediaSlider renders the shared provider error notice'); + +const providerStateIn = ( + parsed, + { url, data, error, fallbackHasResults, renderableTitles } +) => { + const isTmdbMovieFeed = evaluate( + variableIn(parsed, 'isTmdbMovieFeed'), + { url }, + parsed.source + ); + const showingSavedResults = evaluate( + variableIn(parsed, 'showingSavedResults'), + { + isTmdbMovieFeed, + data, + error, + fallbackHasResults, + }, + parsed.source + ); + const showProviderError = evaluate( + variableIn(parsed, 'showProviderError'), + { + isTmdbMovieFeed, + error, + renderableTitles, + }, + parsed.source + ); + return { isTmdbMovieFeed, showingSavedResults, showProviderError }; +}; +const providerState = (values) => providerStateIn(contract, values); + +test('stale cached movie data keeps its cards while showing the saved-results notice', () => { + const staleResponseState = providerState({ + url: '/api/v1/discover/movies', + data: [{ stale: true, results: [{ id: 438148, mediaType: 'movie' }] }], + error: undefined, + fallbackHasResults: true, + renderableTitles: [{ id: 438148, mediaType: 'movie' }], + }); + const cachedFallbackState = providerState({ + url: '/api/v1/discover/movies', + data: [{ stale: false, results: [{ id: 438148, mediaType: 'movie' }] }], + error: new Error('TMDB unavailable'), + fallbackHasResults: true, + renderableTitles: [{ id: 438148, mediaType: 'movie' }], + }); + + assert.deepEqual(staleResponseState, { + isTmdbMovieFeed: true, + showingSavedResults: true, + showProviderError: false, + }); + assert.deepEqual(cachedFallbackState, { + isTmdbMovieFeed: true, + showingSavedResults: true, + showProviderError: false, + }); + assert.equal(variable('renderableTitles').getText(source), 'titles'); + assert.match( + variable('visibleTitles').getText(source), + /renderableTitles\.slice\(0, MEDIA_SLIDER_TITLE_LIMIT\)/ + ); + assert.equal(expression(slider, 'items').getText(source), 'finalTitles'); + assert.match( + expression(slider, 'notice').getText(source), + /^showingSavedResults \|\| showProviderError \?/ + ); + const description = expression(pageError, 'description'); + assert.ok(ts.isConditionalExpression(description)); + assert.equal(description.condition.getText(source), 'showingSavedResults'); + assert.equal( + description.whenTrue.getText(source), + 'intl.formatMessage(messages.showingSavedResults)' + ); + assert.equal(description.whenFalse.getText(source), 'undefined'); +}); + +test('an empty movie-provider failure shows the error notice without broadening to other feeds', () => { + const movieState = providerState({ + url: '/api/v1/discover/movies', + data: undefined, + error: new Error('TMDB unavailable'), + fallbackHasResults: false, + renderableTitles: [], + }); + const seriesState = providerState({ + url: '/api/v1/discover/tv', + data: undefined, + error: new Error('provider unavailable'), + fallbackHasResults: false, + renderableTitles: [], + }); + + assert.deepEqual(movieState, { + isTmdbMovieFeed: true, + showingSavedResults: false, + showProviderError: true, + }); + assert.deepEqual(seriesState, { + isTmdbMovieFeed: false, + showingSavedResults: false, + showProviderError: false, + }); + assert.equal( + expression(pageError, 'title').getText(source), + 'intl.formatMessage(messages.tmdbUnavailable)' + ); +}); + +test('the provider notice retry invokes the SWR recovery callback', () => { + const retry = expression(pageError, 'retry'); + assert.ok(ts.isObjectLiteralExpression(retry)); + const retryProperties = Object.fromEntries( + retry.properties + .filter(ts.isPropertyAssignment) + .map((property) => [ + property.name.getText(source), + property.initializer.getText(source), + ]) + ); + assert.deepEqual(retryProperties, { + onClick: 'revalidate', + tooltip: 'intl.formatMessage(messages.tryAgain)', + }); + + const swrBinding = nodes.find( + (node) => + ts.isVariableDeclaration(node) && + ts.isObjectBindingPattern(node.name) && + node.initializer?.getText(source).startsWith('useSWRInfinite<') + ); + assert.ok(swrBinding); + assert.ok( + swrBinding.name.elements.some( + (element) => + element.propertyName?.getText(source) === 'mutate' && + element.name.getText(source) === 'revalidate' + ), + 'retry remains wired to the mutate callback returned by useSWRInfinite' + ); +}); + +const fixture = (current, replacement) => { + assert.ok( + sourceText.includes(current), + `negative fixture target exists: ${current}` + ); + return parseSource(sourceText.replace(current, replacement)); +}; + +test('provider notice checks reject lost cards, broadened feeds, and disconnected retries', () => { + const lostCards = fixture('items={finalTitles}', 'items={[]}'); + const lostCardsSlider = selfClosingElement(lostCards, 'Slider'); + assert.throws( + () => + assert.equal( + expressionIn(lostCards, lostCardsSlider, 'items').getText( + lostCards.source + ), + 'finalTitles', + 'stale results must retain their rendered cards' + ), + /stale results must retain their rendered cards/ + ); + + const broadenedFeed = fixture( + "const isTmdbMovieFeed = url === '/api/v1/discover/movies';", + 'const isTmdbMovieFeed = true;' + ); + assert.throws( + () => + assert.equal( + providerStateIn(broadenedFeed, { + url: '/api/v1/discover/tv', + data: undefined, + error: new Error('provider unavailable'), + fallbackHasResults: false, + renderableTitles: [], + }).showProviderError, + false, + 'provider notice must remain scoped to movie discovery' + ), + /provider notice must remain scoped to movie discovery/ + ); + + const disconnectedRetry = fixture( + 'onClick: revalidate,', + 'onClick: () => undefined,' + ); + const disconnectedPageError = selfClosingElement( + disconnectedRetry, + 'PageErrorMessage' + ); + const retry = expressionIn(disconnectedRetry, disconnectedPageError, 'retry'); + const onClick = retry.properties.find( + (property) => + ts.isPropertyAssignment(property) && + property.name.getText(disconnectedRetry.source) === 'onClick' + ); + assert.throws( + () => + assert.equal( + onClick.initializer.getText(disconnectedRetry.source), + 'revalidate', + 'retry must remain wired to SWR recovery' + ), + /retry must remain wired to SWR recovery/ + ); +}); diff --git a/src/components/TitleCard/index.tsx b/src/components/TitleCard/index.tsx index 0161db013..77623aa04 100644 --- a/src/components/TitleCard/index.tsx +++ b/src/components/TitleCard/index.tsx @@ -177,6 +177,15 @@ const TitleCard = ({ useState(!isAddedToWatchlist); const [previewWatchlisted, setPreviewWatchlisted] = useState(false); const cardRef = useRef(null); + const watchlistMutationActive = useRef(false); + const addWatchlistDescription = intl.formatMessage( + messages.watchlistPreviewAdd, + { title } + ); + const removeWatchlistDescription = intl.formatMessage( + messages.watchlistPreviewRemove, + { title } + ); const statusBadges = getTitleCardStatusBadges({ mediaType, status: currentStatus, @@ -233,6 +242,11 @@ const TitleCard = ({ }, []); const onClickWatchlistBtn = async (): Promise => { + if (watchlistMutationActive.current) { + return; + } + + watchlistMutationActive.current = true; setIsUpdating(true); const actionId = normalizeExternalTitleId(mediaType, id); try { @@ -258,30 +272,42 @@ const TitleCard = ({ title, } ); - mutate('/api/v1/discover/watchlist'); - if (response.data) { - addToast( - - {intl.formatMessage(messages.watchlistSuccess, { - title, - strong: (msg: React.ReactNode) => {msg}, - })} - , - { appearance: 'success', autoDismiss: true } - ); + if (!Number.isInteger(response.data?.id) || response.data.id <= 0) { + addToast(intl.formatMessage(messages.watchlistError), { + appearance: 'error', + autoDismiss: true, + }); + return; } + + mutate('/api/v1/discover/watchlist'); + addToast( + + {intl.formatMessage(messages.watchlistSuccess, { + title, + strong: (msg: React.ReactNode) => {msg}, + })} + , + { appearance: 'success', autoDismiss: true } + ); + setToggleWatchlist(false); } catch { addToast(intl.formatMessage(messages.watchlistError), { appearance: 'error', autoDismiss: true, }); } finally { + watchlistMutationActive.current = false; setIsUpdating(false); - setToggleWatchlist((prevState) => !prevState); } }; const onClickDeleteWatchlistBtn = async (): Promise => { + if (watchlistMutationActive.current) { + return; + } + + watchlistMutationActive.current = true; setIsUpdating(true); const actionId = normalizeExternalTitleId(mediaType, id); try { @@ -291,29 +317,34 @@ const TitleCard = ({ }` ); - if (response.status === 204) { - addToast( - - {intl.formatMessage(messages.watchlistDeleted, { - title, - strong: (msg: React.ReactNode) => {msg}, - })} - , - { appearance: 'info', autoDismiss: true } - ); + if (response.status !== 204) { + addToast(intl.formatMessage(messages.watchlistError), { + appearance: 'error', + autoDismiss: true, + }); + return; } + + addToast( + + {intl.formatMessage(messages.watchlistDeleted, { + title, + strong: (msg: React.ReactNode) => {msg}, + })} + , + { appearance: 'info', autoDismiss: true } + ); + mutate('/api/v1/discover/watchlist'); + mutateParent?.(); + setToggleWatchlist(true); } catch { addToast(intl.formatMessage(messages.watchlistError), { appearance: 'error', autoDismiss: true, }); } finally { + watchlistMutationActive.current = false; setIsUpdating(false); - mutate('/api/v1/discover/watchlist'); - if (mutateParent) { - mutateParent(); - } - setToggleWatchlist((prevState) => !prevState); } }; @@ -1058,27 +1089,46 @@ const TitleCard = ({
{canUseWatchlistActions && !watchlistPreview && - user?.userType !== UserType.PLEX && - (toggleWatchlist ? ( - - ) : ( - - ))} +
+ {toggleWatchlist ? ( + + ) : ( + + )} +
+ + )}
)} diff --git a/src/context/ThemeContext.tsx b/src/context/ThemeContext.tsx index 1bc7e77b9..644e85fd4 100644 --- a/src/context/ThemeContext.tsx +++ b/src/context/ThemeContext.tsx @@ -10,6 +10,10 @@ import { getAdvancedThemeCssValue, validateAdvancedThemeOverrides, } from '@server/utils/advancedThemeOverrides'; +import { + DEFAULT_THEME_PALETTE_ID, + parseThemePalette, +} from '@server/utils/themePreference'; import axios from 'axios'; import type { ReactNode } from 'react'; import { @@ -21,6 +25,7 @@ import { useRef, useState, } from 'react'; +export { DEFAULT_THEME_PALETTE_ID } from '@server/utils/themePreference'; export type ThemeMode = 'light' | 'dark'; @@ -225,8 +230,6 @@ export const themePalettes: ThemePalette[] = [ }, ]; -export const DEFAULT_THEME_PALETTE_ID = 'seerr'; - const shades = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950]; const themeScales = { @@ -804,7 +807,7 @@ type ThemeContextValue = { palette: string; advancedThemeOverrides: AdvancedThemeOverrides | null; setMode: (mode: ThemeMode) => void; - setPalette: (palette: string) => void; + setPalette: (palette: string) => Promise; toggleMode: () => void; saveAdvancedThemeOverrides: ( overrides: AdvancedThemeOverrides | null @@ -821,14 +824,6 @@ const getStoredMode = (): ThemeMode => { return storedMode === 'light' || storedMode === 'dark' ? storedMode : 'dark'; }; -const getStoredPalette = (): string => { - const storedPalette = readLocalStorageValue(THEME_PALETTE_KEY); - return storedPalette && - themePalettes.some((palette) => palette.id === storedPalette) - ? storedPalette - : DEFAULT_THEME_PALETTE_ID; -}; - const getThemePalette = (palette: string): ThemePalette => themePalettes.find((themePalette) => themePalette.id === palette) ?? themePalettes.find( @@ -997,15 +992,35 @@ export const ThemeProvider = ({ children }: { children: ReactNode }) => { useState(null); const hasRestoredTheme = useRef(false); const { user, revalidate } = useUser(); + const currentUserId = useRef(user?.id); + currentUserId.current = user?.id; + const currentMode = useRef(mode); + currentMode.current = mode; + const themeSaveActive = useRef(false); useEffect(() => { const validation = validateAdvancedThemeOverrides( user?.settings?.advancedThemeOverrides ?? null ); const savedOverrides = 'error' in validation ? null : validation.value; - + const savedPalette = + parseThemePalette(user?.settings?.themePalette) ?? + DEFAULT_THEME_PALETTE_ID; + const restoredMode = hasRestoredTheme.current + ? currentMode.current + : getStoredMode(); + // Old browser palettes must not override the one-time account migration. + // Later logins and releases restore the user's newly saved account choice. + hasRestoredTheme.current = true; + setModeState(restoredMode); + setPaletteState(savedPalette); setAdvancedThemeOverrides(savedOverrides); - }, [user?.id, user?.settings?.advancedThemeOverrides]); + applyTheme(restoredMode, savedPalette, savedOverrides); + }, [ + user?.id, + user?.settings?.themePalette, + user?.settings?.advancedThemeOverrides, + ]); useEffect(() => { if (!hasRestoredTheme.current) { @@ -1015,16 +1030,6 @@ export const ThemeProvider = ({ children }: { children: ReactNode }) => { applyTheme(mode, palette, advancedThemeOverrides); }, [mode, palette, advancedThemeOverrides]); - useEffect(() => { - const storedMode = getStoredMode(); - const storedPalette = getStoredPalette(); - - hasRestoredTheme.current = true; - setModeState(storedMode); - setPaletteState(storedPalette); - applyTheme(storedMode, storedPalette); - }, []); - const setMode = useCallback( (nextMode: ThemeMode) => { setModeState(nextMode); @@ -1034,13 +1039,45 @@ export const ThemeProvider = ({ children }: { children: ReactNode }) => { ); const setPalette = useCallback( - (nextPalette: string) => { - const activePalette = getThemePalette(nextPalette); - - setPaletteState(activePalette.id); - applyTheme(mode, activePalette.id, advancedThemeOverrides); + async (nextPalette: string) => { + const userId = user?.id; + const selectedPalette = parseThemePalette(nextPalette); + if (!userId || !selectedPalette || themeSaveActive.current) { + throw new Error( + 'Sign in and choose a valid theme after the current save finishes.' + ); + } + themeSaveActive.current = true; + try { + const { data } = await axios.post<{ themePalette: string }>( + `/api/v1/user/${userId}/settings/theme`, + { palette: selectedPalette } + ); + if (data.themePalette !== selectedPalette) + throw new Error('Theme preference was not confirmed.'); + // A delayed response from a previous login must not affect another user. + if (currentUserId.current !== userId) return; + setPaletteState(selectedPalette); + await revalidate( + (currentUser) => + currentUser?.id === userId + ? { + ...currentUser, + settings: { + ...currentUser.settings, + notificationTypes: + currentUser.settings?.notificationTypes ?? {}, + themePalette: selectedPalette, + }, + } + : currentUser, + false + ); + } finally { + themeSaveActive.current = false; + } }, - [mode, advancedThemeOverrides] + [revalidate, user?.id] ); const toggleMode = useCallback(() => { diff --git a/src/context/ThemeProvider.vitest.test.tsx b/src/context/ThemeProvider.vitest.test.tsx new file mode 100644 index 000000000..00bb320c0 --- /dev/null +++ b/src/context/ThemeProvider.vitest.test.tsx @@ -0,0 +1,158 @@ +import type { User } from '@app/hooks/useUser'; +import { THEME_PALETTE_IDS } from '@server/utils/themePreference'; +import { JSDOM } from 'jsdom'; +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, expect, test, vi } from 'vitest'; +import { ThemeProvider, themePalettes, useTheme } from './ThemeContext'; + +const fixture = vi.hoisted(() => ({ + user: undefined as User | undefined, + post: vi.fn(), + revalidate: vi.fn(), +})); +vi.mock('@app/hooks/useUser', () => ({ + useUser: () => ({ user: fixture.user, revalidate: fixture.revalidate }), +})); +vi.mock('axios', () => ({ default: { post: fixture.post } })); + +let root: Root; +let dom: JSDOM; +let container: HTMLDivElement; +let theme: ReturnType; +const Probe = () => { + theme = useTheme(); + return ; +}; +const render = () => + root.render( + + + + ); +const account = (id: number, palette?: string): User => + ({ + id, + settings: { notificationTypes: {}, themePalette: palette }, + }) as User; + +beforeEach(() => { + dom = new JSDOM('', { + url: 'http://localhost', + }); + vi.stubGlobal('window', dom.window); + vi.stubGlobal('document', dom.window.document); + vi.stubGlobal('localStorage', dom.window.localStorage); + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + fixture.user = undefined; + fixture.post.mockReset(); + fixture.revalidate.mockReset(); + fixture.revalidate.mockImplementation(async (callback) => { + fixture.user = callback(fixture.user); + return fixture.user; + }); + localStorage.clear(); + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); +}); +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + dom.window.close(); + vi.unstubAllGlobals(); +}); + +test('all offered themes share the server allowlist', () => { + expect(themePalettes.map((p) => p.id)).toEqual([...THEME_PALETTE_IDS]); +}); + +test('new installs and migrated logins ignore legacy browser palettes and use SeerrNG', async () => { + localStorage.setItem('seerr-theme-palette', 'classic'); + localStorage.setItem('seerr-theme-mode', 'light'); + await act(async () => render()); + expect(theme.palette).toBe('seerr'); + expect(theme.mode).toBe('light'); + fixture.user = account(1); + await act(async () => render()); + expect(theme.palette).toBe('seerr'); + expect(document.documentElement.dataset.themePalette).toBe('seerr'); + expect(fixture.post).not.toHaveBeenCalled(); +}); + +test('later logins restore the account choice instead of resetting it or sharing another user choice', async () => { + localStorage.setItem('seerr-theme-palette', 'classic'); + fixture.user = account(1, 'aurora'); + await act(async () => render()); + expect(theme.palette).toBe('aurora'); + fixture.user = account(2, 'seerr'); + await act(async () => render()); + expect(theme.palette).toBe('seerr'); + fixture.user = account(1, 'aurora'); + await act(async () => render()); + expect(theme.palette).toBe('aurora'); +}); + +test('a logged-in choice is applied only after confirmation and stored in the account cache', async () => { + fixture.user = account(1, 'seerr'); + await act(async () => render()); + let confirm!: (value: unknown) => void; + fixture.post.mockImplementation( + () => + new Promise((resolve) => { + confirm = resolve; + }) + ); + let save!: Promise; + await act(async () => { + save = theme.setPalette('classic'); + }); + expect(theme.palette).toBe('seerr'); + expect(fixture.post).toHaveBeenCalledWith('/api/v1/user/1/settings/theme', { + palette: 'classic', + }); + await expect(theme.setPalette('aurora')).rejects.toThrow(); + expect(fixture.post).toHaveBeenCalledTimes(1); + await act(async () => { + confirm({ data: { themePalette: 'classic' } }); + await save; + }); + expect(theme.palette).toBe('classic'); + expect(fixture.user?.settings?.themePalette).toBe('classic'); + expect(localStorage.getItem('seerr-theme-palette')).toBe('classic'); +}); + +test('failed saves preserve the current theme and guest changes are not accepted', async () => { + await act(async () => render()); + await expect(theme.setPalette('classic')).rejects.toThrow(); + expect(fixture.post).not.toHaveBeenCalled(); + fixture.user = account(1, 'seerr'); + await act(async () => render()); + fixture.post.mockRejectedValue(new Error('save failed')); + await act(async () => { + await expect(theme.setPalette('classic')).rejects.toThrow('save failed'); + }); + expect(theme.palette).toBe('seerr'); + expect(fixture.user?.settings?.themePalette).toBe('seerr'); +}); + +test('a delayed save response cannot change the theme after switching accounts', async () => { + fixture.user = account(1, 'seerr'); + await act(async () => render()); + let confirm!: (value: unknown) => void; + fixture.post.mockImplementation( + () => + new Promise((resolve) => { + confirm = resolve; + }) + ); + const save = theme.setPalette('classic'); + fixture.user = account(2, 'aurora'); + await act(async () => render()); + await act(async () => { + confirm({ data: { themePalette: 'classic' } }); + await save; + }); + expect(theme.palette).toBe('aurora'); + expect(fixture.revalidate).not.toHaveBeenCalled(); +}); diff --git a/src/hooks/useUser.ts b/src/hooks/useUser.ts index dae827c7d..60e038e62 100644 --- a/src/hooks/useUser.ts +++ b/src/hooks/useUser.ts @@ -65,6 +65,7 @@ export interface UserSettings { detailDisclosureOrder?: DetailDisclosureOrder; mediaFilterPins?: UserMediaFilterPins; advancedThemeOverrides?: AdvancedThemeOverrides | null; + themePalette?: string; } interface UserHookResponse { diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index f0b556717..f2b71b2e5 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -960,6 +960,7 @@ "components.Layout.Sidebar.users": "User List", "components.Layout.ThemePicker.darkMode": "Dark mode", "components.Layout.ThemePicker.lightMode": "Light mode", + "components.Layout.ThemePicker.saveError": "Could not save your theme. Please try again.", "components.Layout.ThemePicker.themePicker": "Theme picker", "components.Layout.ThemePicker.toggle": "Toggle", "components.Layout.UserDropdown.MiniQuotaDisplay.bookrequests": "Book Requests", diff --git a/src/styles/globals.css b/src/styles/globals.css index 80e2f54f9..702c03608 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -4159,6 +4159,12 @@ } } + @media (prefers-reduced-motion: reduce) { + .poster-layout[data-poster-state='loading'] { + animation: none; + } + } + .poster-layout [data-poster-region='overlay'] { border-radius: inherit; } diff --git a/src/styles/titleCardWatchlistActions.test.mjs b/src/styles/titleCardWatchlistActions.test.mjs new file mode 100644 index 000000000..a6c89cf5b --- /dev/null +++ b/src/styles/titleCardWatchlistActions.test.mjs @@ -0,0 +1,591 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import test from 'node:test'; +import postcss from 'postcss'; + +const require = createRequire(path.resolve('package.json')); +const ts = require('typescript'); +const React = require('react'); +const { JSDOM } = require('jsdom'); +const dom = new JSDOM('', { + url: 'http://localhost/', +}); +globalThis.window = dom.window; +globalThis.document = dom.window.document; +globalThis.Element = dom.window.Element; +globalThis.IS_REACT_ACT_ENVIRONMENT = true; +const { createRoot } = require('react-dom/client'); +const { act } = React; +const sourceRoot = process.env.TITLE_CARD_TEST_SOURCE_ROOT ?? '.'; +const source = fs.readFileSync( + path.resolve(sourceRoot, 'src/components/TitleCard/index.tsx'), + 'utf8' +); +const sourceFile = ts.createSourceFile( + 'TitleCard/index.tsx', + source, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TSX +); +const nodes = (root, predicate) => { + const found = []; + const visit = (node) => { + if (predicate(node)) found.push(node); + ts.forEachChild(node, visit); + }; + visit(root); + return found; +}; +const attributeExpression = (opening, name) => + opening.attributes.properties.find( + (entry) => ts.isJsxAttribute(entry) && entry.name.getText() === name + )?.initializer?.expression; +const ancestor = (node, predicate) => { + for (let parent = node.parent; parent; parent = parent.parent) { + if (predicate(parent)) return parent; + } + return undefined; +}; +let environment; + +const component = (tag = 'div') => { + const Component = ({ children, ...props }) => + React.createElement(tag, props, children); + return Component; +}; +const icon = (props) => React.createElement('svg', props); +const Button = ({ buttonType, buttonSize, iconOnly, children, ...props }) => + React.createElement('button', props, children); +const CachedImage = ({ + type, + fallbackSrc, + errorFallbackSrc, + fill, + priority, + ...props +}) => React.createElement('img', props); +const globalMessages = new Proxy( + {}, + { + get: (_target, key) => ({ defaultMessage: String(key) }), + } +); +const deps = { + react: React, + 'react/jsx-runtime': require('react/jsx-runtime'), + '@app/assets/spinner.svg': { + default: component('span'), + __esModule: true, + }, + '@app/components/Association/AssociationBadge': { + default: component(), + __esModule: true, + }, + '@app/components/BlocklistConfirmationModal': { + default: component(), + __esModule: true, + }, + '@app/components/Common/BookFormatBadge': { + default: component(), + getBookFormatMessage: (format) => ({ defaultMessage: format }), + __esModule: true, + }, + '@app/components/Common/Button': { + default: Button, + __esModule: true, + }, + '@app/components/Common/CachedImage': { + default: CachedImage, + __esModule: true, + }, + '@app/components/Common/MediaTypeBadge': { + default: component(), + __esModule: true, + }, + '@app/components/Common/StatusBadgeMini': { + default: component(), + __esModule: true, + }, + '@app/components/Common/Tooltip': { + default: ({ content, children }) => + React.createElement('div', { title: content }, children), + __esModule: true, + }, + '@app/components/Common/WatchedBadge': { + default: component(), + __esModule: true, + }, + '@app/components/TitleCard/ErrorCard': { + default: component(), + __esModule: true, + }, + '@app/components/TitleCard/Placeholder': { + default: component(), + __esModule: true, + }, + '@app/components/TitleCard/PosterRatingPopover': { + default: () => null, + __esModule: true, + }, + '@app/components/TitleCard/bookDetailQuery': { + getTitleCardBookDetailQuery: () => ({}), + }, + '@app/components/TitleCard/statusBadges': { + getTitleCardStatusBadges: () => [], + getTitleCardStatusBadgeSlots: () => ({}), + }, + '@app/hooks/useAlbumArtwork': { + default: (_id, image) => image, + __esModule: true, + }, + '@app/hooks/useIsTouch': { + useIsTouch: () => false, + }, + '@app/hooks/useSettings': { + default: () => ({ + currentSettings: { movie4kEnabled: false, series4kEnabled: false }, + }), + __esModule: true, + }, + '@app/hooks/useToasts': { + default: () => ({ + addToast: (...args) => environment.toasts.push(args), + }), + __esModule: true, + }, + '@app/hooks/useUser': { + Permission: new Proxy({}, { get: (_target, key) => key }), + UserType: { PLEX: 'plex' }, + useUser: () => ({ + user: { id: 7, userType: 'local' }, + hasPermission: () => false, + }), + }, + '@app/hooks/useWatchStatus': { + default: () => ({}), + __esModule: true, + }, + '@app/i18n/globalMessages': { + default: globalMessages, + __esModule: true, + }, + '@app/utils/apiPath': { + encodeApiPathSegment: encodeURIComponent, + normalizeExternalTitleId: (_mediaType, id) => id, + }, + '@app/utils/defineMessages': { + default: (_id, messages) => + Object.fromEntries( + Object.entries(messages).map(([key, defaultMessage]) => [ + key, + { defaultMessage }, + ]) + ), + __esModule: true, + }, + '@app/utils/imageCache': { + getTmdbPosterImageUrl: (value) => value, + isResolvedImageUrl: (value) => Boolean(value), + }, + '@app/utils/typeHelpers': { + withProperties: (Component, properties) => + Object.assign(Component, properties), + }, + '@headlessui/react': { + Transition: ({ show, children }) => (show ? children : null), + }, + '@heroicons/react/24/outline': { + ArrowDownTrayIcon: icon, + EyeIcon: icon, + EyeSlashIcon: icon, + MinusCircleIcon: icon, + StarIcon: icon, + }, + '@heroicons/react/24/solid': { + StarIcon: icon, + }, + '@server/constants/media': { + MediaStatus: { + AVAILABLE: 'available', + BLOCKLISTED: 'blocklisted', + DELETED: 'deleted', + PARTIALLY_AVAILABLE: 'partially_available', + PENDING: 'pending', + PROCESSING: 'processing', + UNKNOWN: 'unknown', + }, + }, + axios: { + default: { + get: async () => ({}), + post: (...args) => { + environment.posts.push(args); + return environment.post(...args); + }, + delete: (...args) => { + environment.deletes.push(args); + return environment.delete(...args); + }, + }, + __esModule: true, + }, + 'next/dynamic': { + default: () => () => null, + __esModule: true, + }, + 'next/link': { + default: ({ children }) => + React.createElement('a', { href: '#' }, children), + __esModule: true, + }, + 'next/router': { + useRouter: () => ({ push: async () => true }), + }, + 'react-intl': { + useIntl: () => ({ + formatMessage: (message, values = {}) => + (message?.defaultMessage ?? '').replace(/\{(\w+)\}/g, (_match, key) => + String(values[key] ?? '') + ), + }), + }, + swr: { + default: () => ({}), + mutate: (...args) => environment.mutations.push(args), + __esModule: true, + }, +}; +const compiledModule = { exports: {} }; +const javascript = ts.transpileModule(source, { + compilerOptions: { + target: ts.ScriptTarget.ES2020, + module: ts.ModuleKind.CommonJS, + jsx: ts.JsxEmit.ReactJSX, + esModuleInterop: true, + }, +}).outputText; +new Function('require', 'module', 'exports', javascript)( + (id) => { + assert.ok(id in deps, `Unexpected dependency ${id}`); + return deps[id]; + }, + compiledModule, + compiledModule.exports +); +const TitleCard = compiledModule.exports.default; +const tick = () => new Promise((resolve) => setTimeout(resolve, 0)); +const deferred = () => { + let resolve; + let reject; + const promise = new Promise((done, fail) => { + resolve = done; + reject = fail; + }); + return { promise, resolve, reject }; +}; +const addLabel = 'Add Fixture title to your Watchlist.'; +const removeLabel = 'Remove Fixture title from your Watchlist.'; + +test('production watchlist controls keep their interactive slot and keyboard boundary', () => { + const controls = nodes( + sourceFile, + (node) => + ts.isJsxOpeningElement(node) && + ['onClickWatchlistBtn', 'onClickDeleteWatchlistBtn'].includes( + attributeExpression(node, 'onClick')?.getText() + ) + ); + assert.equal(controls.length, 2); + for (const control of controls) { + assert.equal( + attributeExpression(control, 'disabled')?.getText(), + 'isUpdating' + ); + assert.equal( + attributeExpression(control, 'aria-busy')?.getText(), + 'isUpdating' + ); + assert.match( + attributeExpression(control, 'aria-label')?.getText() ?? '', + /(?:add|remove)WatchlistDescription/ + ); + assert.match( + attributeExpression(control, 'onKeyDown')?.getText() ?? '', + /event\.stopPropagation\(\)/ + ); + assert.ok( + ancestor( + control, + (node) => + ts.isJsxElement(node) && + node.openingElement.attributes.properties.some( + (entry) => + ts.isJsxAttribute(entry) && + entry.name.getText() === 'data-poster-region' && + entry.initializer?.text === 'watchlist-slot' + ) + ) + ); + } +}); + +const fixture = async ({ added = false, post, remove } = {}) => { + environment = { + posts: [], + deletes: [], + mutations: [], + toasts: [], + post: post ?? (async () => ({ data: {} })), + delete: remove ?? (async () => ({ status: 204 })), + }; + const env = environment; + const container = document.createElement('div'); + document.body.append(container); + const root = createRoot(container); + await act(async () => { + root.render( + React.createElement(TitleCard, { + id: 'fixture-book', + image: 'https://example.test/poster.jpg', + isAddedToWatchlist: added, + mediaType: 'book', + requestable: false, + title: 'Fixture title', + }) + ); + await tick(); + }); + await act(async () => { + container + .querySelector('[data-poster-region="frame"]') + .dispatchEvent(new window.MouseEvent('click', { bubbles: true })); + await tick(); + }); + const action = () => + [...container.querySelectorAll('button')].find((button) => + [addLabel, removeLabel].includes(button.getAttribute('aria-label')) + ); + return { + env, + action, + doubleClick: async () => { + const button = action(); + await act(async () => { + button.dispatchEvent(new window.MouseEvent('click', { bubbles: true })); + button.dispatchEvent(new window.MouseEvent('click', { bubbles: true })); + await Promise.resolve(); + }); + }, + click: async () => { + const button = action(); + await act(async () => { + button.dispatchEvent(new window.MouseEvent('click', { bubbles: true })); + await Promise.resolve(); + }); + }, + settle: async (callback) => { + await act(async () => { + callback(); + await tick(); + }); + }, + cleanup: async () => { + await act(async () => root.unmount()); + container.remove(); + }, + }; +}; + +test('add action is named, guarded synchronously, and changes state only on a confirmed response', async () => { + const rejected = deferred(); + const failed = await fixture({ post: () => rejected.promise }); + assert.equal(failed.action()?.getAttribute('aria-label'), addLabel); + assert.equal( + failed.action()?.closest('[title]')?.getAttribute('title'), + addLabel + ); + await failed.doubleClick(); + assert.equal(failed.env.posts.length, 1); + assert.equal(failed.action()?.disabled, true); + assert.equal(failed.action()?.getAttribute('aria-busy'), 'true'); + await failed.settle(() => rejected.reject(new Error('request failed'))); + assert.equal(failed.action()?.getAttribute('aria-label'), addLabel); + assert.equal(failed.action()?.disabled, false); + assert.equal(failed.env.mutations.length, 0); + await failed.cleanup(); + + const malformedResponse = deferred(); + const malformed = await fixture({ post: () => malformedResponse.promise }); + await malformed.click(); + await malformed.settle(() => malformedResponse.resolve({})); + assert.equal(malformed.action()?.getAttribute('aria-label'), addLabel); + assert.equal(malformed.env.mutations.length, 0); + await malformed.cleanup(); + + const emptyObjectResponse = deferred(); + const emptyObject = await fixture({ + post: () => emptyObjectResponse.promise, + }); + await emptyObject.click(); + await emptyObject.settle(() => emptyObjectResponse.resolve({ data: {} })); + assert.equal(emptyObject.action()?.getAttribute('aria-label'), addLabel); + assert.equal(emptyObject.env.mutations.length, 0); + await emptyObject.cleanup(); + + const confirmedResponse = deferred(); + const confirmed = await fixture({ post: () => confirmedResponse.promise }); + await confirmed.click(); + await confirmed.settle(() => confirmedResponse.resolve({ data: { id: 1 } })); + assert.equal(confirmed.action()?.getAttribute('aria-label'), removeLabel); + assert.equal(confirmed.env.mutations.length, 1); + await confirmed.cleanup(); +}); + +test('remove action is named, guarded synchronously, and changes state only after status 204', async () => { + const rejected = deferred(); + const failed = await fixture({ + added: true, + remove: () => rejected.promise, + }); + assert.equal(failed.action()?.getAttribute('aria-label'), removeLabel); + assert.equal( + failed.action()?.closest('[title]')?.getAttribute('title'), + removeLabel + ); + await failed.doubleClick(); + assert.equal(failed.env.deletes.length, 1); + assert.equal(failed.action()?.disabled, true); + await failed.settle(() => rejected.reject(new Error('request failed'))); + assert.equal(failed.action()?.getAttribute('aria-label'), removeLabel); + assert.equal(failed.env.mutations.length, 0); + await failed.cleanup(); + + const malformedResponse = deferred(); + const malformed = await fixture({ + added: true, + remove: () => malformedResponse.promise, + }); + await malformed.click(); + await malformed.settle(() => malformedResponse.resolve({ status: 200 })); + assert.equal(malformed.action()?.getAttribute('aria-label'), removeLabel); + assert.equal(malformed.env.mutations.length, 0); + await malformed.cleanup(); + + const confirmedResponse = deferred(); + const confirmed = await fixture({ + added: true, + remove: () => confirmedResponse.promise, + }); + await confirmed.click(); + await confirmed.settle(() => confirmedResponse.resolve({ status: 204 })); + assert.equal(confirmed.action()?.getAttribute('aria-label'), addLabel); + assert.equal(confirmed.env.mutations.length, 1); + await confirmed.cleanup(); +}); + +const loadingPosterSelector = ".poster-layout[data-poster-state='loading']"; +const reducedMotionMedia = '(prefers-reduced-motion: reduce)'; +const geometryProperties = new Set([ + 'aspect-ratio', + 'bottom', + 'display', + 'height', + 'inset', + 'left', + 'margin', + 'margin-block', + 'margin-inline', + 'max-height', + 'max-width', + 'min-height', + 'min-width', + 'padding', + 'padding-block', + 'padding-inline', + 'position', + 'right', + 'top', + 'width', +]); +const mediaAncestor = (rule) => { + for (let parent = rule.parent; parent; parent = parent.parent) { + if (parent.type === 'atrule' && parent.name === 'media') return parent; + } + return undefined; +}; +const declarations = (rule, property) => + rule.nodes + .filter((node) => node.type === 'decl' && node.prop === property) + .map((node) => node.value); +const assertReducedMotionContract = (source) => { + const root = postcss.parse(source); + const loadingRules = []; + root.walkRules((rule) => { + if (rule.selectors.includes(loadingPosterSelector)) loadingRules.push(rule); + }); + const normalOwners = loadingRules.filter((rule) => !mediaAncestor(rule)); + assert.equal(normalOwners.length, 1, 'loading poster keeps one normal owner'); + assert.deepEqual( + declarations(normalOwners[0], 'animation'), + ['poster-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite'], + 'normal motion keeps the accepted poster pulse' + ); + + const reducedOwners = loadingRules.filter( + (rule) => mediaAncestor(rule)?.params === reducedMotionMedia + ); + assert.equal( + reducedOwners.length, + 1, + 'loading poster keeps one reduced-motion owner' + ); + const reducedOwner = reducedOwners[0]; + assert.equal( + reducedOwner.parent.type, + 'atrule', + 'reduced-motion owner belongs directly to its media rule' + ); + assert.equal(reducedOwner.parent.name, 'media'); + assert.equal(reducedOwner.parent.params, reducedMotionMedia); + assert.deepEqual( + declarations(reducedOwner, 'animation'), + ['none'], + 'reduced motion disables the loading animation' + ); + assert.deepEqual( + reducedOwner.nodes + .filter( + (node) => node.type === 'decl' && geometryProperties.has(node.prop) + ) + .map((node) => node.prop), + [], + 'reduced-motion owner does not change poster geometry' + ); +}; + +test('loading poster pulse keeps normal geometry and is suppressed for reduced motion', () => { + const css = fs.readFileSync( + path.resolve(sourceRoot, 'src/styles/globals.css'), + 'utf8' + ); + assertReducedMotionContract(css); + + const missingContract = postcss.parse(css); + let removed = 0; + missingContract.walkAtRules('media', (media) => { + if (media.params !== reducedMotionMedia) return; + media.walkRules((rule) => { + if (!rule.selectors.includes(loadingPosterSelector)) return; + rule.remove(); + removed++; + }); + }); + assert.equal(removed, 1, 'negative fixture removes the owned motion rule'); + assert.throws( + () => assertReducedMotionContract(missingContract.toString()), + /reduced-motion owner/ + ); +}); diff --git a/tools/validation-engine/README.md b/tools/validation-engine/README.md new file mode 100644 index 000000000..4501b92c7 --- /dev/null +++ b/tools/validation-engine/README.md @@ -0,0 +1,45 @@ +# Saved SeerrNG validation engine + +This is an add-only preservation copy for later use. It does not replace the +preview's tests, application code, package commands or hooks, and saving it did +not run tests, compile, start a preview or deploy anything. + +`validation-engine-v1.1.0.tar.gz` contains the hash-verified reusable +controller, reviewed repair/retest components and AI setup instructions, plus +the exact frozen recipes and inventory from the latest successful V7 run. +The original RC1 status remains historical; `LATEST-RESULT.txt` records the +later V7 result separately. The inventory identifies every saved file. + +The latest full run tested SeerrNG 3.48.1 at commit +`897adeefa77371abed217e7d454d29cf0985b58b`, not this preview's dirty working +source. It passed 3,983 cases across 553 test files, with zero failures and four +existing PostgreSQL conditional skips, in 5m36.205s including host lifecycle. +No application repair was needed in that run. Browser/Cypress, CodeQL, +compilation and release/deployment checks are not acceptance implied by it. + +Worker capacity is selected inside the engine from effective logical CPUs after +visible-CPU and cgroup limits. The universal default is `max(1, N - 1)` workers. +When the public GitHub login `JohnCronk79` is detected through GitHub Actions or +ordinary Git identity, the same engine automatically uses `2N`. This follows the +operator across development machines without using a machine name, OS account, +Docker volume, credential lookup or separate runner. An explicit bounded worker +override remains available to maintainers. The historical V7 result still records +its original 24-slot configuration; it is not evidence for this updated policy. + +## Reuse + +1. Extract into a separate development workspace, outside automatic test globs. +2. Read `engine/AI-INSTRUCTIONS.txt`, the target repo's AGENTS and Fix-it rules. +3. Choose the actual source to test; preserve its dirty working bytes. Do not + replace it with the 3.48.1 reference or reuse reference pass counts/pins. +4. Regenerate and review the inventory, case ledger, source/dependency/runtime + hashes and invocation packets for that chosen source. Old Windows paths, + Docker volumes and receipts are reference evidence, not live instructions. +5. Run with disposable config/database fixtures and provider/network guards. + Compilation is separate. The engine queues failures; an authorized coding + agent supplies reviewed repairs, followed by failed and affected retests. + +All tests already present in the preview remain in their original locations. +The saved recipe archive is deliberately not unpacked into those locations: +that would create duplicate test discovery or overwrite a different revision. +No future run is automatically authorized by this preservation operation. diff --git a/tools/validation-engine/inventory.json b/tools/validation-engine/inventory.json new file mode 100644 index 000000000..f5ce81037 --- /dev/null +++ b/tools/validation-engine/inventory.json @@ -0,0 +1,312 @@ +{ + "schema": 1, + "version": "1.1.0", + "purpose": "preserved engine and frozen reference, not preview acceptance", + "items": [ + { + "path": "engine/engine-controller-priority-v2/controller.mjs", + "bytes": 19108, + "sha256": "9b2778e0207ff1c4aab727be53c4bae083f2220b6e4493c4e2d35efda414c7ad" + }, + { + "path": "engine/engine-controller-priority-v2/priority.mjs", + "bytes": 4919, + "sha256": "8e7f6410db28edd59a3c96fda594c9e5d09e8ea7dae61e88aae0944283a483c9" + }, + { + "path": "engine/engine-controller-priority-v2/retest.mjs", + "bytes": 8798, + "sha256": "5e11f3c6549e18a820956df25c7d3a3906ad39d9197399bcbf77f5108205ca5f" + }, + { + "path": "engine/engine-repair-integration-v4/repair.mjs", + "bytes": 17499, + "sha256": "f7d84c61e7091e6be5a7f079c820757a199864ab8be15a8ca69f0afbaa0cdd92" + }, + { + "path": "engine/engine-repair-integration-v4/canonical-host-bridge.mjs", + "bytes": 14807, + "sha256": "4b17320b61788a608cafd57a01ad454eef3298902a4fe47c80aafbebd6b01484" + }, + { + "path": "engine/engine-native-repair-bridge-v2/native-host-bridge.mjs", + "bytes": 10341, + "sha256": "435ae26f262b070987dceae2c12fc888c1f2baf69290f49b6d70fac4afb4b62b" + }, + { + "path": "engine/engine-native-repair-bridge-v2/node-tap-hierarchy.mjs", + "bytes": 5979, + "sha256": "8787a918a3d0fd986b5f69c37f92f7c0dfe29897edd2449d7628981e319cff6f" + }, + { + "path": "engine/engine-working-repair-orchestration-v5/session.mjs", + "bytes": 24947, + "sha256": "a71688f74bfd521911fbcb094b8a6c8979c378f26e800be2694ffd51d0f3119b" + }, + { + "path": "engine/engine-working-repair-orchestration-v5/cli.mjs", + "bytes": 13715, + "sha256": "824066b19e2ed4a147cdbf1ad6d744fb99d85adfd737532adbd7deb81927a3e8" + }, + { + "path": "engine/engine-working-repair-orchestration-v5/PLAN.txt", + "bytes": 11090, + "sha256": "a8a1ea949fc849fea8db7ca9b3b49d0b783be3d9073b570700b3551dd390876b" + }, + { + "path": "engine/linux-real-source-repair-v1/cli.mjs", + "bytes": 10847, + "sha256": "5597c67586bfa62b72d9ec32a2bbe7b08a6f2d629fd7bae871d34bbb88ccb3b0" + }, + { + "path": "engine/linux-real-source-repair-v1/filesystem.mjs", + "bytes": 12696, + "sha256": "9e93e9bb174b8eadd5e2a23700858203b12c9ac0b459e57aee3af4744c35a7c4" + }, + { + "path": "engine/linux-real-source-repair-v1/filesystem-export-v2.mjs", + "bytes": 12798, + "sha256": "abdec9f78f1176737ca1e0187c743df603627692d6eaab7669623ed5af977d51" + }, + { + "path": "engine/linux-real-source-repair-v1/session-host.mjs", + "bytes": 6054, + "sha256": "761ca7a967b1e50a21a97b739a90cbab67ec9ec25d2055337fcc5e14bf163f4c" + }, + { + "path": "engine/linux-real-source-repair-v1/verify-working-tree-metadata.mjs", + "bytes": 5314, + "sha256": "aad7fbeb453c04ea42c446226e109c49f3cb00ca728042e41af8bf8c1c80db4d" + }, + { + "path": "engine/linux-real-source-repair-v1/CONTRACT.txt", + "bytes": 9455, + "sha256": "1d344aba3ac60cefe979aba74fd3143fcc16b4882ad7e42479f629e87e592222" + }, + { + "path": "engine/repaired-readonly-consumer-v2/adapter.mjs", + "bytes": 26234, + "sha256": "7305a1fff7a9b08e180449384458632cfb40fdae2dafcb4fb843ec2dd3702349" + }, + { + "path": "engine/repaired-readonly-consumer-v2/config.mjs", + "bytes": 1630, + "sha256": "bcca2c4337c55e1268d8edd27e5ffcc4195e8b1fa6f2d3a8d1f37030c5ffa179" + }, + { + "path": "engine/repaired-readonly-consumer-v2/isolate-before.mjs", + "bytes": 533, + "sha256": "321a3f9179274c4c5dd93257d8fc7d21a2bd821166a3a37fd336f1c32414dac6" + }, + { + "path": "engine/repaired-readonly-consumer-v2/sequencer.mjs", + "bytes": 1325, + "sha256": "030f978a7f5f6b427a193e164d6073156432d0915794c3905f626cfdd0a0ac44" + }, + { + "path": "engine/repaired-readonly-consumer-v2/controller.mjs", + "bytes": 19108, + "sha256": "9b2778e0207ff1c4aab727be53c4bae083f2220b6e4493c4e2d35efda414c7ad" + }, + { + "path": "engine/repaired-readonly-consumer-v2/priority.mjs", + "bytes": 4919, + "sha256": "8e7f6410db28edd59a3c96fda594c9e5d09e8ea7dae61e88aae0944283a483c9" + }, + { + "path": "engine/repaired-readonly-consumer-v2/verify-working-tree-metadata.mjs", + "bytes": 5314, + "sha256": "aad7fbeb453c04ea42c446226e109c49f3cb00ca728042e41af8bf8c1c80db4d" + }, + { + "path": "engine/repaired-readonly-consumer-v2/verify-metadata.mjs", + "bytes": 2470, + "sha256": "6dc6abe205264ec14f0734c95cb8d4e496bee469386adb8d99f2c62868d6644a" + }, + { + "path": "engine/repaired-readonly-consumer-v2/consumer.mjs", + "bytes": 8477, + "sha256": "f3887233bb8140848427b14191c596ef759232c2486c946cc5590d19aa5bbf10" + }, + { + "path": "engine/repaired-readonly-consumer-v2/canonical-host-bridge.mjs", + "bytes": 14849, + "sha256": "8fe9d12783e474fa6a103c34057dafeca59d7b30544905cc1259e7e5f44c9a86" + }, + { + "path": "engine/repaired-readonly-consumer-v2/native-host-bridge.mjs", + "bytes": 10353, + "sha256": "89528267bb563bc4c60e573d413596bf80109e41ebc58e6c7653ceb11eaae1b6" + }, + { + "path": "engine/repaired-readonly-consumer-v2/node-tap-hierarchy.mjs", + "bytes": 5979, + "sha256": "8787a918a3d0fd986b5f69c37f92f7c0dfe29897edd2449d7628981e319cff6f" + }, + { + "path": "engine/repaired-readonly-consumer-v2/watchlistPreview.overlay.test.mjs", + "bytes": 20651, + "sha256": "db41aa9845eb6541ad9cd69ac3081e01c6456bd0cd96f1a38b43f0bb6427a71a" + }, + { + "path": "engine/repaired-readonly-consumer-v2/watchlist-overlay.json", + "bytes": 1979, + "sha256": "52690e1bca0b8e9473d38c2c9b334178fab9dbf6ea142f51ea9a07be1cee67b0" + }, + { + "path": "engine/repaired-readonly-consumer-v2/watchlist-command-override.mjs", + "bytes": 3590, + "sha256": "57f5af2b2bf1ba0d9be8805665ff105d92e05b79898b1e121c27bff06e3ba1c0" + }, + { + "path": "engine/repaired-readonly-consumer-v2/host.mjs", + "bytes": 7616, + "sha256": "0a4b54599fe80e64d568c81a82d0546f3d7357791f5939d48efefe749373411d" + }, + { + "path": "engine/repaired-readonly-consumer-v2/prepare-consumer.mjs", + "bytes": 11598, + "sha256": "cf5d6afec20e8bd1f1909737fd7010d9ee39b2202f289af916845111f37ee36d" + }, + { + "path": "engine/AI-INSTRUCTIONS.txt", + "bytes": 11048, + "sha256": "94e415a610259e8530ff89779d47d9f131647ac2f150367e0b0246578874c8d9" + }, + { + "path": "engine/cpu-capacity.mjs", + "bytes": 4905, + "sha256": "902449a13bc104b751927c168e0e4710183856160c5bcd41724f4e96134b7f75" + }, + { + "path": "engine/cpu-capacity.test.mjs", + "bytes": 2934, + "sha256": "bb6b750b6d3c49048710986162b31c8bd1da4133b32aa384c28ac17029c9ccfc" + }, + { + "path": "engine/ACTUAL-RUN-STATUS.json", + "bytes": 1852, + "sha256": "a9d3d220c7167a4ce88c270c62efb103fd909f2c1b6f981d6cb841eadb53a7c4" + }, + { + "path": "reference-v7/recipes/adapter.mjs", + "bytes": 26234, + "sha256": "7305a1fff7a9b08e180449384458632cfb40fdae2dafcb4fb843ec2dd3702349" + }, + { + "path": "reference-v7/recipes/canonical-host-bridge.mjs", + "bytes": 14849, + "sha256": "8fe9d12783e474fa6a103c34057dafeca59d7b30544905cc1259e7e5f44c9a86" + }, + { + "path": "reference-v7/recipes/config.mjs", + "bytes": 1630, + "sha256": "bcca2c4337c55e1268d8edd27e5ffcc4195e8b1fa6f2d3a8d1f37030c5ffa179" + }, + { + "path": "reference-v7/recipes/consumer.mjs", + "bytes": 8477, + "sha256": "f3887233bb8140848427b14191c596ef759232c2486c946cc5590d19aa5bbf10" + }, + { + "path": "reference-v7/recipes/controller.mjs", + "bytes": 19108, + "sha256": "9b2778e0207ff1c4aab727be53c4bae083f2220b6e4493c4e2d35efda414c7ad" + }, + { + "path": "reference-v7/recipes/isolate-before.mjs", + "bytes": 533, + "sha256": "321a3f9179274c4c5dd93257d8fc7d21a2bd821166a3a37fd336f1c32414dac6" + }, + { + "path": "reference-v7/recipes/native-host-bridge.mjs", + "bytes": 10353, + "sha256": "89528267bb563bc4c60e573d413596bf80109e41ebc58e6c7653ceb11eaae1b6" + }, + { + "path": "reference-v7/recipes/node-tap-hierarchy.mjs", + "bytes": 5979, + "sha256": "8787a918a3d0fd986b5f69c37f92f7c0dfe29897edd2449d7628981e319cff6f" + }, + { + "path": "reference-v7/recipes/plan.json", + "bytes": 352789, + "sha256": "a9016d7be8660b6920a16f697a7e48f1007a7ee2e19a7ac6ab8b2ccef05bacaf" + }, + { + "path": "reference-v7/recipes/priority.mjs", + "bytes": 4919, + "sha256": "8e7f6410db28edd59a3c96fda594c9e5d09e8ea7dae61e88aae0944283a483c9" + }, + { + "path": "reference-v7/recipes/sequencer.mjs", + "bytes": 1325, + "sha256": "030f978a7f5f6b427a193e164d6073156432d0915794c3905f626cfdd0a0ac44" + }, + { + "path": "reference-v7/recipes/verify-metadata.mjs", + "bytes": 2470, + "sha256": "6dc6abe205264ec14f0734c95cb8d4e496bee469386adb8d99f2c62868d6644a" + }, + { + "path": "reference-v7/recipes/verify-working-tree-metadata.mjs", + "bytes": 5314, + "sha256": "aad7fbeb453c04ea42c446226e109c49f3cb00ca728042e41af8bf8c1c80db4d" + }, + { + "path": "reference-v7/recipes/watchlist-command-override.mjs", + "bytes": 3590, + "sha256": "57f5af2b2bf1ba0d9be8805665ff105d92e05b79898b1e121c27bff06e3ba1c0" + }, + { + "path": "reference-v7/recipes/watchlist-overlay.json", + "bytes": 1979, + "sha256": "52690e1bca0b8e9473d38c2c9b334178fab9dbf6ea142f51ea9a07be1cee67b0" + }, + { + "path": "reference-v7/recipes/watchlistPreview.overlay.test.mjs", + "bytes": 20651, + "sha256": "db41aa9845eb6541ad9cd69ac3081e01c6456bd0cd96f1a38b43f0bb6427a71a" + }, + { + "path": "reference-v7/trial/attestation.json", + "bytes": 8898, + "sha256": "f4b3f4d9610e8a7abf28562f2e12f5de4e5d2270c90ad22597e065ac2cd7b5af" + }, + { + "path": "reference-v7/trial/boundary.json", + "bytes": 415452, + "sha256": "a58c4ffec3925bf9c4ee56b0e0767524a1532d18f02aa864aa5f2a0d410c5a94" + }, + { + "path": "reference-v7/trial/case-ledger.json", + "bytes": 743156, + "sha256": "27cec291ce29785a5979333e0b8c7717b12726ed0181b643da527807ce2e0d45" + }, + { + "path": "reference-v7/trial/full-source-manifest.json", + "bytes": 842883, + "sha256": "cecc94c87d105162d30852398d34ec1423ada3cc70c19b4883205552436c241d" + }, + { + "path": "reference-v7/trial/git-metadata.json", + "bytes": 24114, + "sha256": "59f1c69e955d03d6ac0f92713ca3203be5229a9c4b002b68ac763398a36e2804" + }, + { + "path": "reference-v7/trial/options.json", + "bytes": 843, + "sha256": "f702ab462728d8595f6514689d87a4d9ba354203dcf8a1e51700a8053bd0a461" + }, + { + "path": "README.md", + "bytes": 2800, + "sha256": "ef44b2a3f38742c3a896dce53b3ffb50d3d6e500457c1924ceddfd19ef31ffaa" + }, + { + "path": "LATEST-RESULT.txt", + "bytes": 692, + "sha256": "bf9f12c0bdbb4dc4c3eb766e19caa70b485f2afd3a5412fccdbe2c1fc5112f38" + } + ] +} diff --git a/tools/validation-engine/validation-engine-v1.1.0.tar.gz b/tools/validation-engine/validation-engine-v1.1.0.tar.gz new file mode 100644 index 000000000..b595cd719 Binary files /dev/null and b/tools/validation-engine/validation-engine-v1.1.0.tar.gz differ