From d2552c5abe19edab8569c862730f7326ca33738b Mon Sep 17 00:00:00 2001 From: "r.osipin" Date: Thu, 28 May 2026 14:11:11 +0300 Subject: [PATCH 1/6] test: harden desktop import bridge --- README.md | 6 +- docs/acceptance-checklist.md | 20 +- electron/preload-contract.test.cjs | 60 +++++ electron/scanner.test.cjs | 90 +++++++ src/App.imports-exports.test.tsx | 221 ++++++++++++++++++ src/features/catalog/api/catalogTypes.ts | 24 +- .../catalog/catalogApi.protocol.test.ts | 33 +++ src/features/imports/ImportsWorkspace.tsx | 8 + 8 files changed, 445 insertions(+), 17 deletions(-) create mode 100644 electron/preload-contract.test.cjs create mode 100644 electron/scanner.test.cjs diff --git a/README.md b/README.md index a1df4f0..79e5fd7 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ The Vite dev server usually listens on `http://localhost:5173`. Open the web URL ## Desktop Development -The desktop app packages the same UI through Electron and enables local folder import. It scans audio metadata and SHA-256 content hashes locally, then sends metadata, file identity, paths and cover artifacts to the API. It does not upload audio files. +The desktop app packages the same UI through Electron and enables local folder import. It scans audio metadata and SHA-256 content hashes locally, then sends metadata, file identity, paths and cover artifacts to the API. It does not upload audio files. The hosted desktop submission contract is documented in the sibling API repository at `cratebase-api/docs/imports/desktop-import-api-boundary.md`. Run the API first, then start the desktop app in development mode: @@ -108,7 +108,7 @@ npm run build - Workspaces for artists, releases, tracks, owned items, labels, relations, playlists, imports, exports and settings. - Server graph context in catalog detail panels, including credits, relations, media, collector signals and playlist backlinks. - Persistent manual and smart playlists through `/api/playlists`. -- Desktop local folder import with streaming SHA-256 hashes and duplicate review warnings. +- Browser import review and desktop-only local folder scanning through the Electron preload bridge, with streaming SHA-256 hashes and duplicate review warnings. - JSON and CSV export downloads in browser and desktop modes. - JSON restore into an empty active collection. @@ -118,7 +118,7 @@ See [docs/acceptance-checklist.md](docs/acceptance-checklist.md) for the shared - Smart playlist editing currently exposes simple rule text and preserves server rules when editing existing smart playlists. - Manual playlist creation can persist ordered server catalog links; free-form draft links are kept client-side until linked to catalog IDs. -- The browser app can review import sessions but cannot browse arbitrary local folders. +- The browser app can review import sessions but cannot browse arbitrary local folders or show the desktop folder picker. - There is no streaming player, social graph, marketplace, recommendation engine or external catalog integration. - Cratebase Web is a working catalog, search and relation-navigation interface, not a public profile system or mobile-first app. diff --git a/docs/acceptance-checklist.md b/docs/acceptance-checklist.md index 65849e6..d04ea6d 100644 --- a/docs/acceptance-checklist.md +++ b/docs/acceptance-checklist.md @@ -30,14 +30,16 @@ and `cratebase-web`. 5. Create a manual playlist with ordered release or track references and verify the order remains stable after reload. 6. Create a smart playlist with tag, genre, media, ownership status or year rules and verify results are computed from current catalog data. 7. Confirm playlists appear in search, export data, catalog links and graph backlinks. -8. Use the desktop app to scan a local audio folder and create an import review session. -9. Confirm every supported audio file includes a SHA-256 `contentHash` in the desktop scan request. -10. Re-import the same folder and verify fully duplicate drafts are no-ops against existing catalog data. -11. Rename or move duplicate files and verify same-collection content hash matching still preselects existing tracks. -12. Add a partial duplicate folder and verify existing tracks are preselected while missing catalog data can still be created. -13. Use saved search views for `remixes`, `productions`, `labels`, `physicalWithoutDigital`, `lossyWithoutLossless`, `wantedNotOwned` and `needsDigitization`. -14. Export JSON and CSV and verify core catalog data, import-created data, playlists and playlist entries are present. -15. Restore a JSON export into an empty collection and verify restored search, graph context, playlists and exports. +8. Use the browser app to review existing import sessions and confirm it does not expose local folder selection. +9. Use the desktop app to scan a local audio folder through `window.cratebaseDesktop.imports.pickAndScan()` and create an import review session. +10. Confirm every supported audio file includes a SHA-256 `contentHash` in the desktop scan request, and confirm audio bytes are not uploaded. +11. Confirm the native import confirmation prompt appears before catalog records are created. +12. Re-import the same folder and verify fully duplicate drafts are no-ops against existing catalog data. +13. Rename or move duplicate files and verify same-collection content hash matching still preselects existing tracks. +14. Add a partial duplicate folder and verify existing tracks are preselected while missing catalog data can still be created. +15. Use saved search views for `remixes`, `productions`, `labels`, `physicalWithoutDigital`, `lossyWithoutLossless`, `wantedNotOwned` and `needsDigitization`. +16. Export JSON and CSV and verify core catalog data, import-created data, playlists and playlist entries are present. +17. Restore a JSON export into an empty collection and verify restored search, graph context, playlists and exports. ## Verification Commands @@ -62,6 +64,6 @@ npm run build ## Product Boundaries - Smart playlists are dynamic rules, not materialized snapshots. -- Browser import review is supported, but local folder scanning is desktop-only. +- Browser import review is supported, but local folder scanning is desktop-only through the Electron preload bridge. The API boundary is documented in `cratebase-api/docs/imports/desktop-import-api-boundary.md`. - Audio files are not uploaded to the API. - External catalog integrations, streaming, marketplace, social, and recommendation features are outside the product boundary. diff --git a/electron/preload-contract.test.cjs b/electron/preload-contract.test.cjs new file mode 100644 index 0000000..08d86af --- /dev/null +++ b/electron/preload-contract.test.cjs @@ -0,0 +1,60 @@ +// @vitest-environment node + +const Module = require('node:module') +const path = require('node:path') + +const preloadPath = path.join(__dirname, 'preload.cjs') +const originalLoad = Module._load + +describe('desktop preload contract', () => { + afterEach(() => { + Module._load = originalLoad + delete require.cache[preloadPath] + }) + + it('exposes only the cratebaseDesktop bridge and routes imports and exports over IPC', async () => { + const exposeInMainWorld = vi.fn() + const invoke = vi + .fn() + .mockResolvedValueOnce({ cancelled: true }) + .mockResolvedValueOnce({ cancelled: false, path: '/tmp/export.json' }) + + Module._load = function load(request, parent, isMain) { + if (request === 'electron') { + return { + contextBridge: { exposeInMainWorld }, + ipcRenderer: { invoke }, + } + } + + return originalLoad.call(this, request, parent, isMain) + } + + require(preloadPath) + + expect(exposeInMainWorld).toHaveBeenCalledTimes(1) + const [bridgeName, bridge] = exposeInMainWorld.mock.calls[0] + expect(bridgeName).toBe('cratebaseDesktop') + expect(Object.keys(bridge).sort()).toEqual([ + 'exports', + 'imports', + 'isDesktop', + ]) + expect(Object.keys(bridge.imports)).toEqual(['pickAndScan']) + expect(Object.keys(bridge.exports)).toEqual(['download']) + + await expect(bridge.imports.pickAndScan()).resolves.toEqual({ + cancelled: true, + }) + await expect(bridge.exports.download('json')).resolves.toEqual({ + cancelled: false, + path: '/tmp/export.json', + }) + expect(invoke).toHaveBeenNthCalledWith(1, 'cratebase:imports:pick-and-scan') + expect(invoke).toHaveBeenNthCalledWith( + 2, + 'cratebase:exports:download', + 'json', + ) + }) +}) diff --git a/electron/scanner.test.cjs b/electron/scanner.test.cjs new file mode 100644 index 0000000..592e034 --- /dev/null +++ b/electron/scanner.test.cjs @@ -0,0 +1,90 @@ +// @vitest-environment node + +const crypto = require('node:crypto') +const fs = require('node:fs/promises') +const os = require('node:os') +const path = require('node:path') +const { scanFolder } = require('./scanner.cjs') + +const tempRoots = [] + +async function createTempRoot() { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'cratebase-scan-')) + tempRoots.push(root) + return root +} + +describe('desktop folder scanner', () => { + afterEach(async () => { + await Promise.all( + tempRoots.splice(0).map((root) => + fs.rm(root, { + force: true, + recursive: true, + }), + ), + ) + }) + + it('hashes audio files, includes metadata shape, and never attaches audio bytes', async () => { + const root = await createTempRoot() + const releaseDir = path.join(root, 'Release') + const audioPath = path.join(releaseDir, '01 Track.flac') + const coverPath = path.join(releaseDir, 'cover.jpg') + const audioBytes = Buffer.from('fake flac bytes') + const coverBytes = Buffer.from('cover bytes') + const mtime = new Date('2026-05-16T12:00:00Z') + + await fs.mkdir(releaseDir, { recursive: true }) + await fs.writeFile(audioPath, audioBytes) + await fs.writeFile(coverPath, coverBytes) + await fs.writeFile(path.join(releaseDir, 'notes.txt'), 'ignored') + await fs.writeFile(path.join(releaseDir, '.DS_Store'), 'ignored') + await fs.utimes(audioPath, mtime, mtime) + await fs.utimes(coverPath, mtime, mtime) + + const scan = await scanFolder(root) + + const audio = scan.files.find((file) => file.relativePath.endsWith('.flac')) + const cover = scan.files.find((file) => file.relativePath.endsWith('.jpg')) + expect(scan.sourceRoot).toBe(root) + expect(scan.ignoredFileCount).toBe(2) + expect(audio).toMatchObject({ + filePath: audioPath, + relativePath: path.join('Release', '01 Track.flac'), + format: 'flac', + sizeBytes: audioBytes.length, + lastModifiedAt: '2026-05-16T12:00:00.000Z', + contentHash: crypto.createHash('sha256').update(audioBytes).digest('hex'), + audioMetadata: { + title: null, + artists: [], + albumTitle: null, + albumArtists: [], + catalogNumber: null, + releaseDate: null, + year: null, + durationSeconds: null, + trackNumber: null, + }, + coverArtifact: null, + }) + expect(audio).not.toHaveProperty('contentBase64') + expect(JSON.stringify(audio)).not.toContain(audioBytes.toString('base64')) + expect(cover).toMatchObject({ + filePath: coverPath, + relativePath: path.join('Release', 'cover.jpg'), + format: null, + sizeBytes: coverBytes.length, + lastModifiedAt: '2026-05-16T12:00:00.000Z', + audioMetadata: null, + coverArtifact: { + fileName: 'cover.jpg', + extension: '.jpg', + contentType: 'image/jpeg', + sizeBytes: coverBytes.length, + contentBase64: coverBytes.toString('base64'), + }, + }) + }) +}) diff --git a/src/App.imports-exports.test.tsx b/src/App.imports-exports.test.tsx index 611dd27..07f3bd0 100644 --- a/src/App.imports-exports.test.tsx +++ b/src/App.imports-exports.test.tsx @@ -3,6 +3,89 @@ import * as h from './test/appTestHarness' h.setupAppTestHooks() +const desktopAudioContentHash = + '70bc8f4b72a86921468bf8e8441dce51d8c6cb7d792fa7bbcb0d4d9eba328b75' + +function importSessionDetailResponse(status: 'needsReview' | 'confirmed') { + return h.jsonResponse({ + id: 'import-session-1', + sourceRoot: '/Users/example/Music', + status: status === 'confirmed' ? 'confirmed' : 'readyForReview', + draftCount: 1, + trackCount: 1, + ignoredFileCount: 0, + createdAt: '2026-05-16T12:00:00Z', + updatedAt: '2026-05-16T12:00:00Z', + drafts: [ + { + id: 'draft-1', + sourcePath: '/Users/example/Music/Release', + relativePath: 'Release', + status, + title: 'Imported Release', + type: 'album', + catalogNumber: null, + labelName: null, + releaseDate: null, + year: 1992, + isVariousArtists: false, + notOnLabel: true, + artistNames: ['Aphex Twin'], + artistCredits: [], + selectedArtistIds: [], + artistSuggestions: [], + labels: [], + genres: [], + tags: ['local-import'], + coverPath: 'Release/cover.jpg', + issues: [], + tracks: [ + { + id: 'draft-track-1', + filePath: '/Users/example/Music/Release/01 Track.flac', + relativePath: 'Release/01 Track.flac', + format: 'flac', + sizeBytes: 12, + lastModifiedAt: '2026-05-16T12:00:00Z', + durationSeconds: null, + position: 1, + title: 'Track', + artistNames: ['Aphex Twin'], + artistCredits: [], + artistSuggestions: [], + trackSuggestions: [], + isSkipped: false, + selectedTrackId: null, + selectedArtistIds: [], + issues: [], + }, + ], + }, + ], + }) +} + +function importSessionListResponse() { + return h.jsonResponse({ + items: [ + { + id: 'import-session-1', + sourceRoot: '/Users/example/Music', + status: 'readyForReview', + draftCount: 1, + trackCount: 1, + ignoredFileCount: 0, + createdAt: '2026-05-16T12:00:00Z', + updatedAt: '2026-05-16T12:00:00Z', + drafts: [], + }, + ], + limit: 100, + offset: 0, + total: 1, + }) +} + describe('App imports and exports', () => { it('shows the desktop download CTA for local imports in web mode', () => { window.history.pushState({}, '', '/imports') @@ -122,6 +205,144 @@ describe('App imports and exports', () => { } }) + it('posts desktop scan results, selects the first draft, and sends no audio bytes', async () => { + vi.stubGlobal('__cratebaseUseRealCatalogApi', true) + window.history.pushState({}, '', '/imports') + const pickAndScan = vi.fn().mockResolvedValue({ + cancelled: false, + scan: { + sourceRoot: '/Users/example/Music', + ignoredFileCount: 1, + files: [ + { + filePath: '/Users/example/Music/Release/01 Track.flac', + relativePath: 'Release/01 Track.flac', + format: 'flac', + sizeBytes: 12, + lastModifiedAt: '2026-05-16T12:00:00Z', + contentHash: desktopAudioContentHash, + audioMetadata: { + title: 'Track', + artists: ['Aphex Twin'], + albumTitle: 'Imported Release', + albumArtists: ['Aphex Twin'], + catalogNumber: null, + releaseDate: null, + year: 1992, + durationSeconds: null, + trackNumber: 1, + }, + coverArtifact: null, + }, + { + filePath: '/Users/example/Music/Release/cover.jpg', + relativePath: 'Release/cover.jpg', + format: null, + sizeBytes: 11, + lastModifiedAt: '2026-05-16T12:00:00Z', + audioMetadata: null, + coverArtifact: { + fileName: 'cover.jpg', + extension: '.jpg', + contentType: 'image/jpeg', + sizeBytes: 11, + contentBase64: 'Y292ZXIgYnl0ZXM=', + }, + }, + ], + }, + }) + const originalDesktopBridge = window.cratebaseDesktop + window.cratebaseDesktop = { + isDesktop: true, + exports: { download: vi.fn() }, + imports: { pickAndScan }, + } + const fetchMock = h.mockFetch( + h.emptyImportSessionsResponse(), + importSessionDetailResponse('needsReview'), + importSessionListResponse(), + ) + + try { + const user = h.userEvent.setup() + h.render() + + await user.click( + await h.screen.findByRole('button', { name: /choose local folder/i }), + ) + + expect(await h.screen.findByText('Scan saved')).toBeInTheDocument() + expect(h.screen.getByDisplayValue('Imported Release')).toBeVisible() + expect(h.screen.getByText('Ready to confirm.')).toBeInTheDocument() + const scanCall = fetchMock.mock.calls.find( + ([url]) => url === '/api/imports/desktop-folder-scans', + ) + expect(scanCall).toBeDefined() + const requestBody = JSON.parse( + ((scanCall?.[1] as RequestInit).body as string) ?? '{}', + ) as { files: Array> } + const requestJson = JSON.stringify(requestBody) + expect(requestJson).not.toContain('collectionId') + expect(requestBody.files[0]).toMatchObject({ + relativePath: 'Release/01 Track.flac', + contentHash: desktopAudioContentHash, + coverArtifact: null, + }) + expect(requestBody.files[0]).not.toHaveProperty('contentBase64') + expect(requestBody.files[0]).not.toHaveProperty('audioContentBase64') + expect(JSON.stringify(requestBody.files[0])).not.toContain( + 'ZmFrZSBmbGFjIGJ5dGVz', + ) + expect(requestBody.files[1]).toMatchObject({ + relativePath: 'Release/cover.jpg', + coverArtifact: { + contentBase64: 'Y292ZXIgYnl0ZXM=', + }, + }) + } finally { + window.cratebaseDesktop = originalDesktopBridge + } + }) + + it('cancels import confirmation before save or catalog writes when not confirmed', async () => { + vi.stubGlobal('__cratebaseUseRealCatalogApi', true) + window.history.pushState({}, '', '/imports') + const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false) + const fetchMock = h.mockFetch( + importSessionListResponse(), + importSessionDetailResponse('needsReview'), + importSessionDetailResponse('needsReview'), + importSessionDetailResponse('confirmed'), + importSessionListResponse(), + ) + const user = h.userEvent.setup() + h.render() + + await user.click( + await h.screen.findByRole('button', { name: /\/Users\/example\/Music/i }), + ) + await h.screen.findByText('Ready to confirm.') + await user.click(h.screen.getByRole('button', { name: /^confirm$/i })) + + await h.waitFor(() => + expect(confirm).toHaveBeenCalledWith( + 'Confirm this import draft and create catalog records?', + ), + ) + expect(await h.screen.findByText('Confirmation cancelled')).toBeVisible() + expect( + fetchMock.mock.calls.some( + ([url]) => typeof url === 'string' && url.includes('/drafts/'), + ), + ).toBe(false) + expect( + fetchMock.mock.calls.some( + ([url]) => typeof url === 'string' && url.includes('/confirm'), + ), + ).toBe(false) + }) + it('shows portable export downloads for the active collection', () => { window.history.pushState({}, '', '/exports') diff --git a/src/features/catalog/api/catalogTypes.ts b/src/features/catalog/api/catalogTypes.ts index 92aa7da..fb74bfa 100644 --- a/src/features/catalog/api/catalogTypes.ts +++ b/src/features/catalog/api/catalogTypes.ts @@ -182,15 +182,29 @@ export type DesktopFolderScanRequest = { ignoredFileCount: number } -export type DesktopFolderScanFileRequest = { +export type DesktopFolderScanFileRequest = + | DesktopAudioScanFileRequest + | DesktopCoverScanFileRequest + +type DesktopScanFileBaseRequest = { filePath: string relativePath: string - format?: string | null sizeBytes: number lastModifiedAt: string - contentHash?: string | null - audioMetadata?: DesktopAudioMetadataRequest | null - coverArtifact?: DesktopCoverArtifactRequest | null +} + +export type DesktopAudioScanFileRequest = DesktopScanFileBaseRequest & { + format: string + contentHash: string + audioMetadata: DesktopAudioMetadataRequest | null + coverArtifact: null +} + +export type DesktopCoverScanFileRequest = DesktopScanFileBaseRequest & { + format: null + contentHash?: null + audioMetadata: null + coverArtifact: DesktopCoverArtifactRequest } export type DesktopAudioMetadataRequest = { diff --git a/src/features/catalog/catalogApi.protocol.test.ts b/src/features/catalog/catalogApi.protocol.test.ts index ee582b6..d054aed 100644 --- a/src/features/catalog/catalogApi.protocol.test.ts +++ b/src/features/catalog/catalogApi.protocol.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import * as api from './catalogApi' import * as h from './catalogApiTestHarness' import { postEmpty, sendJson } from './api/httpClient' +import type { DesktopFolderScanFileRequest } from './catalogApi' h.setupCatalogApiAdapterTests() @@ -329,4 +330,36 @@ describe('catalog API adapter protocol, playlists and imports', () => { }, ) }) + + it('keeps desktop scan DTOs typed as audio files with hashes or cover artifacts only', () => { + const coverScanFile = { + filePath: '/Users/example/Music/Release/cover.jpg', + relativePath: 'Release/cover.jpg', + format: null, + sizeBytes: 11, + lastModifiedAt: '2026-05-16T12:00:00Z', + audioMetadata: null, + coverArtifact: { + fileName: 'cover.jpg', + extension: '.jpg', + contentType: 'image/jpeg', + sizeBytes: 11, + contentBase64: 'Y292ZXIgYnl0ZXM=', + }, + } satisfies DesktopFolderScanFileRequest + + // @ts-expect-error Audio scan files must include a SHA-256 contentHash. + const audioScanFileWithoutHash: DesktopFolderScanFileRequest = { + filePath: '/Users/example/Music/Release/01 Track.flac', + relativePath: 'Release/01 Track.flac', + format: 'flac', + sizeBytes: 12, + lastModifiedAt: '2026-05-16T12:00:00Z', + audioMetadata: null, + coverArtifact: null, + } + + expect(coverScanFile.coverArtifact.contentBase64).toBe('Y292ZXIgYnl0ZXM=') + expect(audioScanFileWithoutHash.format).toBe('flac') + }) }) diff --git a/src/features/imports/ImportsWorkspace.tsx b/src/features/imports/ImportsWorkspace.tsx index 744d03e..ca364b2 100644 --- a/src/features/imports/ImportsWorkspace.tsx +++ b/src/features/imports/ImportsWorkspace.tsx @@ -41,6 +41,8 @@ type ImportsWorkspaceProps = { } const macOsDownloadUrl = '/api/imports/desktop-downloads/macos' +const confirmImportDraftMessage = + 'Confirm this import draft and create catalog records?' export function ImportsWorkspace({ artists, @@ -222,6 +224,12 @@ export function ImportsWorkspace({ return } + if (!window.confirm(confirmImportDraftMessage)) { + setStatus('Confirmation cancelled') + setError(null) + return + } + setStatus('Confirming') setPendingAction('confirm') try { From f50e6a7099cbf5dad9ace4ef94f32330cd99a020 Mon Sep 17 00:00:00 2001 From: "r.osipin" Date: Thu, 28 May 2026 15:29:56 +0300 Subject: [PATCH 2/6] feat: add export onboarding UI --- README.md | 10 ++++ docs/acceptance-checklist.md | 35 +++++++------ src/App.auth.test.tsx | 10 ++++ src/App.imports-exports.test.tsx | 58 +++++++++++++++++++++ src/app/AuthBoundary.tsx | 12 ++++- src/features/auth/auth.css | 6 +++ src/features/exports/ExportsWorkspace.tsx | 31 +++++++++++ src/features/exports/exports.css | 39 ++++++++++++++ src/features/imports/ImportReviewPanels.tsx | 7 ++- 9 files changed, 188 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 79e5fd7..8437b51 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,9 @@ Packaged builds default to `https://cratebase.example.com`, while development builds default to `http://localhost:5094`. Set `CRATEBASE_API_BASE_URL` at runtime when a desktop build must target another hosted origin. +Private beta users sign in with issued credentials. The first bootstrap setup +creates the first admin account and its default private collection. + The cross-repository compose and reverse proxy example lives in `../cratebase-api/deploy`. @@ -112,6 +115,13 @@ npm run build - JSON and CSV export downloads in browser and desktop modes. - JSON restore into an empty active collection. +The desktop import API boundary is documented in +`../cratebase-api/docs/imports/desktop-import-api-boundary.md`. The portable +export v1 contract is documented in +`../cratebase-api/docs/exports/portable-export-v1.md`. User-triggered JSON and +CSV exports are portability tools and personal backups; hosted service backups +are an operator-managed responsibility outside the export UI. + See [docs/acceptance-checklist.md](docs/acceptance-checklist.md) for the shared acceptance path. ## Product Boundaries diff --git a/docs/acceptance-checklist.md b/docs/acceptance-checklist.md index d04ea6d..e8417e2 100644 --- a/docs/acceptance-checklist.md +++ b/docs/acceptance-checklist.md @@ -24,22 +24,24 @@ and `cratebase-web`. ## Acceptance Path 1. Bootstrap a clean database and create the first admin user. -2. Create a release manually with artist credits, label metadata, tracklist rows, genres, tags and one owned item. -3. Search for the created data by artist, release title, track title, label, media, ownership status, tag and credit role. -4. Open catalog result details and verify server graph sections for credits, relations, media coverage, collector signals and workspace links. -5. Create a manual playlist with ordered release or track references and verify the order remains stable after reload. -6. Create a smart playlist with tag, genre, media, ownership status or year rules and verify results are computed from current catalog data. -7. Confirm playlists appear in search, export data, catalog links and graph backlinks. -8. Use the browser app to review existing import sessions and confirm it does not expose local folder selection. -9. Use the desktop app to scan a local audio folder through `window.cratebaseDesktop.imports.pickAndScan()` and create an import review session. -10. Confirm every supported audio file includes a SHA-256 `contentHash` in the desktop scan request, and confirm audio bytes are not uploaded. -11. Confirm the native import confirmation prompt appears before catalog records are created. -12. Re-import the same folder and verify fully duplicate drafts are no-ops against existing catalog data. -13. Rename or move duplicate files and verify same-collection content hash matching still preselects existing tracks. -14. Add a partial duplicate folder and verify existing tracks are preselected while missing catalog data can still be created. -15. Use saved search views for `remixes`, `productions`, `labels`, `physicalWithoutDigital`, `lossyWithoutLossless`, `wantedNotOwned` and `needsDigitization`. -16. Export JSON and CSV and verify core catalog data, import-created data, playlists and playlist entries are present. -17. Restore a JSON export into an empty collection and verify restored search, graph context, playlists and exports. +2. Confirm sign-in and bootstrap copy describes invited private beta access, the default private collection, and the hosted archive workflow without marketing claims. +3. Create a release manually with artist credits, label metadata, tracklist rows, genres, tags and one owned item. +4. Search for the created data by artist, release title, track title, label, media, ownership status, tag and credit role. +5. Open catalog result details and verify server graph sections for credits, relations, media coverage, collector signals and workspace links. +6. Create a manual playlist with ordered release or track references and verify the order remains stable after reload. +7. Create a smart playlist with tag, genre, media, ownership status or year rules and verify results are computed from current catalog data. +8. Confirm playlists appear in search, export data, catalog links and graph backlinks. +9. Use the browser app to review existing import sessions and confirm it does not expose local folder selection. +10. Use the desktop app to scan a local audio folder through `window.cratebaseDesktop.imports.pickAndScan()` and create an import review session. +11. Confirm every supported audio file includes a SHA-256 `contentHash` in the desktop scan request, and confirm audio bytes are not uploaded. +12. Confirm the native import confirmation prompt appears before catalog records are created. +13. Re-import the same folder and verify fully duplicate drafts are no-ops against existing catalog data. +14. Rename or move duplicate files and verify same-collection content hash matching still preselects existing tracks. +15. Add a partial duplicate folder and verify existing tracks are preselected while missing catalog data can still be created. +16. Use saved search views for `remixes`, `productions`, `labels`, `physicalWithoutDigital`, `lossyWithoutLossless`, `wantedNotOwned` and `needsDigitization`. +17. Open the export workspace and confirm it explains JSON/CSV scope, known v1 limits, no audio export, and the difference between user exports and hosted service backups. +18. Export JSON and CSV and verify core catalog data, import-created data, playlists and playlist entries are present. +19. Restore a JSON export into an empty collection and verify restored search, graph context, playlists and exports. ## Verification Commands @@ -66,4 +68,5 @@ npm run build - Smart playlists are dynamic rules, not materialized snapshots. - Browser import review is supported, but local folder scanning is desktop-only through the Electron preload bridge. The API boundary is documented in `cratebase-api/docs/imports/desktop-import-api-boundary.md`. - Audio files are not uploaded to the API. +- User-triggered JSON and CSV exports are portability tools and personal backups. Hosted service backups are separate operator-managed recovery work, and the export v1 contract is documented in `cratebase-api/docs/exports/portable-export-v1.md`. - External catalog integrations, streaming, marketplace, social, and recommendation features are outside the product boundary. diff --git a/src/App.auth.test.tsx b/src/App.auth.test.tsx index 79da6c3..39bd273 100644 --- a/src/App.auth.test.tsx +++ b/src/App.auth.test.tsx @@ -20,6 +20,11 @@ describe('App auth', () => { expect( await h.screen.findByRole('form', { name: 'Sign in' }), ).toBeInTheDocument() + expect( + h.screen.getByText( + /Invited private beta users sign in with the credentials issued for their collection/i, + ), + ).toBeVisible() }) it('shows bootstrap setup for first user state', async () => { @@ -38,6 +43,11 @@ describe('App auth', () => { expect( await h.screen.findByRole('form', { name: 'Bootstrap setup' }), ).toBeInTheDocument() + expect( + h.screen.getByText( + /Bootstrap creates the first admin account and its default private collection/i, + ), + ).toBeVisible() }) it('logs out back to sign in', async () => { diff --git a/src/App.imports-exports.test.tsx b/src/App.imports-exports.test.tsx index 07f3bd0..f97a852 100644 --- a/src/App.imports-exports.test.tsx +++ b/src/App.imports-exports.test.tsx @@ -101,6 +101,11 @@ describe('App imports and exports', () => { expect( h.screen.queryByRole('button', { name: /choose local folder/i }), ).not.toBeInTheDocument() + expect( + h.screen.getByText( + /Desktop import sends metadata, hashes, paths and cover artifacts, not audio files/i, + ), + ).toBeVisible() }) it('loads import review sessions from the authenticated API', async () => { @@ -364,6 +369,26 @@ describe('App imports and exports', () => { expect( h.screen.getByRole('button', { name: /download csv/i }), ).toBeEnabled() + expect( + h.screen.getByText( + /User exports are portable snapshots for personal backup and spreadsheet work/i, + ), + ).toBeVisible() + expect( + h.screen.getByText( + /Hosted service backups are operated separately from these JSON and CSV downloads/i, + ), + ).toBeVisible() + expect( + h.screen.getByText( + /Export v1 includes confirmed catalog data and omits audio bytes, raw cover bytes, import review drafts and account data/i, + ), + ).toBeVisible() + expect( + h.screen.getByText( + /JSON restore is available only for an empty active collection/i, + ), + ).toBeVisible() }) it('starts JSON exports through authenticated direct browser downloads', async () => { @@ -395,6 +420,35 @@ describe('App imports and exports', () => { expect(await h.screen.findByText('JSON export started')).toBeInTheDocument() }) + it('starts CSV exports through authenticated direct browser downloads', async () => { + window.history.pushState({}, '', '/exports') + const { click, createObjectURL, download, revokeObjectURL } = + h.stubBrowserExportDownload() + const fetchMock = h.mockFetch( + new Response(null, { + headers: { + 'Content-Disposition': 'attachment; filename="cratebase.zip"', + }, + status: 200, + }), + ) + const user = h.userEvent.setup() + h.render() + + await user.click(h.screen.getByRole('button', { name: /download csv/i })) + + expect(fetchMock).toHaveBeenCalledWith('/api/exports/csv', { + credentials: 'include', + method: 'HEAD', + }) + expect(click).toHaveBeenCalledOnce() + expect(download.href).toBe('/api/exports/csv') + expect(download.fileName).toBe('cratebase.zip') + expect(createObjectURL).not.toHaveBeenCalled() + expect(revokeObjectURL).not.toHaveBeenCalled() + expect(await h.screen.findByText('CSV export started')).toBeInTheDocument() + }) + it('shows export server failures accessibly and resets pending state', async () => { window.history.pushState({}, '', '/exports') h.mockFetch( @@ -454,6 +508,10 @@ describe('App imports and exports', () => { expect(downloadExport).toHaveBeenCalledWith('json') expect(await h.screen.findByText('JSON export saved')).toBeInTheDocument() + await user.click(h.screen.getByRole('button', { name: /download csv/i })) + + expect(downloadExport).toHaveBeenCalledWith('csv') + expect(await h.screen.findByText('CSV export saved')).toBeInTheDocument() expect( h.screen.queryByRole('link', { name: /download json/i }), ).not.toBeInTheDocument() diff --git a/src/app/AuthBoundary.tsx b/src/app/AuthBoundary.tsx index b6772be..e800ae2 100644 --- a/src/app/AuthBoundary.tsx +++ b/src/app/AuthBoundary.tsx @@ -242,8 +242,16 @@ function AuthForm({

