diff --git a/backend/README.md b/backend/README.md index f41f96c..6195ebd 100644 --- a/backend/README.md +++ b/backend/README.md @@ -103,6 +103,9 @@ mounted outside production. configured separately for archives. - Upload defaults are 90 MB for base/demo videos and 80 MB for contributor recordings. File contents are verified by signature, not browser MIME type. +- Reference and demo-video uploads accept browser-reported `width` and `height` + in pixels, each from 0 to 16,384. Zero represents unknown dimensions; these + values are metadata for responsive playback, not server-side media probes. - `SUBMISSION_COOLDOWN_MS` defaults to 30 seconds per contributor/video pair. Atomic claiming prevents simultaneous duplicate submissions; failed processing releases the claim. @@ -121,7 +124,7 @@ npm test The command runs Node's built-in test runner serially across every `tests/*.test.js` file. The API suites use MongoDB Memory Server unless `MONGODB_URI_TEST` is supplied. That configured database is dropped by the tests, -so use only a disposable database. Provider delivery is stubbed. The 56 cases and +so use only a disposable database. Provider delivery is stubbed. The 57 cases and their test types are documented in [docs/TEST-CASES.md](docs/TEST-CASES.md). ## Current limitation diff --git a/backend/docs/API.md b/backend/docs/API.md index c972b6f..1cf6cf6 100644 --- a/backend/docs/API.md +++ b/backend/docs/API.md @@ -114,6 +114,14 @@ The video and demo-video upload routes use `multipart/form-data`: | `categoryId` | Optional category ID | Not accepted | | `status` | Optional video status | Not accepted | | `durationSec` | Optional duration in seconds | Optional duration in seconds | +| `width` | Optional displayed width in pixels | Optional displayed width in pixels | +| `height` | Optional displayed height in pixels | Optional displayed height in pixels | + +`width` and `height` are integers from 0 through 16,384 and default to `0` +when omitted or unknown. They are reported by the admin's browser during video +preparation (including rotation metadata); the API stores the values but does +not probe media files to verify them. The frontend uses the stored dimensions +to size responsive video frames before playback metadata loads. Submission creation uses `multipart/form-data` with a required `recording` file and `videoId`, `trimStart`, `trimEnd`, `mirrored`, and `duration` fields. Only diff --git a/backend/docs/TEST-CASES.md b/backend/docs/TEST-CASES.md index 5d5d015..5e7d6a9 100644 --- a/backend/docs/TEST-CASES.md +++ b/backend/docs/TEST-CASES.md @@ -1,12 +1,12 @@ # Backend test cases -The backend suite uses Node's built-in test runner. It currently contains **56 test +The backend suite uses Node's built-in test runner. It currently contains **57 test cases** across twelve test files. Each named API test suite has its own `.test.js` file and starts with a fresh test database and account fixtures: | Test file | Cases | Main test type | | --- | ---: | --- | -| `tests/api-basics.test.js` | 4 | HTTP integration tests | +| `tests/api-basics.test.js` | 5 | HTTP integration tests | | `tests/api-demo-video.test.js` | 1 | HTTP integration tests | | `tests/api-auth.test.js` | 8 | HTTP integration tests | | `tests/api-profile-connections.test.js` | 1 | HTTP integration tests | @@ -43,128 +43,131 @@ their respective test file. Each API case below is an HTTP integration test. favicon handling, both documentation UI routes, and both OpenAPI JSON routes. 2. **health check reports the database** — Checks that the health endpoint reports a connected database. -3. **unknown routes and bad JSON use the error shape** — Checks 404 handling and +3. **public landing stats return aggregate counts without authentication** — + Checks the public aggregate response shape and verifies its counts against the + public category and video lists. +4. **unknown routes and bad JSON use the error shape** — Checks 404 handling and malformed JSON handling, including their standard error codes. -4. **cross-origin writes are refused, allowed origin passes** — Checks that a +5. **cross-origin writes are refused, allowed origin passes** — Checks that a disallowed request origin is rejected while the configured client origin can perform a write. ### `tests/api-demo-video.test.js` — Demo video -5. **admins can upload, replace, and delete the public demo video** — Checks public +6. **admins can upload, replace, and delete the public demo video** — Checks public empty and file responses, admin-only upload, replacement and cleanup, and that the demo video is separate from the regular video library. ### `tests/api-auth.test.js` — Authentication -6. **signup validates every field** — Checks required signup fields, password +7. **signup validates every field** — Checks required signup fields, password confirmation, and minimum password length. -7. **signup sets an httpOnly cookie and never leaks the hash or accepts a role** — +8. **signup sets an httpOnly cookie and never leaks the hash or accepts a role** — Checks that signup does not create a session before verification, ignores a submitted admin role, verifies email, sets secure cookie attributes, and does not expose the password hash. -8. **password reset codes are one-time and replace the old password** — Checks the +9. **password reset codes are one-time and replace the old password** — Checks the generic reset-request response, invalid-code handling, password replacement, code reuse rejection, and invalidation of the old session. -9. **duplicate emails conflict, case-insensitively** — Checks that signup rejects +10. **duplicate emails conflict, case-insensitively** — Checks that signup rejects an address that already exists with different letter casing. -10. **login: wrong password and unknown email give the same answer** — Checks +11. **login: wrong password and unknown email give the same answer** — Checks equivalent unauthorized responses for an unknown account and a wrong password. -11. **session, logout and tampered cookies** — Checks anonymous and authenticated +12. **session, logout and tampered cookies** — Checks anonymous and authenticated session responses, logout, and rejection of an invalid session cookie. -12. **the env admin exists once, has the admin role, and re-running is harmless** +13. **the env admin exists once, has the admin role, and re-running is harmless** — Checks environment-admin bootstrap is idempotent and assigns the admin role. -13. **admin-only routes reject contributors and visitors** — Checks role and +14. **admin-only routes reject contributors and visitors** — Checks role and authentication enforcement on an admin endpoint. ### `tests/api-profile-connections.test.js` — Profile connections -14. **normalises, validates, and disconnects** — Checks GitHub and LinkedIn URL +15. **normalises, validates, and disconnects** — Checks GitHub and LinkedIn URL normalization, invalid-handle validation, clearing one connection without changing the other, and authentication requirements. ### `tests/api-categories.test.js` — Categories -15. **only admins write; names are unique regardless of case** — Checks role +16. **only admins write; names are unique regardless of case** — Checks role access, category creation, case-insensitive uniqueness, and invalid tone validation. -16. **update and public listing** — Checks category updates, duplicate-name and +17. **update and public listing** — Checks category updates, duplicate-name and empty-update validation, public listing, and invalid ID validation. ### `tests/api-videos.test.js` — Videos -17. **upload needs a real video file** — Checks missing, fake, and invalid-category +18. **upload needs a real video file** — Checks missing, fake, and invalid-category uploads, admin access, and cleanup of rejected temporary uploads. -18. **admin uploads with and without a poster; the API hides storage internals** — +19. **admin uploads with and without a poster; the API hides storage internals** — Checks upload responses and defaults, ordering, optional posters, public resource URLs, storage metadata privacy, and stored-file counts. -19. **visibility: contributors and visitors only see published, categorised +20. **visibility: contributors and visitors only see published, categorised videos** — Checks that admins can see all videos while contributors and visitors only see published videos assigned to a category. -20. **published files stream publicly with Range support** — Checks full and +21. **published files stream publicly with Range support** — Checks full and partial video responses, suffix ranges, unsatisfiable ranges, content types, and hidden draft files. -21. **posters are served as images** — Checks public poster delivery and the +22. **posters are served as images** — Checks public poster delivery and the missing-poster response. -22. **editing: moving categories appends to the end of that path** — Checks video +23. **editing: moving categories appends to the end of that path** — Checks video movement and ordering, invalid category/status handling, draft updates, and admin-only editing. ### `tests/api-submissions.test.js` — Submissions and cooldown -23. **archive and database failures release cooldown and remove any archived +24. **archive and database failures release cooldown and remove any archived file** — Checks rollback and cleanup when archive storage or database creation fails after the cooldown is claimed. -24. **input is checked before anything is stored** — Checks authentication, +25. **input is checked before anything is stored** — Checks authentication, required and valid recording content, trim ranges, video IDs and visibility, plus absence of orphaned files on rejection. -25. **a valid submission is stored and listed** — Checks successful submission +26. **a valid submission is stored and listed** — Checks successful submission metadata, recording privacy, archive storage, contributor ownership, and admin visibility. -26. **COOLDOWN: a second submission for the same video, too soon, is refused and +27. **COOLDOWN: a second submission for the same video, too soon, is refused and stores nothing** — Checks cooldown rejection and temporary/archive file cleanup. -27. **COOLDOWN: concurrent double-submits for the same video produce exactly one +28. **COOLDOWN: concurrent double-submits for the same video produce exactly one submission** — Checks atomic cooldown behavior under concurrent uploads and cleanup of losing uploads. -28. **COOLDOWN: once it elapses, the same video can be submitted again, as a new, +29. **COOLDOWN: once it elapses, the same video can be submitted again, as a new, separate recording** — Checks that a later submission succeeds as a distinct append-only record after the cooldown expires. -29. **a submission can never be edited, deleted, or read once archived** — Checks +30. **a submission can never be edited, deleted, or read once archived** — Checks that submission mutation and recording playback endpoints remain unavailable to contributors and admins. -30. **deleting a base video keeps contributors' submissions on record** — Checks +31. **deleting a base video keeps contributors' submissions on record** — Checks that deleting the reference video removes its file and record without removing archived submissions. ### `tests/api-admin-stats-contact.test.js` — Admin stats and contact -31. **stats add up** — Checks admin access, aggregate totals, daily activity, +32. **stats add up** — Checks admin access, aggregate totals, daily activity, category counts, recent submissions, handling of deleted videos, and the allowed day range. -32. **deleting a category unassigns its videos instead of deleting them** — +33. **deleting a category unassigns its videos instead of deleting them** — Checks that category deletion keeps videos but makes unassigned videos invisible to contributors. -33. **contact delivery requires matching account email, enforces quotas, and +34. **contact delivery requires matching account email, enforces quotas, and blocks abusive devices** — Checks contact validation, delivery failure handling, matching account email, per-user and global daily quotas, anonymous access rejection, device blocking, message listing, and admin-only access. -34. **oversized uploads are rejected with 413** — Checks payload-too-large +35. **oversized uploads are rejected with 413** — Checks payload-too-large responses and temporary-file cleanup. ### `tests/api-admin-user-controls.test.js` — Admin user controls and access restrictions -35. **suspending a user blocks its existing session and login; reactivation +36. **suspending a user blocks its existing session and login; reactivation restores access** — Checks self-suspension protection, suspension effects on sessions and login, and reactivation. -36. **deleting a user removes their submissions, cooldowns, and stored +37. **deleting a user removes their submissions, cooldowns, and stored recording** — Checks account deletion cleanup across database records, cooldowns, archive storage, sessions, and admin listings. -37. **device restrictions reject matching IDs, expose only a hint, and can be +38. **device restrictions reject matching IDs, expose only a hint, and can be removed** — Checks device restriction creation, hashed-identifier privacy, blocking, duplicate detection, and removal. -38. **IP restrictions block API traffic while admin routes remain available for +39. **IP restrictions block API traffic while admin routes remain available for recovery** — Checks IP validation and restriction enforcement while preserving admin access to remove restrictions. @@ -175,55 +178,55 @@ routes. The cases are separated by the behavior under test. ### `tests/safeName.test.js` — Safe names (unit tests) -39. **safeName uses its fallback for nullish and blank values** — Checks fallback +40. **safeName uses its fallback for nullish and blank values** — Checks fallback behavior for nullish, whitespace-only, and fully sanitized names. -40. **safeName replaces reserved path characters and collapses whitespace** — +41. **safeName replaces reserved path characters and collapses whitespace** — Checks removal of path-reserved characters, whitespace normalization, and string conversion of non-string values. -41. **safeName caps its output at 100 characters** — Checks the maximum safe-name +42. **safeName caps its output at 100 characters** — Checks the maximum safe-name length. ### `tests/localStorage.test.js` — Local driver and storage service (filesystem/storage integration tests) -42. **local driver stores named files in folders and appends suffixes on +43. **local driver stores named files in folders and appends suffixes on collisions** — Checks directory placement, readable keys, collision naming, and file contents. -43. **local driver handles named files without an extension and dotfiles** — +44. **local driver handles named files without an extension and dotfiles** — Checks collision naming for extensionless names and dotfiles. -44. **local driver generates unique UUID keys when no file name is supplied** — +45. **local driver generates unique UUID keys when no file name is supplied** — Checks generated key format and uniqueness. -45. **local driver rejects paths escaping the storage root and keeps the source +46. **local driver rejects paths escaping the storage root and keeps the source file** — Checks rejection of traversal through file names or folder names and confirms rejected saves do not consume the source file. -46. **local driver stat reports missing files and remove is idempotent** — Checks +47. **local driver stat reports missing files and remove is idempotent** — Checks missing-file errors from stat and harmless removal of an absent key. -47. **storageService forwards named folder and file name to the local driver** — +48. **storageService forwards named folder and file name to the local driver** — Checks storage-service metadata, destination key, file size, and cleanup. ### `tests/videoService.test.js` — Video service (unit tests) -48. **video create names video and poster from title and category safely** — +49. **video create names video and poster from title and category safely** — Checks sanitized names, category folder selection, poster naming, storage calls, and category ordering. -49. **video create uses Unassigned and Untitled fallbacks when optional labels +50. **video create uses Unassigned and Untitled fallbacks when optional labels are absent** — Checks fallback folder and file names, default ordering, and absent poster metadata. -50. **video create selects detected video and image extensions** — Checks +51. **video create selects detected video and image extensions** — Checks content-detected extensions for WebM, MOV, PNG, and WebP rather than relying on the original file name. -51. **video create rejects a missing upload, unsupported content, and invalid +52. **video create rejects a missing upload, unsupported content, and invalid categories before storage** — Checks validation errors and confirms storage is not called for invalid input. -52. **video create rejects oversized or unsupported posters before storage** — +53. **video create rejects oversized or unsupported posters before storage** — Checks poster size and real-content validation before any storage call. -53. **video create accepts a poster exactly at the size limit** — Checks that a +54. **video create accepts a poster exactly at the size limit** — Checks that a poster at the configured maximum size is accepted. -54. **video create surfaces filesystem errors instead of reporting invalid video +55. **video create surfaces filesystem errors instead of reporting invalid video content** — Checks missing-file I/O errors are preserved and storage is not called. -55. **video create removes already stored files when poster storage fails** — +56. **video create removes already stored files when poster storage fails** — Checks cleanup of the stored video when the subsequent poster save fails. -56. **video create removes video and poster if the database write fails** — +57. **video create removes video and poster if the database write fails** — Checks cleanup of both stored files when database record creation fails. ## Running the suite diff --git a/backend/package-lock.json b/backend/package-lock.json index a99420e..394e50f 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "backend", - "version": "1.0.1", + "version": "1.0.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "backend", - "version": "1.0.1", + "version": "1.0.2", "license": "MIT", "dependencies": { "@getbrevo/brevo": "^6.0.3", diff --git a/backend/package.json b/backend/package.json index 0b2ee17..9079e7d 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "backend", - "version": "1.0.1", + "version": "1.0.2", "description": "SignPakCommons API: Express, Node.js and MongoDB", "main": "server.js", "scripts": { diff --git a/backend/src/docs/openapi.js b/backend/src/docs/openapi.js index 7304966..0db3717 100644 --- a/backend/src/docs/openapi.js +++ b/backend/src/docs/openapi.js @@ -113,6 +113,8 @@ export const openapi = { categoryId: { type: 'string', nullable: true }, order: { type: 'integer' }, durationSec: { type: 'number' }, + width: { type: 'integer', description: 'Displayed width in pixels; 0 when unknown.' }, + height: { type: 'integer', description: 'Displayed height in pixels; 0 when unknown.' }, videoUrl: { type: 'string' }, poster: { type: 'string', nullable: true }, size: { type: 'integer' }, @@ -213,6 +215,8 @@ export const openapi = { categoryId: { type: 'string', nullable: true, pattern: '^[a-f\\d]{24}$' }, status: { type: 'string', enum: ['draft', 'published'], default: 'published' }, durationSec: { type: 'number', minimum: 0, maximum: 86400, default: 0 }, + width: { type: 'integer', minimum: 0, maximum: 16384, default: 0 }, + height: { type: 'integer', minimum: 0, maximum: 16384, default: 0 }, }, }, DemoVideo: { @@ -222,6 +226,8 @@ export const openapi = { id: { type: 'string', enum: ['current'] }, title: { type: 'string', maxLength: 120 }, durationSec: { type: 'number' }, + width: { type: 'integer' }, + height: { type: 'integer' }, videoUrl: { type: 'string' }, size: { type: 'integer' }, }, @@ -233,6 +239,8 @@ export const openapi = { video: { type: 'string', format: 'binary', description: 'MP4, WebM, or MOV video.' }, title: { type: 'string', minLength: 1, maxLength: 120 }, durationSec: { type: 'number', minimum: 0, maximum: 86400, default: 0 }, + width: { type: 'integer', minimum: 0, maximum: 16384, default: 0 }, + height: { type: 'integer', minimum: 0, maximum: 16384, default: 0 }, }, }, Submission: { diff --git a/backend/src/middlewares/validators/demoVideoSchemas.js b/backend/src/middlewares/validators/demoVideoSchemas.js index ddf9a9a..eb3a003 100644 --- a/backend/src/middlewares/validators/demoVideoSchemas.js +++ b/backend/src/middlewares/validators/demoVideoSchemas.js @@ -3,4 +3,6 @@ import { z } from 'zod' export const uploadDemoVideoSchema = z.object({ title: z.string({ error: 'Give the demo video a title.' }).trim().min(1, 'Give the demo video a title.').max(120, 'Keep the title under 120 characters.'), durationSec: z.coerce.number({ error: 'Duration must be a number.' }).min(0).max(24 * 60 * 60).default(0), + width: z.coerce.number().int().min(0).max(16384).default(0), + height: z.coerce.number().int().min(0).max(16384).default(0), }) diff --git a/backend/src/middlewares/validators/videoSchemas.js b/backend/src/middlewares/validators/videoSchemas.js index f17a5df..412a8b5 100644 --- a/backend/src/middlewares/validators/videoSchemas.js +++ b/backend/src/middlewares/validators/videoSchemas.js @@ -7,11 +7,15 @@ const status = z.enum(VIDEO_STATUS_LIST, { error: `Status must be one of: ${VIDE // An empty string (from a form's "Unassigned" option) means no category. const categoryId = z.preprocess((value) => (value === '' ? null : value), objectId('category').nullable()) +const pixels = z.coerce.number({ error: 'Size must be a number.' }).int().min(0).max(16384).default(0) + export const createVideoSchema = z.object({ title, status: status.default(VIDEO_STATUS.PUBLISHED), categoryId: categoryId.optional().default(null), durationSec: z.coerce.number({ error: 'Duration must be a number.' }).min(0).max(24 * 60 * 60).default(0), + width: pixels, + height: pixels, }) export const updateVideoSchema = z.object({ diff --git a/backend/src/models/DemoVideo.js b/backend/src/models/DemoVideo.js index 313c849..2103c8b 100644 --- a/backend/src/models/DemoVideo.js +++ b/backend/src/models/DemoVideo.js @@ -7,6 +7,8 @@ const demoVideoSchema = new mongoose.Schema({ _id: { type: String, default: 'current' }, title: { type: String, required: true, trim: true, maxlength: 120 }, durationSec: { type: Number, default: 0, min: 0 }, + width: { type: Number, default: 0, min: 0 }, + height: { type: Number, default: 0, min: 0 }, videoFile: { type: storedFileSchema, required: true }, }, { timestamps: true, diff --git a/backend/src/models/Video.js b/backend/src/models/Video.js index 114d342..041c4c3 100644 --- a/backend/src/models/Video.js +++ b/backend/src/models/Video.js @@ -9,6 +9,9 @@ const videoSchema = new mongoose.Schema({ category: { type: mongoose.Schema.Types.ObjectId, ref: 'Category', default: null }, order: { type: Number, default: 1, min: 0 }, durationSec: { type: Number, default: 0, min: 0 }, + // Displayed size in pixels as the browser reported it when the admin prepared the file; 0 means unknown (older uploads). + width: { type: Number, default: 0, min: 0 }, + height: { type: Number, default: 0, min: 0 }, videoFile: { type: storedFileSchema, required: true }, posterFile: { type: storedFileSchema, default: null }, createdBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User' }, diff --git a/backend/src/services/demoVideoService.js b/backend/src/services/demoVideoService.js index fd05711..1f42f25 100644 --- a/backend/src/services/demoVideoService.js +++ b/backend/src/services/demoVideoService.js @@ -22,7 +22,7 @@ export const demoVideoService = { try { const current = await demoVideoRepo.find() - const saved = await demoVideoRepo.replace({ title: input.title, durationSec: input.durationSec, videoFile }) + const saved = await demoVideoRepo.replace({ title: input.title, durationSec: input.durationSec, width: input.width, height: input.height, videoFile }) await storageService.remove(current?.videoFile) return saved } catch (error) { diff --git a/backend/src/services/videoService.js b/backend/src/services/videoService.js index 59aca85..d18bd5f 100644 --- a/backend/src/services/videoService.js +++ b/backend/src/services/videoService.js @@ -69,7 +69,7 @@ export const videoService = { } const order = input.categoryId ? (await videoRepo.maxOrder(input.categoryId)) + 1 : 1 return await videoRepo.create({ - title: input.title, status: input.status, durationSec: input.durationSec, + title: input.title, status: input.status, durationSec: input.durationSec, width: input.width, height: input.height, category: input.categoryId, order, videoFile, posterFile, createdBy: admin._id, }) } catch (error) { diff --git a/backend/tests/api-admin-stats-contact.test.js b/backend/tests/api-admin-stats-contact.test.js index f19ae4c..5b2c4af 100644 --- a/backend/tests/api-admin-stats-contact.test.js +++ b/backend/tests/api-admin-stats-contact.test.js @@ -1,19 +1,19 @@ import assert from 'node:assert/strict' -import { before, describe, test } from 'node:test' +import { after, before, describe, test } from 'node:test' import { Client, filesIn, jpeg, mp4, recordingForm, videoForm, webm } from './helpers.js' import { useApiContext } from './apiContext.js' -const getContext = useApiContext() +const getContext = useApiContext({ after }) let base, admin, contributor, other, createVerifiedUser, deliveredCodes, contactDeliveries let ContactDailyQuota, Submission, SubmissionCooldown, storageService, authService, submissionRepo let daily, work, video1, video2, draft, submission, eveSession -before(() => { - const context = getContext() +before(async () => { + const context = await getContext() ;({ base, admin, contributor, other, createVerifiedUser, deliveredCodes, contactDeliveries, ContactDailyQuota, Submission, SubmissionCooldown, storageService, authService, submissionRepo } = context) }) before(async () => { - const context = getContext() + const context = await getContext() const admin = context.admin daily = (await admin.post('/categories', { label: 'Daily phrases', tone: 'yellow' })).body.data work = (await admin.post('/categories', { label: 'Work', tone: 'blue' })).body.data diff --git a/backend/tests/api-admin-user-controls.test.js b/backend/tests/api-admin-user-controls.test.js index 5e6520e..9487730 100644 --- a/backend/tests/api-admin-user-controls.test.js +++ b/backend/tests/api-admin-user-controls.test.js @@ -1,19 +1,19 @@ import assert from 'node:assert/strict' -import { before, describe, test } from 'node:test' +import { after, before, describe, test } from 'node:test' import { Client, filesIn, jpeg, mp4, recordingForm, videoForm, webm } from './helpers.js' import { useApiContext } from './apiContext.js' -const getContext = useApiContext() +const getContext = useApiContext({ after }) let base, admin, contributor, other, createVerifiedUser, deliveredCodes, contactDeliveries let ContactDailyQuota, Submission, SubmissionCooldown, storageService, authService, submissionRepo let daily, work, video1, video2, draft, submission, eveSession -before(() => { - const context = getContext() +before(async () => { + const context = await getContext() ;({ base, admin, contributor, other, createVerifiedUser, deliveredCodes, contactDeliveries, ContactDailyQuota, Submission, SubmissionCooldown, storageService, authService, submissionRepo } = context) }) before(async () => { - const context = getContext() + const context = await getContext() const category = (await context.admin.post('/categories', { label: 'Daily phrases', tone: 'yellow' })).body.data const video = await context.admin.upload('/videos', videoForm({ title: 'First sign', categoryId: category.id, durationSec: '3' })) assert.equal(video.status, 201, JSON.stringify(video.body)) diff --git a/backend/tests/api-auth.test.js b/backend/tests/api-auth.test.js index fa59b5e..7dbb6f7 100644 --- a/backend/tests/api-auth.test.js +++ b/backend/tests/api-auth.test.js @@ -1,15 +1,16 @@ import assert from 'node:assert/strict' -import { before, describe, test } from 'node:test' +import { after, before, describe, test } from 'node:test' import { Client, filesIn, jpeg, mp4, recordingForm, videoForm, webm } from './helpers.js' import { useApiContext } from './apiContext.js' -const getContext = useApiContext() +const getContext = useApiContext({ after }) +const contributorData = { firstName: 'Sara', surname: 'Ahmed', password: 'password1' } let base, admin, contributor, other, createVerifiedUser, deliveredCodes, contactDeliveries let ContactDailyQuota, Submission, SubmissionCooldown, storageService, authService, submissionRepo let daily, work, video1, video2, draft, submission, eveSession -before(() => { - const context = getContext() +before(async () => { + const context = await getContext() ;({ base, admin, contributor, other, createVerifiedUser, deliveredCodes, contactDeliveries, ContactDailyQuota, Submission, SubmissionCooldown, storageService, authService, submissionRepo } = context) }) diff --git a/backend/tests/api-basics.test.js b/backend/tests/api-basics.test.js index bdd5a26..72a1463 100644 --- a/backend/tests/api-basics.test.js +++ b/backend/tests/api-basics.test.js @@ -1,15 +1,15 @@ import assert from 'node:assert/strict' -import { before, describe, test } from 'node:test' +import { after, before, describe, test } from 'node:test' import { Client, filesIn, jpeg, mp4, recordingForm, videoForm, webm } from './helpers.js' import { useApiContext } from './apiContext.js' -const getContext = useApiContext() +const getContext = useApiContext({ after }) let base, admin, contributor, other, createVerifiedUser, deliveredCodes, contactDeliveries let ContactDailyQuota, Submission, SubmissionCooldown, storageService, authService, submissionRepo let daily, work, video1, video2, draft, submission, eveSession -before(() => { - const context = getContext() +before(async () => { + const context = await getContext() ;({ base, admin, contributor, other, createVerifiedUser, deliveredCodes, contactDeliveries, ContactDailyQuota, Submission, SubmissionCooldown, storageService, authService, submissionRepo } = context) }) diff --git a/backend/tests/api-categories.test.js b/backend/tests/api-categories.test.js index c45d1e8..9c0d09d 100644 --- a/backend/tests/api-categories.test.js +++ b/backend/tests/api-categories.test.js @@ -1,15 +1,15 @@ import assert from 'node:assert/strict' -import { before, describe, test } from 'node:test' +import { after, before, describe, test } from 'node:test' import { Client, filesIn, jpeg, mp4, recordingForm, videoForm, webm } from './helpers.js' import { useApiContext } from './apiContext.js' -const getContext = useApiContext() +const getContext = useApiContext({ after }) let base, admin, contributor, other, createVerifiedUser, deliveredCodes, contactDeliveries let ContactDailyQuota, Submission, SubmissionCooldown, storageService, authService, submissionRepo let daily, work, video1, video2, draft, submission, eveSession -before(() => { - const context = getContext() +before(async () => { + const context = await getContext() ;({ base, admin, contributor, other, createVerifiedUser, deliveredCodes, contactDeliveries, ContactDailyQuota, Submission, SubmissionCooldown, storageService, authService, submissionRepo } = context) }) diff --git a/backend/tests/api-demo-video.test.js b/backend/tests/api-demo-video.test.js index 22d360c..ee9eb43 100644 --- a/backend/tests/api-demo-video.test.js +++ b/backend/tests/api-demo-video.test.js @@ -1,15 +1,15 @@ import assert from 'node:assert/strict' -import { before, describe, test } from 'node:test' +import { after, before, describe, test } from 'node:test' import { Client, filesIn, jpeg, mp4, recordingForm, videoForm, webm } from './helpers.js' import { useApiContext } from './apiContext.js' -const getContext = useApiContext() +const getContext = useApiContext({ after }) let base, admin, contributor, other, createVerifiedUser, deliveredCodes, contactDeliveries let ContactDailyQuota, Submission, SubmissionCooldown, storageService, authService, submissionRepo let daily, work, video1, video2, draft, submission, eveSession -before(() => { - const context = getContext() +before(async () => { + const context = await getContext() ;({ base, admin, contributor, other, createVerifiedUser, deliveredCodes, contactDeliveries, ContactDailyQuota, Submission, SubmissionCooldown, storageService, authService, submissionRepo } = context) }) diff --git a/backend/tests/api-profile-connections.test.js b/backend/tests/api-profile-connections.test.js index 93e4abb..93da5ea 100644 --- a/backend/tests/api-profile-connections.test.js +++ b/backend/tests/api-profile-connections.test.js @@ -1,15 +1,15 @@ import assert from 'node:assert/strict' -import { before, describe, test } from 'node:test' +import { after, before, describe, test } from 'node:test' import { Client, filesIn, jpeg, mp4, recordingForm, videoForm, webm } from './helpers.js' import { useApiContext } from './apiContext.js' -const getContext = useApiContext() +const getContext = useApiContext({ after }) let base, admin, contributor, other, createVerifiedUser, deliveredCodes, contactDeliveries let ContactDailyQuota, Submission, SubmissionCooldown, storageService, authService, submissionRepo let daily, work, video1, video2, draft, submission, eveSession -before(() => { - const context = getContext() +before(async () => { + const context = await getContext() ;({ base, admin, contributor, other, createVerifiedUser, deliveredCodes, contactDeliveries, ContactDailyQuota, Submission, SubmissionCooldown, storageService, authService, submissionRepo } = context) }) diff --git a/backend/tests/api-submissions.test.js b/backend/tests/api-submissions.test.js index caf49eb..d8f5ffc 100644 --- a/backend/tests/api-submissions.test.js +++ b/backend/tests/api-submissions.test.js @@ -1,19 +1,19 @@ import assert from 'node:assert/strict' -import { before, describe, test } from 'node:test' +import { after, before, describe, test } from 'node:test' import { Client, filesIn, jpeg, mp4, recordingForm, videoForm, webm } from './helpers.js' import { useApiContext } from './apiContext.js' -const getContext = useApiContext() +const getContext = useApiContext({ after }) let base, admin, contributor, other, createVerifiedUser, deliveredCodes, contactDeliveries let ContactDailyQuota, Submission, SubmissionCooldown, storageService, authService, submissionRepo let daily, work, video1, video2, draft, submission, eveSession -before(() => { - const context = getContext() +before(async () => { + const context = await getContext() ;({ base, admin, contributor, other, createVerifiedUser, deliveredCodes, contactDeliveries, ContactDailyQuota, Submission, SubmissionCooldown, storageService, authService, submissionRepo } = context) }) before(async () => { - const context = getContext() + const context = await getContext() const admin = context.admin daily = (await admin.post('/categories', { label: 'Daily phrases', tone: 'yellow' })).body.data work = (await admin.post('/categories', { label: 'Work', tone: 'blue' })).body.data diff --git a/backend/tests/api-videos.test.js b/backend/tests/api-videos.test.js index ccbdd07..b30770c 100644 --- a/backend/tests/api-videos.test.js +++ b/backend/tests/api-videos.test.js @@ -1,19 +1,19 @@ import assert from 'node:assert/strict' -import { before, describe, test } from 'node:test' +import { after, before, describe, test } from 'node:test' import { Client, filesIn, jpeg, mp4, recordingForm, videoForm, webm } from './helpers.js' import { useApiContext } from './apiContext.js' -const getContext = useApiContext() +const getContext = useApiContext({ after }) let base, admin, contributor, other, createVerifiedUser, deliveredCodes, contactDeliveries let ContactDailyQuota, Submission, SubmissionCooldown, storageService, authService, submissionRepo let daily, work, video1, video2, draft, submission, eveSession -before(() => { - const context = getContext() +before(async () => { + const context = await getContext() ;({ base, admin, contributor, other, createVerifiedUser, deliveredCodes, contactDeliveries, ContactDailyQuota, Submission, SubmissionCooldown, storageService, authService, submissionRepo } = context) }) before(async () => { - const context = getContext() + const context = await getContext() daily = (await context.admin.post('/categories', { label: 'Daily phrases', copy: 'Everyday.', tone: 'yellow' })).body.data work = (await context.admin.post('/categories', { label: 'Work', tone: 'blue' })).body.data }) diff --git a/backend/tests/apiContext.js b/backend/tests/apiContext.js index 6264432..c84f1b6 100644 --- a/backend/tests/apiContext.js +++ b/backend/tests/apiContext.js @@ -1,4 +1,3 @@ -import { after, before } from 'node:test' import { Client } from './helpers.js' process.env.NODE_ENV = 'test' @@ -26,9 +25,19 @@ const dependencies = Promise.all([ import('mongoose'), ]) -export function useApiContext() { - let context - before(async () => { +export function useApiContext({ after }) { + let contextPromise + const getContext = () => { + if (!contextPromise) contextPromise = createApiContext() + return contextPromise + } + after(async () => { + if (contextPromise) await (await contextPromise).close() + }) + return getContext +} + +async function createApiContext() { const [ { default: app }, { connectDb, disconnectDb }, @@ -100,7 +109,7 @@ export function useApiContext() { email: 'omar@example.com', }) - context = { + return { app, base, admin, @@ -125,14 +134,4 @@ export function useApiContext() { await memory?.stop() }, } - }) - - after(async () => { - if (context) await context.close() - }) - - return () => { - if (!context) throw new Error('API test context is not initialized.') - return context - } } diff --git a/backend/tests/videoService.test.js b/backend/tests/videoService.test.js index ee905c3..ed31fc1 100644 --- a/backend/tests/videoService.test.js +++ b/backend/tests/videoService.test.js @@ -1,4 +1,5 @@ import assert from 'node:assert/strict' +import fs from 'node:fs/promises' import os from 'node:os' import path from 'node:path' import test from 'node:test' diff --git a/frontend/README.md b/frontend/README.md index 7d71f10..774027c 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -15,6 +15,7 @@ cd frontend npm install npm run dev # http://localhost:5173 npm run lint +npm test npm run build npm run preview # serve the production build locally ``` @@ -65,10 +66,21 @@ put credentials or private keys in them. Backend variables are documented in Contributor recordings stay in memory as a `Blob` while the user moves between the player and editor; closing the tab discards an unsubmitted take. On -submission, the frontend uploads the video and trim/mirror metadata to the +submission, the frontend uploads the recording and trim/mirror metadata to the backend. The backend validates and stores the recording and enforces the -submission cooldown. Theme preference, device ID, and notifications are stored -locally in the browser; notifications are not synchronized between devices. +submission cooldown. Admins prepare reference and demo videos in the browser +before upload: they can remove audio and trim the clip, with processing performed +locally by ffmpeg.wasm when changes are applied. The browser also reports the +video's displayed dimensions and duration; the backend stores these values for +responsive playback and display. No video-processing worker is required on the +backend. + +The ffmpeg.wasm core is loaded from jsDelivr on first use, so admins need network +access to that CDN to apply audio removal or trimming. If processing is +unavailable, the upload UI reports the failure and allows the original video to +be uploaded unchanged. Theme preference, device ID, and notifications are +stored locally in the browser; notifications are not synchronized between +devices. ## Structure @@ -87,6 +99,8 @@ src/ seo/ Static route SEO metadata and helpers styles/ Theme tokens and feature stylesheets utils/ Validation, media, and browser storage helpers +test/ + RecorderPanel.test.jsx Recorder workflow component tests docs/ ARCHITECTURE.md Runtime, state, data flow, and deployment overview ``` @@ -119,9 +133,9 @@ origin. API cookies require compatible HTTPS and cookie settings. ## Checks -The frontend package currently provides `npm run lint`, `npm run build`, -`npm run seo:generate`, and `npm run snap`. It does not define an automated -frontend test script. +The frontend package provides `npm run lint`, `npm test`, `npm run build`, +`npm run seo:generate`, and `npm run snap`. `npm test` runs the Vitest suite in +jsdom; component interaction tests use Testing Library. ## Project model diff --git a/frontend/docs/architecture.md b/frontend/docs/architecture.md index 4388207..72a1e7f 100644 --- a/frontend/docs/architecture.md +++ b/frontend/docs/architecture.md @@ -8,8 +8,9 @@ contributor workflows, and an admin workspace. The backend API is the source of truth for accounts, categories, reference videos, and submitted recordings. The frontend requires Node.js 20.19 or newer, as declared by -`package.json`. Its package scripts are `dev`, `lint`, `build`, `preview`, -`seo:generate`, and `snap`; there is no frontend test script. +`package.json`. Its package scripts are `dev`, `lint`, `test`, `build`, +`preview`, `seo:generate`, and `snap`. `npm test` runs Vitest in jsdom, with +React component tests written using Testing Library. ## Bootstrap and providers @@ -89,7 +90,26 @@ The `RecordingProvider` keeps the current take available while a contributor moves between `/watch` and `/watch/edit`. The editor applies trim and mirror settings as submission metadata, then uploads the recording blob and metadata through the API service. The backend stores the uploaded recording and metadata; -it does not physically trim or transcode the file. +it does not physically trim or transcode contributor recordings. + +### Admin video preparation and responsive playback + +Before uploading reference or demo videos, admins use `VideoPrep` to preview the +local file, optionally remove audio, and trim its start and end. When processing +is needed, `processVideo` loads ffmpeg.wasm on demand and processes the file in +the browser; trimming re-encodes the selected range, while audio-only removal +copies the video stream. No processing happens on the backend. The core is +downloaded from jsDelivr on first use, so the browser needs network access to +that CDN to apply changes. If processing fails, the UI reports the error and +allows an unchanged original upload. + +`inspectVideoFile` reads the browser-reported duration and displayed dimensions +(including rotation metadata) and captures a poster frame locally. The upload +stores `durationSec`, `width`, and `height` with the video; unknown dimensions +are represented as zero. `ResponsiveVideo` uses stored dimensions to size the +frame before media metadata loads, then prefers the dimensions reported by the +loaded video. This keeps portrait, square, and landscape videos in their native +aspect ratio while fitting the player layout. ## UI and styling @@ -144,7 +164,9 @@ frontend/ seo/ # SEO metadata and helpers styles/ # Theme tokens and feature styles utils/ # Shared browser and media utilities - docs/ + test/ + RecorderPanel.test.jsx # Recorder workflow component tests + docs/ ARCHITECTURE.md # This guide scripts/ generateSeoAssets.js # Sitemap, robots, and prerender route generation diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f79c360..0a4c37f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,13 +1,15 @@ { "name": "frontend", - "version": "1.0.3", + "version": "1.0.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "frontend", - "version": "1.0.3", + "version": "1.0.4", "dependencies": { + "@ffmpeg/ffmpeg": "^0.12.15", + "@ffmpeg/util": "^0.12.2", "@tailwindcss/vite": "^4.3.3", "react": "^19.2.8", "react-dom": "^19.2.8", @@ -16,6 +18,7 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@testing-library/react": "^16.3.3", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.7", "@vitejs/plugin-react": "^6.1.1", @@ -23,13 +26,87 @@ "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.6", "globals": "^17.12.0", + "jsdom": "^29.1.1", "react-snap": "^1.23.0", - "vite": "^8.3.0" + "vite": "^8.3.0", + "vitest": "^5.0.4" }, "engines": { "node": ">=20.19.0" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -222,6 +299,16 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz", + "integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", @@ -270,6 +357,40 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.3.tgz", + "integrity": "sha512-0GEz8qJWokkUVYQYRukmkSHIFNoBH8QTreeHrAFbvz1XFYc3Y+puhMs8PQ5x8GNugiC2l5jvDBXKLbBIByeSBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.2.1" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@bramus/specificity/node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/@bramus/specificity/node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/@cacheable/memory": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", @@ -294,6 +415,121 @@ "keyv": "^5.6.0" } }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.2.tgz", + "integrity": "sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.3.tgz", + "integrity": "sha512-iex20d8CHVkyvg6B7UKV7uHnI2Bqo9g+EFfT9E0y+GvTvhZ/DwONJ+9aKb1dlqm0ZiGsL5RXjp0fCoJYnkeDjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.6.tgz", + "integrity": "sha512-iiPQ3iRWwnJkeEn6RIu6SJPr7hYrLz6XZ9s/QZl+2/LI5KQVjpl2fdmDSZKuD4xP6GMmMPgHFFXg6k1Wkz0Trg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.2", + "@csstools/css-calc": "^3.4.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.2.tgz", + "integrity": "sha512-40cSKyMvK+tq4qz6Awrlye2WGuOKt3FwPgtGg6KTfbHOWNw+Rk1rzbAtZnZ6IBhsY491HLRnDXwoyBAijmmILA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.2.tgz", + "integrity": "sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.10.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", @@ -422,6 +658,54 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, + "node_modules/@exodus/bytes": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz", + "integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@ffmpeg/ffmpeg": { + "version": "0.12.15", + "resolved": "https://registry.npmjs.org/@ffmpeg/ffmpeg/-/ffmpeg-0.12.15.tgz", + "integrity": "sha512-1C8Obr4GsN3xw+/1Ww6PFM84wSQAGsdoTuTWPOj2OizsRDLT4CXTaVjPhkw6ARyDus1B9X/L2LiXHqYYsGnRFw==", + "license": "MIT", + "dependencies": { + "@ffmpeg/types": "^0.12.4" + }, + "engines": { + "node": ">=18.x" + } + }, + "node_modules/@ffmpeg/types": { + "version": "0.12.4", + "resolved": "https://registry.npmjs.org/@ffmpeg/types/-/types-0.12.4.tgz", + "integrity": "sha512-k9vJQNBGTxE5AhYDtOYR5rO5fKsspbg51gbcwtbkw2lCdoIILzklulcjJfIDwrtn7XhDeF2M+THwJ2FGrLeV6A==", + "license": "MIT", + "engines": { + "node": ">=16.x" + } + }, + "node_modules/@ffmpeg/util": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/@ffmpeg/util/-/util-0.12.2.tgz", + "integrity": "sha512-ouyoW+4JB7WxjeZ2y6KpRvB+dLp7Cp4ro8z0HIVpZVCM7AwFlHa0c4R8Y/a4M3wMqATpYKhC7lSFHQ0T11MEDw==", + "license": "MIT", + "engines": { + "node": ">=18.x" + } + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -1318,6 +1602,81 @@ "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.2", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.2.tgz", + "integrity": "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/react": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz", + "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -1399,6 +1758,54 @@ } } }, + "node_modules/@vitest/mocker": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.4.tgz", + "integrity": "sha512-tqAlrYWce4iUxhr3ncNohejtCTOcHeE8XWrSNZY3OhPoDn01yeGv+gnaBy64ATp7o6pb03eIbBAjzmibpc9vIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.4", + "estree-walker": "^3.0.3", + "magic-string": "^1.4.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9 || ^3.0.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/magic-string": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", + "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.4.tgz", + "integrity": "sha512-MZKk/gSkm2q+ICobMxCpH1mA+cCAWm8WV7UhxlqGjq/sJ3AGc1BqFOs5CRVXxH78wQtme9q/NAAgBquj4AcyMw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -1466,6 +1873,42 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "dequal": "^2.0.3" + } + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -1473,6 +1916,16 @@ "dev": true, "license": "MIT" }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/async-limiter": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz", @@ -1503,6 +1956,16 @@ "node": ">=6.0.0" } }, + "node_modules/bidi-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", + "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/body-parser": { "version": "1.18.3", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.18.3.tgz", @@ -1669,6 +2132,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/cheerio": { "version": "1.0.0-rc.2", "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.2.tgz", @@ -1916,6 +2389,20 @@ "dev": true, "license": "MIT" }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1934,6 +2421,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -1951,6 +2445,17 @@ "node": ">= 0.6" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + } + }, "node_modules/destroy": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz", @@ -1967,6 +2472,14 @@ "node": ">=8" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/dom-serializer": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.1.1.tgz", @@ -2072,6 +2585,13 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, "node_modules/es6-promise": { "version": "4.2.8", "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz", @@ -2294,6 +2814,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -2314,6 +2844,16 @@ "node": ">= 0.6" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/express": { "version": "4.16.4", "resolved": "https://registry.npmjs.org/express/-/express-4.16.4.tgz", @@ -2769,6 +3309,19 @@ "dev": true, "license": "MIT" }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/html-minifier": { "version": "3.5.21", "resolved": "https://registry.npmjs.org/html-minifier/-/html-minifier-3.5.21.tgz", @@ -2931,6 +3484,13 @@ "node": ">=0.10.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-stream": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", @@ -2982,6 +3542,129 @@ "dev": true, "license": "MIT" }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.15.tgz", + "integrity": "sha512-J0u7HkVl2nzSlhsiTOp4AmwcUQ3D+mGEEKfBy/7To5/y7F2OHwyLrXfrhR0SMgr4p5Lo+eaMVSeai24zUcBIxA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/jsdom/node_modules/entities": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/jsdom/node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/jsdom/node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -3335,6 +4018,17 @@ "yallist": "^3.0.2" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -3562,6 +4256,20 @@ "boolbase": "~1.0.0" } }, + "node_modules/obug": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/obug/-/obug-3.0.0.tgz", + "integrity": "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/on-finished": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", @@ -3765,6 +4473,22 @@ "node": ">= 0.8.0" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", @@ -3916,6 +4640,14 @@ "react": "^19.3.0" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/react-router": { "version": "7.18.4", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.4.tgz", @@ -4004,6 +4736,16 @@ "node": ">= 0.10" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/rimraf": { "version": "2.7.1", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", @@ -4065,6 +4807,19 @@ "dev": true, "license": "MIT" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.28.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", @@ -4229,6 +4984,13 @@ "node": ">= 0.6" } }, + "node_modules/std-env": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", + "dev": true, + "license": "MIT" + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -4260,6 +5022,13 @@ ], "license": "MIT" }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwindcss": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", @@ -4279,6 +5048,26 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tinybench": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.2.tgz", + "integrity": "sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -4295,6 +5084,52 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tldts": { + "version": "7.4.18", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.18.tgz", + "integrity": "sha512-somxA8xKAUzlAxl0TX/jMSYIbRNFIVGnI96pVGJPeMc5BsHmfVXn6mOJT6U++ELnl04eGfzpfcEuZe8WiASkXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.18" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.18", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.18.tgz", + "integrity": "sha512-KI2PKvCmvQI/xD6ypPqkU0i7oxNXR/O58RLRaHR4svsgVUQ2JE/XC8BtMbG6PSehD27I8sZJRcC1ASnCy6lp3g==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -4353,6 +5188,16 @@ "dev": true, "license": "MIT" }, + "node_modules/undici": { + "version": "7.30.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.0.tgz", + "integrity": "sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "8.9.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", @@ -4522,6 +5367,122 @@ } } }, + "node_modules/vitest": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.4.tgz", + "integrity": "sha512-z0WxZRV2Sg48wF0FSgMq3TFG8JS38/Qr96vZVZdX6+Em9raQTvnXQvdTFJe2m3rfyJnEHeHQ/snceIdi8D1pVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.4", + "chai": "^6.3.0", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.4.3", + "obug": "^3.0.0", + "picomatch": "^4.0.7", + "std-env": "^4.3.0", + "tinybench": "^6.2.2", + "tinyexec": "^1.3.1", + "tinyglobby": "^0.2.17", + "why-is-node-running": "3.2.1" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.4", + "@vitest/browser-preview": "5.0.4", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.4", + "@vitest/coverage-v8": "5.0.4", + "@vitest/ui": "5.0.4", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/magic-string": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", + "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, "node_modules/whatwg-fetch": { "version": "3.6.20", "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", @@ -4529,6 +5490,31 @@ "dev": true, "license": "MIT" }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -4545,6 +5531,19 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", + "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", + "dev": true, + "license": "MIT", + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=20.11" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -4572,6 +5571,23 @@ "async-limiter": "~1.0.0" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 5c318ed..2ca9315 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "1.0.3", + "version": "1.0.4", "type": "module", "engines": { "node": ">=20.19.0" @@ -11,11 +11,14 @@ "prebuild": "npm run seo:generate", "build": "vite build", "lint": "eslint .", + "test": "vitest run --environment jsdom", "preview": "vite preview", "seo:generate": "node scripts/generateSeoAssets.js", "snap": "node scripts/runReactSnap.js" }, "dependencies": { + "@ffmpeg/ffmpeg": "^0.12.15", + "@ffmpeg/util": "^0.12.2", "@tailwindcss/vite": "^4.3.3", "react": "^19.2.8", "react-dom": "^19.2.8", @@ -24,6 +27,7 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@testing-library/react": "^16.3.3", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.7", "@vitejs/plugin-react": "^6.1.1", @@ -31,7 +35,9 @@ "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.6", "globals": "^17.12.0", + "jsdom": "^29.1.1", "react-snap": "^1.23.0", - "vite": "^8.3.0" + "vite": "^8.3.0", + "vitest": "^5.0.4" } } diff --git a/frontend/src/components/BasePlayer.jsx b/frontend/src/components/BasePlayer.jsx index 5d02dc5..e2c6de1 100644 --- a/frontend/src/components/BasePlayer.jsx +++ b/frontend/src/components/BasePlayer.jsx @@ -1,10 +1,12 @@ import { useRef, useState } from 'react' import { formatTime } from '../utils/format' +import { aspectOf } from '../utils/media' +import ResponsiveVideo from './ResponsiveVideo' const SPEEDS = [0.5, 0.75, 1] /** Base video player: play/pause, replay, seek, speed and fullscreen. The parent owns the