Cratebase

Personal music archive.

{mode === 'bootstrap' ? ( -

Create the first local admin and default collection.

- ) : null} +

+ Bootstrap creates the first admin account and its default private + collection. +

+ ) : ( +

+ Invited private beta users sign in with the credentials issued for + their collection. +

+ )}
)} +
+

Private beta export boundary

+

+ User exports are portable snapshots for personal backup and + spreadsheet work. +

+

+ Hosted service backups are operated separately from these JSON and + CSV downloads. +

+
+
JSON, CSV
+
+

Export v1 limits

+
    +
  • + Export v1 includes confirmed catalog data and omits audio bytes, + raw cover bytes, import review drafts and account data. +
  • +
  • + JSON restore is available only for an empty active collection. +
  • +
  • + Confirmed desktop imports appear as ordinary catalog records in + future exports. +
  • +
+
) diff --git a/src/features/exports/exports.css b/src/features/exports/exports.css index 07f317f..99ce897 100644 --- a/src/features/exports/exports.css +++ b/src/features/exports/exports.css @@ -16,6 +16,28 @@ gap: 10px; } +.exports-guidance { + display: grid; + gap: 8px; + border-top: 1px solid var(--color-border); + padding-top: 12px; +} + +.exports-guidance h3, +.exports-boundary-list h3 { + margin: 0; + color: var(--color-heading); + font-size: 14px; + line-height: 1.3; +} + +.exports-guidance p { + margin: 0; + color: var(--color-muted); + font-size: 13px; + line-height: 1.45; +} + .exports-download-row { display: grid; grid-template-columns: 36px minmax(0, 1fr) auto; @@ -110,3 +132,20 @@ font-size: 13px; font-weight: 760; } + +.exports-boundary-list { + display: grid; + gap: 8px; + border-top: 1px solid var(--color-border); + padding-top: 12px; +} + +.exports-boundary-list ul { + display: grid; + gap: 8px; + margin: 0; + padding-left: 18px; + color: var(--color-muted); + font-size: 13px; + line-height: 1.45; +} diff --git a/src/features/imports/ImportReviewPanels.tsx b/src/features/imports/ImportReviewPanels.tsx index 7843224..5c15fa4 100644 --- a/src/features/imports/ImportReviewPanels.tsx +++ b/src/features/imports/ImportReviewPanels.tsx @@ -11,7 +11,9 @@ export function ImportSourcePanel({ isDesktop }: { isDesktop: boolean }) { Desktop app Local import enabled - Choose a folder on this Mac and review parsed drafts here. + Choose a folder on this Mac and review parsed drafts here. Desktop + import sends metadata, hashes, paths and cover artifacts, not audio + files. @@ -25,7 +27,8 @@ export function ImportSourcePanel({ isDesktop }: { isDesktop: boolean }) { Local folder import is desktop-only Web review remains available; local folder selection runs in the macOS - app. + app. Desktop import sends metadata, hashes, paths and cover artifacts, + not audio files. From 2fe7f423425a71792046c911340169812d687979 Mon Sep 17 00:00:00 2001 From: "r.osipin" Date: Thu, 28 May 2026 16:46:08 +0300 Subject: [PATCH 3/6] docs: link private beta readiness guides --- README.md | 7 +++++++ docs/acceptance-checklist.md | 2 ++ 2 files changed, 9 insertions(+) diff --git a/README.md b/README.md index 8437b51..5240531 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,10 @@ creates the first admin account and its default private collection. The cross-repository compose and reverse proxy example lives in `../cratebase-api/deploy`. +Private beta data handling, hosted backup ownership, and release readiness are +documented in +`../cratebase-api/docs/private-beta/data-handling-and-trust.md` and +`../cratebase-api/docs/private-beta/release-readiness.md`. ## Verification @@ -121,6 +125,9 @@ export v1 contract is documented in `../cratebase-api/docs/exports/portable-export-v1.md`. User-triggered JSON and CSV exports are portability tools and personal backups; hosted service backups are an operator-managed responsibility outside the export UI. +Private beta data handling and release readiness are tracked in +`../cratebase-api/docs/private-beta/data-handling-and-trust.md` and +`../cratebase-api/docs/private-beta/release-readiness.md`. See [docs/acceptance-checklist.md](docs/acceptance-checklist.md) for the shared acceptance path. diff --git a/docs/acceptance-checklist.md b/docs/acceptance-checklist.md index e8417e2..a97bdab 100644 --- a/docs/acceptance-checklist.md +++ b/docs/acceptance-checklist.md @@ -20,6 +20,7 @@ and `cratebase-web`. - Confirm private beta desktop packages target `https://cratebase.example.com` by default, with `CRATEBASE_API_BASE_URL` available as a runtime override. - Build the API and web Docker images, then run the example compose stack and verify `/health`, `/web-health`, web routing and authenticated `/api` calls through the reverse proxy. - Verify staging and production do not share PostgreSQL databases, service storage, secrets, invite data or user accounts. +- Review `cratebase-api/docs/private-beta/data-handling-and-trust.md` and `cratebase-api/docs/private-beta/release-readiness.md` before private beta evidence is collected. ## Acceptance Path @@ -69,4 +70,5 @@ npm run build - Browser import review is supported, but local folder scanning is desktop-only through the Electron preload bridge. The API boundary is documented in `cratebase-api/docs/imports/desktop-import-api-boundary.md`. - Audio files are not uploaded to the API. - User-triggered JSON and CSV exports are portability tools and personal backups. Hosted service backups are separate operator-managed recovery work, and the export v1 contract is documented in `cratebase-api/docs/exports/portable-export-v1.md`. +- Private beta data-handling and release-readiness expectations are documented in `cratebase-api/docs/private-beta/data-handling-and-trust.md` and `cratebase-api/docs/private-beta/release-readiness.md`. - External catalog integrations, streaming, marketplace, social, and recommendation features are outside the product boundary. From 4e2e8a34d7b4a7a74034470bcca9755825c4cec9 Mon Sep 17 00:00:00 2001 From: "r.osipin" Date: Thu, 28 May 2026 19:11:17 +0300 Subject: [PATCH 4/6] test: split export app tests --- src/App.exports.test.tsx | 178 +++++++++++++++++++++++++++++++ src/App.imports-exports.test.tsx | 172 ----------------------------- 2 files changed, 178 insertions(+), 172 deletions(-) create mode 100644 src/App.exports.test.tsx diff --git a/src/App.exports.test.tsx b/src/App.exports.test.tsx new file mode 100644 index 0000000..d8ced2a --- /dev/null +++ b/src/App.exports.test.tsx @@ -0,0 +1,178 @@ +import { describe, expect, it, vi } from 'vitest' +import * as h from './test/appTestHarness' + +h.setupAppTestHooks() + +describe('App exports', () => { + it('shows portable export downloads for the active collection', () => { + window.history.pushState({}, '', '/exports') + + h.render() + + expect( + h.screen.getByRole('region', { name: 'Exports workspace' }), + ).toBeInTheDocument() + expect( + h.screen.getByText(`${h.releaseRecords.length} releases`), + ).toBeVisible() + expect(h.screen.getByText(`${h.trackRecords.length} tracks`)).toBeVisible() + expect( + h.screen.getByText(`${h.ownedItemRecords.length} owned items`), + ).toBeVisible() + expect( + h.screen.getByRole('button', { name: /download json/i }), + ).toBeEnabled() + expect( + h.screen.getByRole('button', { name: /download csv/i }), + ).toBeEnabled() + expect( + h.screen.getByText( + /User exports are portable snapshots for personal backup and spreadsheet work/i, + ), + ).toBeVisible() + expect( + h.screen.getByText( + /Hosted service backups are operated separately from these JSON and CSV downloads/i, + ), + ).toBeVisible() + expect( + h.screen.getByText( + /Export v1 includes confirmed catalog data and omits audio bytes, raw cover bytes, import review drafts and account data/i, + ), + ).toBeVisible() + expect( + h.screen.getByText( + /JSON restore is available only for an empty active collection/i, + ), + ).toBeVisible() + }) + + it('starts JSON exports through authenticated direct browser downloads', async () => { + window.history.pushState({}, '', '/exports') + const { click, createObjectURL, download, revokeObjectURL } = + h.stubBrowserExportDownload() + const fetchMock = h.mockFetch( + new Response(null, { + headers: { + 'Content-Disposition': 'attachment; filename="cratebase.json"', + }, + status: 200, + }), + ) + const user = h.userEvent.setup() + h.render() + + await user.click(h.screen.getByRole('button', { name: /download json/i })) + + expect(fetchMock).toHaveBeenCalledWith('/api/exports/json', { + credentials: 'include', + method: 'HEAD', + }) + expect(click).toHaveBeenCalledOnce() + expect(download.href).toBe('/api/exports/json') + expect(download.fileName).toBe('cratebase.json') + expect(createObjectURL).not.toHaveBeenCalled() + expect(revokeObjectURL).not.toHaveBeenCalled() + expect(await h.screen.findByText('JSON export started')).toBeInTheDocument() + }) + + it('starts CSV exports through authenticated direct browser downloads', async () => { + window.history.pushState({}, '', '/exports') + const { click, createObjectURL, download, revokeObjectURL } = + h.stubBrowserExportDownload() + const fetchMock = h.mockFetch( + new Response(null, { + headers: { + 'Content-Disposition': 'attachment; filename="cratebase.zip"', + }, + status: 200, + }), + ) + const user = h.userEvent.setup() + h.render() + + await user.click(h.screen.getByRole('button', { name: /download csv/i })) + + expect(fetchMock).toHaveBeenCalledWith('/api/exports/csv', { + credentials: 'include', + method: 'HEAD', + }) + expect(click).toHaveBeenCalledOnce() + expect(download.href).toBe('/api/exports/csv') + expect(download.fileName).toBe('cratebase.zip') + expect(createObjectURL).not.toHaveBeenCalled() + expect(revokeObjectURL).not.toHaveBeenCalled() + expect(await h.screen.findByText('CSV export started')).toBeInTheDocument() + }) + + it('shows export server failures accessibly and resets pending state', async () => { + window.history.pushState({}, '', '/exports') + h.mockFetch( + h.jsonResponse( + { code: 'exports.server_error', message: 'Export failed' }, + 500, + ), + ) + const user = h.userEvent.setup() + h.render() + + const downloadJson = h.screen.getByRole('button', { + name: /download json/i, + }) + await user.click(downloadJson) + + expect(await h.screen.findByRole('alert')).toHaveTextContent( + 'Export failed', + ) + expect(downloadJson).toBeEnabled() + }) + + it('returns to sign in when export download expires the session', async () => { + window.history.pushState({}, '', '/exports') + h.mockFetch( + h.jsonResponse({ code: 'auth.unauthenticated', message: 'Expired' }, 401), + new Response(null, { status: 204 }), + ) + const user = h.userEvent.setup() + h.render() + + await user.click(h.screen.getByRole('button', { name: /download json/i })) + + expect( + await h.screen.findByRole('form', { name: 'Sign in' }), + ).toBeInTheDocument() + }) + + it('routes export downloads through the desktop bridge in desktop mode', async () => { + window.history.pushState({}, '', '/exports') + const downloadExport = vi.fn().mockResolvedValue({ + cancelled: false, + path: '/tmp/cratebase-export.json', + }) + const originalDesktopBridge = window.cratebaseDesktop + window.cratebaseDesktop = { + isDesktop: true, + imports: { pickAndScan: vi.fn() }, + exports: { download: downloadExport }, + } + + try { + const user = h.userEvent.setup() + h.render() + + await user.click(h.screen.getByRole('button', { name: /download json/i })) + + expect(downloadExport).toHaveBeenCalledWith('json') + expect(await h.screen.findByText('JSON export saved')).toBeInTheDocument() + await user.click(h.screen.getByRole('button', { name: /download csv/i })) + + expect(downloadExport).toHaveBeenCalledWith('csv') + expect(await h.screen.findByText('CSV export saved')).toBeInTheDocument() + expect( + h.screen.queryByRole('link', { name: /download json/i }), + ).not.toBeInTheDocument() + } finally { + window.cratebaseDesktop = originalDesktopBridge + } + }) +}) diff --git a/src/App.imports-exports.test.tsx b/src/App.imports-exports.test.tsx index f97a852..840aa5e 100644 --- a/src/App.imports-exports.test.tsx +++ b/src/App.imports-exports.test.tsx @@ -348,178 +348,6 @@ describe('App imports and exports', () => { ).toBe(false) }) - it('shows portable export downloads for the active collection', () => { - window.history.pushState({}, '', '/exports') - - h.render() - - expect( - h.screen.getByRole('region', { name: 'Exports workspace' }), - ).toBeInTheDocument() - expect( - h.screen.getByText(`${h.releaseRecords.length} releases`), - ).toBeVisible() - expect(h.screen.getByText(`${h.trackRecords.length} tracks`)).toBeVisible() - expect( - h.screen.getByText(`${h.ownedItemRecords.length} owned items`), - ).toBeVisible() - expect( - h.screen.getByRole('button', { name: /download json/i }), - ).toBeEnabled() - expect( - h.screen.getByRole('button', { name: /download csv/i }), - ).toBeEnabled() - expect( - h.screen.getByText( - /User exports are portable snapshots for personal backup and spreadsheet work/i, - ), - ).toBeVisible() - expect( - h.screen.getByText( - /Hosted service backups are operated separately from these JSON and CSV downloads/i, - ), - ).toBeVisible() - expect( - h.screen.getByText( - /Export v1 includes confirmed catalog data and omits audio bytes, raw cover bytes, import review drafts and account data/i, - ), - ).toBeVisible() - expect( - h.screen.getByText( - /JSON restore is available only for an empty active collection/i, - ), - ).toBeVisible() - }) - - it('starts JSON exports through authenticated direct browser downloads', async () => { - window.history.pushState({}, '', '/exports') - const { click, createObjectURL, download, revokeObjectURL } = - h.stubBrowserExportDownload() - const fetchMock = h.mockFetch( - new Response(null, { - headers: { - 'Content-Disposition': 'attachment; filename="cratebase.json"', - }, - status: 200, - }), - ) - const user = h.userEvent.setup() - h.render() - - await user.click(h.screen.getByRole('button', { name: /download json/i })) - - expect(fetchMock).toHaveBeenCalledWith('/api/exports/json', { - credentials: 'include', - method: 'HEAD', - }) - expect(click).toHaveBeenCalledOnce() - expect(download.href).toBe('/api/exports/json') - expect(download.fileName).toBe('cratebase.json') - expect(createObjectURL).not.toHaveBeenCalled() - expect(revokeObjectURL).not.toHaveBeenCalled() - expect(await h.screen.findByText('JSON export started')).toBeInTheDocument() - }) - - it('starts CSV exports through authenticated direct browser downloads', async () => { - window.history.pushState({}, '', '/exports') - const { click, createObjectURL, download, revokeObjectURL } = - h.stubBrowserExportDownload() - const fetchMock = h.mockFetch( - new Response(null, { - headers: { - 'Content-Disposition': 'attachment; filename="cratebase.zip"', - }, - status: 200, - }), - ) - const user = h.userEvent.setup() - h.render() - - await user.click(h.screen.getByRole('button', { name: /download csv/i })) - - expect(fetchMock).toHaveBeenCalledWith('/api/exports/csv', { - credentials: 'include', - method: 'HEAD', - }) - expect(click).toHaveBeenCalledOnce() - expect(download.href).toBe('/api/exports/csv') - expect(download.fileName).toBe('cratebase.zip') - expect(createObjectURL).not.toHaveBeenCalled() - expect(revokeObjectURL).not.toHaveBeenCalled() - expect(await h.screen.findByText('CSV export started')).toBeInTheDocument() - }) - - it('shows export server failures accessibly and resets pending state', async () => { - window.history.pushState({}, '', '/exports') - h.mockFetch( - h.jsonResponse( - { code: 'exports.server_error', message: 'Export failed' }, - 500, - ), - ) - const user = h.userEvent.setup() - h.render() - - const downloadJson = h.screen.getByRole('button', { - name: /download json/i, - }) - await user.click(downloadJson) - - expect(await h.screen.findByRole('alert')).toHaveTextContent( - 'Export failed', - ) - expect(downloadJson).toBeEnabled() - }) - - it('returns to sign in when export download expires the session', async () => { - window.history.pushState({}, '', '/exports') - h.mockFetch( - h.jsonResponse({ code: 'auth.unauthenticated', message: 'Expired' }, 401), - new Response(null, { status: 204 }), - ) - const user = h.userEvent.setup() - h.render() - - await user.click(h.screen.getByRole('button', { name: /download json/i })) - - expect( - await h.screen.findByRole('form', { name: 'Sign in' }), - ).toBeInTheDocument() - }) - - it('routes export downloads through the desktop bridge in desktop mode', async () => { - window.history.pushState({}, '', '/exports') - const downloadExport = vi.fn().mockResolvedValue({ - cancelled: false, - path: '/tmp/cratebase-export.json', - }) - const originalDesktopBridge = window.cratebaseDesktop - window.cratebaseDesktop = { - isDesktop: true, - imports: { pickAndScan: vi.fn() }, - exports: { download: downloadExport }, - } - - try { - const user = h.userEvent.setup() - h.render() - - await user.click(h.screen.getByRole('button', { name: /download json/i })) - - expect(downloadExport).toHaveBeenCalledWith('json') - expect(await h.screen.findByText('JSON export saved')).toBeInTheDocument() - await user.click(h.screen.getByRole('button', { name: /download csv/i })) - - expect(downloadExport).toHaveBeenCalledWith('csv') - expect(await h.screen.findByText('CSV export saved')).toBeInTheDocument() - expect( - h.screen.queryByRole('link', { name: /download json/i }), - ).not.toBeInTheDocument() - } finally { - window.cratebaseDesktop = originalDesktopBridge - } - }) - it('restores JSON backups without a full catalog reload', async () => { window.history.pushState({}, '', '/imports') h.clearCatalogForTests() From 142db18b7244eada00c9dc3f56f2194f9c8f7057 Mon Sep 17 00:00:00 2001 From: "r.osipin" Date: Thu, 28 May 2026 20:30:27 +0300 Subject: [PATCH 5/6] Fix imports workspace responsive layout --- src/features/imports/imports.css | 68 ++++++++++++++++++++++++++++++-- 1 file changed, 65 insertions(+), 3 deletions(-) diff --git a/src/features/imports/imports.css b/src/features/imports/imports.css index 9ca6185..d878d0f 100644 --- a/src/features/imports/imports.css +++ b/src/features/imports/imports.css @@ -1,5 +1,14 @@ .imports-layout { grid-template-columns: minmax(360px, 0.72fr) minmax(0, 1.28fr); + min-width: 0; +} + +.imports-layout > .catalog-main, +.imports-layout > .detail-panel, +.imports-layout .catalog-main > .panel, +.imports-layout .panel-heading > div, +.imports-agent-card > div { + min-width: 0; } .imports-session-table td:first-child { @@ -98,9 +107,7 @@ color: var(--color-muted); font-size: 12px; line-height: 1.35; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + overflow-wrap: anywhere; } .imports-agent-card { @@ -399,3 +406,58 @@ align-items: center; gap: 6px; } + +@media (max-width: 1100px) { + .imports-layout { + grid-template-columns: minmax(0, 1fr); + } + + .imports-layout > .detail-panel { + position: static; + } + + .imports-restore-row { + grid-template-columns: 36px minmax(0, 1fr); + } + + .imports-restore-row .button { + grid-column: 1 / -1; + } +} + +@media (max-width: 760px) { + .imports-scan-panel .panel-heading, + .imports-agent-card, + .imports-folder-selection, + .imports-folder-picker-header, + .imports-folder-current { + align-items: stretch; + flex-direction: column; + } + + .imports-scan-panel .panel-heading .button, + .imports-folder-selection .button, + .imports-folder-picker-actions .button { + width: 100%; + justify-content: center; + } + + .imports-folder-picker-actions { + justify-content: stretch; + } + + .imports-release-grid, + .imports-track-detail-grid, + .imports-tracklist-layout { + grid-template-columns: minmax(0, 1fr); + } + + .imports-tracklist-master { + max-height: 260px; + } + + .imports-tracklist-toolbar { + align-items: stretch; + flex-direction: column; + } +} From 7c7df07c993046732a28b72f5ba3ee0a4bc81a80 Mon Sep 17 00:00:00 2001 From: "r.osipin" Date: Thu, 28 May 2026 21:43:42 +0300 Subject: [PATCH 6/6] Fix desktop backend default --- electron/backend-config.cjs | 10 ++++++++++ electron/backend-config.test.cjs | 31 +++++++++++++++++++++++++++++++ electron/main.cjs | 7 ++----- 3 files changed, 43 insertions(+), 5 deletions(-) create mode 100644 electron/backend-config.cjs create mode 100644 electron/backend-config.test.cjs diff --git a/electron/backend-config.cjs b/electron/backend-config.cjs new file mode 100644 index 0000000..b5c5c26 --- /dev/null +++ b/electron/backend-config.cjs @@ -0,0 +1,10 @@ +const localApiBaseUrl = 'http://localhost:5094' + +function resolveBackendBaseUrl() { + return process.env.CRATEBASE_API_BASE_URL || localApiBaseUrl +} + +module.exports = { + localApiBaseUrl, + resolveBackendBaseUrl, +} diff --git a/electron/backend-config.test.cjs b/electron/backend-config.test.cjs new file mode 100644 index 0000000..e711afc --- /dev/null +++ b/electron/backend-config.test.cjs @@ -0,0 +1,31 @@ +// @vitest-environment node + +const { resolveBackendBaseUrl } = require('./backend-config.cjs') + +describe('desktop backend configuration', () => { + const originalApiBaseUrl = process.env.CRATEBASE_API_BASE_URL + + afterEach(() => { + if (originalApiBaseUrl === undefined) { + delete process.env.CRATEBASE_API_BASE_URL + } else { + process.env.CRATEBASE_API_BASE_URL = originalApiBaseUrl + } + }) + + it('uses the local API by default for packaged desktop builds', () => { + delete process.env.CRATEBASE_API_BASE_URL + + expect(resolveBackendBaseUrl({ isPackaged: true })).toBe( + 'http://localhost:5094', + ) + }) + + it('allows the backend URL to be overridden for hosted deployments', () => { + process.env.CRATEBASE_API_BASE_URL = 'https://cratebase.example.test' + + expect(resolveBackendBaseUrl({ isPackaged: true })).toBe( + 'https://cratebase.example.test', + ) + }) +}) diff --git a/electron/main.cjs b/electron/main.cjs index 074a4ff..ccdc7bb 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -2,13 +2,10 @@ const { app, BrowserWindow, dialog, ipcMain, shell } = require('electron') const fsp = require('node:fs/promises') const http = require('node:http') const path = require('node:path') +const { resolveBackendBaseUrl } = require('./backend-config.cjs') const { scanFolder } = require('./scanner.cjs') -const localApiBaseUrl = 'http://localhost:5094' -const hostedApiBaseUrl = 'https://cratebase.example.com' -const backendBaseUrl = - process.env.CRATEBASE_API_BASE_URL || - (app.isPackaged ? hostedApiBaseUrl : localApiBaseUrl) +const backendBaseUrl = resolveBackendBaseUrl() const devServerUrl = process.env.CRATEBASE_DESKTOP_DEV_SERVER const cookieJar = new Map() const strippedProxyResponseHeaders = new Set([