mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-24 05:48:59 +08:00
feat(native): record encoding (#14188)
fix #13784 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Start/stop system or meeting recordings with Ogg/Opus artifacts and native start/stop APIs; workspace backup recovery. * **Refactor** * Simplified recording lifecycle and UI flows; native runtime now orchestrates recording/processing and reporting. * **Bug Fixes** * Stronger path validation, safer import/export dialogs, consistent error handling/logging, and retry-safe recording processing. * **Chores** * Added cross-platform native audio capture and Ogg/Opus encoding support. * **Tests** * New unit, integration, and e2e tests for recording, path guards, dialogs, and workspace recovery. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -21,6 +21,11 @@ const docSetSpaceId = vi.fn();
|
||||
const sqliteValidate = vi.fn();
|
||||
const sqliteValidateImportSchema = vi.fn();
|
||||
const sqliteVacuumInto = vi.fn();
|
||||
const sqliteClose = vi.fn();
|
||||
const showOpenDialog = vi.fn();
|
||||
const showSaveDialog = vi.fn();
|
||||
const showItemInFolder = vi.fn(async () => undefined);
|
||||
const getPath = vi.fn();
|
||||
|
||||
vi.doMock('nanoid', () => ({
|
||||
nanoid: () => 'workspace-1',
|
||||
@@ -70,6 +75,10 @@ vi.doMock('@affine/native', () => {
|
||||
vacuumInto(path: string) {
|
||||
return sqliteVacuumInto(this.path, path);
|
||||
}
|
||||
|
||||
close() {
|
||||
return sqliteClose(this.path);
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -84,7 +93,10 @@ vi.doMock('@affine/electron/helper/nbstore', () => ({
|
||||
|
||||
vi.doMock('@affine/electron/helper/main-rpc', () => ({
|
||||
mainRPC: {
|
||||
showItemInFolder: vi.fn(),
|
||||
getPath,
|
||||
showItemInFolder,
|
||||
showOpenDialog,
|
||||
showSaveDialog,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -126,12 +138,11 @@ describe('dialog export', () => {
|
||||
realpath.mockImplementation(async path => path);
|
||||
getSpaceDBPath.mockResolvedValue(dbPath);
|
||||
move.mockResolvedValue(undefined);
|
||||
showSaveDialog.mockResolvedValue({ canceled: false, filePath: exportPath });
|
||||
|
||||
const { saveDBFileAs, setFakeDialogResult } =
|
||||
const { saveDBFileAs } =
|
||||
await import('@affine/electron/helper/dialog/dialog');
|
||||
|
||||
setFakeDialogResult({ filePath: exportPath });
|
||||
|
||||
const result = await saveDBFileAs(id, 'My Space');
|
||||
|
||||
expect(result).toEqual({ filePath: exportPath });
|
||||
@@ -151,12 +162,11 @@ describe('dialog export', () => {
|
||||
|
||||
pathExists.mockResolvedValue(false);
|
||||
getSpaceDBPath.mockResolvedValue(dbPath);
|
||||
showSaveDialog.mockResolvedValue({ canceled: false, filePath: dbPath });
|
||||
|
||||
const { saveDBFileAs, setFakeDialogResult } =
|
||||
const { saveDBFileAs } =
|
||||
await import('@affine/electron/helper/dialog/dialog');
|
||||
|
||||
setFakeDialogResult({ filePath: dbPath });
|
||||
|
||||
const result = await saveDBFileAs(id, 'My Space');
|
||||
|
||||
expect(result).toEqual({ error: 'DB_FILE_PATH_INVALID' });
|
||||
@@ -174,12 +184,11 @@ describe('dialog export', () => {
|
||||
path === exportPath ? dbPath : path
|
||||
);
|
||||
getSpaceDBPath.mockResolvedValue(dbPath);
|
||||
showSaveDialog.mockResolvedValue({ canceled: false, filePath: exportPath });
|
||||
|
||||
const { saveDBFileAs, setFakeDialogResult } =
|
||||
const { saveDBFileAs } =
|
||||
await import('@affine/electron/helper/dialog/dialog');
|
||||
|
||||
setFakeDialogResult({ filePath: exportPath });
|
||||
|
||||
const result = await saveDBFileAs(id, 'My Space');
|
||||
|
||||
expect(result).toEqual({ error: 'DB_FILE_PATH_INVALID' });
|
||||
@@ -193,6 +202,12 @@ describe('dialog import', () => {
|
||||
const originalPath = '/tmp/import.affine';
|
||||
const internalPath = '/app/workspaces/local/workspace-1/storage.db';
|
||||
|
||||
pathExists.mockResolvedValue(true);
|
||||
realpath.mockImplementation(async path => path);
|
||||
showOpenDialog.mockResolvedValue({
|
||||
canceled: false,
|
||||
filePaths: [originalPath],
|
||||
});
|
||||
getWorkspacesBasePath.mockResolvedValue('/app/workspaces');
|
||||
getSpaceDBPath.mockResolvedValue(internalPath);
|
||||
docValidate.mockResolvedValue(true);
|
||||
@@ -201,11 +216,9 @@ describe('dialog import', () => {
|
||||
docSetSpaceId.mockResolvedValue(undefined);
|
||||
ensureDir.mockResolvedValue(undefined);
|
||||
|
||||
const { loadDBFile, setFakeDialogResult } =
|
||||
const { loadDBFile } =
|
||||
await import('@affine/electron/helper/dialog/dialog');
|
||||
|
||||
setFakeDialogResult({ filePath: originalPath });
|
||||
|
||||
const result = await loadDBFile();
|
||||
|
||||
expect(result).toEqual({ workspaceId: 'workspace-1' });
|
||||
@@ -219,15 +232,19 @@ describe('dialog import', () => {
|
||||
test('loadDBFile rejects v2 imports with unexpected schema objects', async () => {
|
||||
const originalPath = '/tmp/import.affine';
|
||||
|
||||
pathExists.mockResolvedValue(true);
|
||||
realpath.mockImplementation(async path => path);
|
||||
showOpenDialog.mockResolvedValue({
|
||||
canceled: false,
|
||||
filePaths: [originalPath],
|
||||
});
|
||||
getWorkspacesBasePath.mockResolvedValue('/app/workspaces');
|
||||
docValidate.mockResolvedValue(true);
|
||||
docValidateImportSchema.mockResolvedValue(false);
|
||||
|
||||
const { loadDBFile, setFakeDialogResult } =
|
||||
const { loadDBFile } =
|
||||
await import('@affine/electron/helper/dialog/dialog');
|
||||
|
||||
setFakeDialogResult({ filePath: originalPath });
|
||||
|
||||
const result = await loadDBFile();
|
||||
|
||||
expect(result).toEqual({ error: 'DB_FILE_INVALID' });
|
||||
@@ -239,6 +256,12 @@ describe('dialog import', () => {
|
||||
const originalPath = '/tmp/import-v1.affine';
|
||||
const internalPath = '/app/workspaces/workspace-1/storage.db';
|
||||
|
||||
pathExists.mockResolvedValue(true);
|
||||
realpath.mockImplementation(async path => path);
|
||||
showOpenDialog.mockResolvedValue({
|
||||
canceled: false,
|
||||
filePaths: [originalPath],
|
||||
});
|
||||
getWorkspacesBasePath.mockResolvedValue('/app/workspaces');
|
||||
getWorkspaceDBPath.mockResolvedValue(internalPath);
|
||||
docValidate.mockResolvedValue(false);
|
||||
@@ -247,11 +270,9 @@ describe('dialog import', () => {
|
||||
sqliteVacuumInto.mockResolvedValue(undefined);
|
||||
ensureDir.mockResolvedValue(undefined);
|
||||
|
||||
const { loadDBFile, setFakeDialogResult } =
|
||||
const { loadDBFile } =
|
||||
await import('@affine/electron/helper/dialog/dialog');
|
||||
|
||||
setFakeDialogResult({ filePath: originalPath });
|
||||
|
||||
const result = await loadDBFile();
|
||||
|
||||
expect(result).toEqual({ workspaceId: 'workspace-1' });
|
||||
@@ -263,6 +284,57 @@ describe('dialog import', () => {
|
||||
id: 'workspace-1',
|
||||
mainDBPath: internalPath,
|
||||
});
|
||||
expect(sqliteClose).toHaveBeenCalledWith(originalPath);
|
||||
expect(copy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('loadDBFile closes v1 connection when schema validation fails', async () => {
|
||||
const originalPath = '/tmp/import-v1-invalid.affine';
|
||||
|
||||
pathExists.mockResolvedValue(true);
|
||||
realpath.mockImplementation(async path => path);
|
||||
showOpenDialog.mockResolvedValue({
|
||||
canceled: false,
|
||||
filePaths: [originalPath],
|
||||
});
|
||||
getWorkspacesBasePath.mockResolvedValue('/app/workspaces');
|
||||
docValidate.mockResolvedValue(false);
|
||||
sqliteValidate.mockResolvedValue('Valid');
|
||||
sqliteValidateImportSchema.mockResolvedValue(false);
|
||||
|
||||
const { loadDBFile } =
|
||||
await import('@affine/electron/helper/dialog/dialog');
|
||||
|
||||
const result = await loadDBFile();
|
||||
|
||||
expect(result).toEqual({ error: 'DB_FILE_INVALID' });
|
||||
expect(sqliteClose).toHaveBeenCalledWith(originalPath);
|
||||
expect(sqliteVacuumInto).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('loadDBFile rejects normalized paths inside app data', async () => {
|
||||
const selectedPath = '/tmp/import.affine';
|
||||
const normalizedPath = '/app/workspaces/local/existing/storage.db';
|
||||
|
||||
pathExists.mockResolvedValue(true);
|
||||
realpath.mockImplementation(async path => {
|
||||
if (path === selectedPath) {
|
||||
return normalizedPath;
|
||||
}
|
||||
return path;
|
||||
});
|
||||
showOpenDialog.mockResolvedValue({
|
||||
canceled: false,
|
||||
filePaths: [selectedPath],
|
||||
});
|
||||
getWorkspacesBasePath.mockResolvedValue('/app/workspaces');
|
||||
|
||||
const { loadDBFile } =
|
||||
await import('@affine/electron/helper/dialog/dialog');
|
||||
|
||||
const result = await loadDBFile();
|
||||
|
||||
expect(result).toEqual({ error: 'DB_FILE_PATH_INVALID' });
|
||||
expect(docValidate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { afterEach, describe, expect, test } from 'vitest';
|
||||
|
||||
import {
|
||||
assertPathComponent,
|
||||
normalizeWorkspaceIdForPath,
|
||||
resolveExistingPathInBase,
|
||||
resolvePathInBase,
|
||||
} from '../../src/shared/utils';
|
||||
|
||||
const tmpDir = path.join(os.tmpdir(), `affine-electron-utils-${randomUUID()}`);
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('path guards', () => {
|
||||
test('resolvePathInBase blocks sibling-prefix escapes', () => {
|
||||
const baseDir = path.join(tmpDir, 'recordings');
|
||||
|
||||
expect(() =>
|
||||
resolvePathInBase(baseDir, '../recordings-evil/file.opus', {
|
||||
label: 'directory',
|
||||
})
|
||||
).toThrow('Invalid directory');
|
||||
});
|
||||
|
||||
test.runIf(process.platform !== 'win32')(
|
||||
'resolveExistingPathInBase rejects symlink escapes',
|
||||
async () => {
|
||||
const baseDir = path.join(tmpDir, 'recordings');
|
||||
const outsideDir = path.join(tmpDir, 'outside');
|
||||
const outsideFile = path.join(outsideDir, 'secret.txt');
|
||||
const linkPath = path.join(baseDir, '1234567890abcdef.blob');
|
||||
|
||||
await fs.mkdir(baseDir, { recursive: true });
|
||||
await fs.mkdir(outsideDir, { recursive: true });
|
||||
await fs.writeFile(outsideFile, 'secret');
|
||||
await fs.symlink(outsideFile, linkPath);
|
||||
|
||||
await expect(
|
||||
resolveExistingPathInBase(baseDir, linkPath, {
|
||||
label: 'recording filepath',
|
||||
})
|
||||
).rejects.toThrow('Invalid recording filepath');
|
||||
}
|
||||
);
|
||||
|
||||
test('resolveExistingPathInBase falls back for missing descendants', async () => {
|
||||
const baseDir = path.join(tmpDir, 'recordings');
|
||||
|
||||
await fs.mkdir(baseDir, { recursive: true });
|
||||
const missingPath = path.join(
|
||||
await fs.realpath(baseDir),
|
||||
'pending',
|
||||
'recording.opus'
|
||||
);
|
||||
|
||||
await expect(
|
||||
resolveExistingPathInBase(baseDir, missingPath, {
|
||||
label: 'recording filepath',
|
||||
})
|
||||
).resolves.toBe(path.resolve(missingPath));
|
||||
});
|
||||
|
||||
test.runIf(process.platform !== 'win32')(
|
||||
'resolveExistingPathInBase preserves non-missing realpath errors',
|
||||
async () => {
|
||||
const baseDir = path.join(tmpDir, 'recordings');
|
||||
const loopPath = path.join(baseDir, 'loop.opus');
|
||||
|
||||
await fs.mkdir(baseDir, { recursive: true });
|
||||
await fs.symlink(path.basename(loopPath), loopPath);
|
||||
|
||||
await expect(
|
||||
resolveExistingPathInBase(baseDir, loopPath, {
|
||||
label: 'recording filepath',
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'ELOOP' });
|
||||
}
|
||||
);
|
||||
|
||||
test.each(['../../escape', 'nested/id'])(
|
||||
'assertPathComponent rejects invalid workspace id %s',
|
||||
input => {
|
||||
expect(() => assertPathComponent(input, 'workspace id')).toThrow(
|
||||
'Invalid workspace id'
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
test.each([
|
||||
{ input: 'legacy:id*with?reserved.', expected: 'legacy_id_with_reserved' },
|
||||
{ input: 'safe-workspace', expected: 'safe-workspace' },
|
||||
])(
|
||||
'normalizeWorkspaceIdForPath maps $input to $expected on Windows',
|
||||
({ input, expected }) => {
|
||||
expect(normalizeWorkspaceIdForPath(input, { windows: true })).toBe(
|
||||
expected
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,256 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
const isActiveTab = vi.fn();
|
||||
const readRecordingFile = vi.fn();
|
||||
const setRecordingBlockCreationStatus = vi.fn();
|
||||
const getCurrentWorkspace = vi.fn();
|
||||
const isAiEnabled = vi.fn();
|
||||
const transcribeRecording = vi.fn();
|
||||
|
||||
let onRecordingStatusChanged:
|
||||
| ((
|
||||
status: {
|
||||
id: number;
|
||||
status: 'processing';
|
||||
appName?: string;
|
||||
filepath?: string;
|
||||
startTime: number;
|
||||
blockCreationStatus?: 'success' | 'failed';
|
||||
} | null
|
||||
) => void)
|
||||
| undefined;
|
||||
|
||||
vi.mock('@affine/core/modules/doc', () => ({
|
||||
DocsService: class DocsService {},
|
||||
}));
|
||||
|
||||
vi.mock('@affine/core/modules/media/services/audio-attachment', () => ({
|
||||
AudioAttachmentService: class AudioAttachmentService {},
|
||||
}));
|
||||
|
||||
vi.mock('@affine/core/modules/workbench', () => ({
|
||||
WorkbenchService: class WorkbenchService {},
|
||||
}));
|
||||
|
||||
vi.mock('@affine/debug', () => ({
|
||||
DebugLogger: class DebugLogger {
|
||||
debug = vi.fn();
|
||||
error = vi.fn();
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@affine/electron-api', () => ({
|
||||
apis: {
|
||||
ui: {
|
||||
isActiveTab,
|
||||
},
|
||||
recording: {
|
||||
readRecordingFile,
|
||||
setRecordingBlockCreationStatus,
|
||||
},
|
||||
},
|
||||
events: {
|
||||
recording: {
|
||||
onRecordingStatusChanged: vi.fn(
|
||||
(handler: typeof onRecordingStatusChanged) => {
|
||||
onRecordingStatusChanged = handler;
|
||||
return () => {
|
||||
onRecordingStatusChanged = undefined;
|
||||
};
|
||||
}
|
||||
),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@affine/i18n', () => ({
|
||||
i18nTime: vi.fn(() => 'Jan 1 09:00'),
|
||||
}));
|
||||
|
||||
vi.mock('@affine/track', () => ({
|
||||
default: {
|
||||
doc: {
|
||||
editor: {
|
||||
audioBlock: {
|
||||
transcribeRecording,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../electron-renderer/src/app/effects/utils', () => ({
|
||||
getCurrentWorkspace,
|
||||
isAiEnabled,
|
||||
}));
|
||||
|
||||
function createWorkspaceRef() {
|
||||
const blobSet = vi.fn(async () => 'blob-1');
|
||||
const addBlock = vi.fn(() => 'attachment-1');
|
||||
const getBlock = vi.fn(() => ({ model: { id: 'attachment-1' } }));
|
||||
const openDoc = vi.fn();
|
||||
|
||||
type MockDoc = {
|
||||
workspace: {
|
||||
blobSync: {
|
||||
set: typeof blobSet;
|
||||
};
|
||||
};
|
||||
addBlock: typeof addBlock;
|
||||
getBlock: typeof getBlock;
|
||||
};
|
||||
|
||||
type MockDocProps = {
|
||||
onStoreLoad: (doc: MockDoc, meta: { noteId: string }) => void;
|
||||
};
|
||||
|
||||
const createDoc = vi.fn(({ docProps }: { docProps: MockDocProps }) => {
|
||||
queueMicrotask(() => {
|
||||
docProps.onStoreLoad(
|
||||
{
|
||||
workspace: { blobSync: { set: blobSet } },
|
||||
addBlock,
|
||||
getBlock,
|
||||
},
|
||||
{ noteId: 'note-1' }
|
||||
);
|
||||
});
|
||||
|
||||
return { id: 'doc-1' };
|
||||
});
|
||||
|
||||
const scope = {
|
||||
get(token: { name?: string }) {
|
||||
switch (token.name) {
|
||||
case 'DocsService':
|
||||
return { createDoc };
|
||||
case 'WorkbenchService':
|
||||
return { workbench: { openDoc } };
|
||||
case 'AudioAttachmentService':
|
||||
return {
|
||||
get: () => ({
|
||||
obj: {
|
||||
transcribe: vi.fn(async () => undefined),
|
||||
},
|
||||
[Symbol.dispose]: vi.fn(),
|
||||
}),
|
||||
};
|
||||
default:
|
||||
throw new Error(`Unexpected token: ${token.name}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const dispose = vi.fn();
|
||||
|
||||
return {
|
||||
ref: {
|
||||
workspace: { scope },
|
||||
dispose,
|
||||
[Symbol.dispose]: dispose,
|
||||
},
|
||||
createDoc,
|
||||
openDoc,
|
||||
blobSet,
|
||||
addBlock,
|
||||
getBlock,
|
||||
};
|
||||
}
|
||||
|
||||
describe('recording effect', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
onRecordingStatusChanged = undefined;
|
||||
readRecordingFile.mockResolvedValue(new Uint8Array([1, 2, 3]).buffer);
|
||||
setRecordingBlockCreationStatus.mockResolvedValue(undefined);
|
||||
isAiEnabled.mockReturnValue(false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.runOnlyPendingTimers();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('retries processing until the active tab has a workspace', async () => {
|
||||
const workspace = createWorkspaceRef();
|
||||
|
||||
isActiveTab.mockResolvedValueOnce(false).mockResolvedValue(true);
|
||||
getCurrentWorkspace
|
||||
.mockReturnValueOnce(undefined)
|
||||
.mockReturnValue(workspace.ref);
|
||||
|
||||
const { setupRecordingEvents } =
|
||||
await import('../../../electron-renderer/src/app/effects/recording');
|
||||
|
||||
setupRecordingEvents({} as never);
|
||||
|
||||
onRecordingStatusChanged?.({
|
||||
id: 7,
|
||||
status: 'processing',
|
||||
appName: 'Zoom',
|
||||
filepath: '/tmp/meeting.opus',
|
||||
startTime: 1000,
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
expect(workspace.createDoc).not.toHaveBeenCalled();
|
||||
expect(setRecordingBlockCreationStatus).not.toHaveBeenCalled();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(workspace.createDoc).not.toHaveBeenCalled();
|
||||
expect(setRecordingBlockCreationStatus).not.toHaveBeenCalled();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
|
||||
expect(workspace.createDoc).toHaveBeenCalledTimes(1);
|
||||
expect(workspace.openDoc).toHaveBeenCalledWith('doc-1');
|
||||
expect(workspace.blobSet).toHaveBeenCalledTimes(1);
|
||||
const [savedBlob] = workspace.blobSet.mock.calls[0] ?? [];
|
||||
expect(savedBlob).toBeInstanceOf(Blob);
|
||||
expect((savedBlob as Blob).type).toBe('audio/ogg');
|
||||
expect(workspace.addBlock).toHaveBeenCalledWith(
|
||||
'affine:attachment',
|
||||
expect.objectContaining({ type: 'audio/ogg' }),
|
||||
'note-1'
|
||||
);
|
||||
expect(setRecordingBlockCreationStatus).toHaveBeenCalledWith(7, 'success');
|
||||
expect(setRecordingBlockCreationStatus).not.toHaveBeenCalledWith(
|
||||
7,
|
||||
'failed',
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
test('retries when the active-tab probe rejects', async () => {
|
||||
const workspace = createWorkspaceRef();
|
||||
|
||||
isActiveTab
|
||||
.mockRejectedValueOnce(new Error('probe failed'))
|
||||
.mockResolvedValue(true);
|
||||
getCurrentWorkspace.mockReturnValue(workspace.ref);
|
||||
|
||||
const { setupRecordingEvents } =
|
||||
await import('../../../electron-renderer/src/app/effects/recording');
|
||||
|
||||
setupRecordingEvents({} as never);
|
||||
|
||||
onRecordingStatusChanged?.({
|
||||
id: 9,
|
||||
status: 'processing',
|
||||
appName: 'Meet',
|
||||
filepath: '/tmp/meeting.opus',
|
||||
startTime: 1000,
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
expect(workspace.createDoc).not.toHaveBeenCalled();
|
||||
expect(setRecordingBlockCreationStatus).not.toHaveBeenCalled();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
|
||||
expect(workspace.createDoc).toHaveBeenCalledTimes(1);
|
||||
expect(setRecordingBlockCreationStatus).toHaveBeenCalledWith(9, 'success');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../src/main/logger', () => ({
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { RecordingStateMachine } from '../../src/main/recording/state-machine';
|
||||
|
||||
function createAttachedRecording(stateMachine: RecordingStateMachine) {
|
||||
const pending = stateMachine.dispatch({
|
||||
type: 'START_RECORDING',
|
||||
});
|
||||
|
||||
stateMachine.dispatch({
|
||||
type: 'ATTACH_NATIVE_RECORDING',
|
||||
id: pending!.id,
|
||||
nativeId: 'native-1',
|
||||
startTime: 100,
|
||||
filepath: '/tmp/recording.opus',
|
||||
sampleRate: 48000,
|
||||
numberOfChannels: 2,
|
||||
});
|
||||
|
||||
return pending!;
|
||||
}
|
||||
|
||||
describe('RecordingStateMachine', () => {
|
||||
test('transitions from recording to ready after artifact import and block creation', () => {
|
||||
const stateMachine = new RecordingStateMachine();
|
||||
|
||||
const pending = createAttachedRecording(stateMachine);
|
||||
expect(pending?.status).toBe('recording');
|
||||
|
||||
const processing = stateMachine.dispatch({
|
||||
type: 'STOP_RECORDING',
|
||||
id: pending.id,
|
||||
});
|
||||
expect(processing?.status).toBe('processing');
|
||||
|
||||
const artifactAttached = stateMachine.dispatch({
|
||||
type: 'ATTACH_RECORDING_ARTIFACT',
|
||||
id: pending.id,
|
||||
filepath: '/tmp/recording.opus',
|
||||
sampleRate: 48000,
|
||||
numberOfChannels: 2,
|
||||
});
|
||||
expect(artifactAttached).toMatchObject({
|
||||
status: 'processing',
|
||||
filepath: '/tmp/recording.opus',
|
||||
});
|
||||
|
||||
const ready = stateMachine.dispatch({
|
||||
type: 'SET_BLOCK_CREATION_STATUS',
|
||||
id: pending.id,
|
||||
status: 'success',
|
||||
});
|
||||
expect(ready).toMatchObject({
|
||||
status: 'ready',
|
||||
blockCreationStatus: 'success',
|
||||
});
|
||||
});
|
||||
|
||||
test('keeps native audio metadata when stop artifact omits it', () => {
|
||||
const stateMachine = new RecordingStateMachine();
|
||||
|
||||
const pending = createAttachedRecording(stateMachine);
|
||||
stateMachine.dispatch({ type: 'STOP_RECORDING', id: pending.id });
|
||||
|
||||
const artifactAttached = stateMachine.dispatch({
|
||||
type: 'ATTACH_RECORDING_ARTIFACT',
|
||||
id: pending.id,
|
||||
filepath: '/tmp/recording.opus',
|
||||
});
|
||||
|
||||
expect(artifactAttached).toMatchObject({
|
||||
sampleRate: 48000,
|
||||
numberOfChannels: 2,
|
||||
});
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ status: 'success' as const, errorMessage: undefined },
|
||||
{ status: 'failed' as const, errorMessage: 'native start failed' },
|
||||
])(
|
||||
'settles recordings into ready state with blockCreationStatus=$status',
|
||||
({ status, errorMessage }) => {
|
||||
const stateMachine = new RecordingStateMachine();
|
||||
|
||||
const pending = stateMachine.dispatch({
|
||||
type: 'START_RECORDING',
|
||||
});
|
||||
expect(pending?.status).toBe('recording');
|
||||
|
||||
const settled = stateMachine.dispatch({
|
||||
type: 'SET_BLOCK_CREATION_STATUS',
|
||||
id: pending!.id,
|
||||
status,
|
||||
errorMessage,
|
||||
});
|
||||
expect(settled).toMatchObject({
|
||||
status: 'ready',
|
||||
blockCreationStatus: status,
|
||||
});
|
||||
|
||||
const next = stateMachine.dispatch({
|
||||
type: 'START_RECORDING',
|
||||
});
|
||||
expect(next?.id).toBeGreaterThan(pending!.id);
|
||||
expect(next?.status).toBe('recording');
|
||||
expect(next?.blockCreationStatus).toBeUndefined();
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -131,4 +131,52 @@ describe('workspace db management', () => {
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('rejects unsafe ids when deleting a workspace', async () => {
|
||||
const { deleteWorkspace } =
|
||||
await import('@affine/electron/helper/workspace/handlers');
|
||||
const outsideDir = path.join(tmpDir, 'outside-delete-target');
|
||||
|
||||
await fs.ensureDir(outsideDir);
|
||||
|
||||
await expect(
|
||||
deleteWorkspace(
|
||||
universalId({
|
||||
peer: 'local',
|
||||
type: 'workspace',
|
||||
id: '../../outside-delete-target',
|
||||
})
|
||||
)
|
||||
).rejects.toThrow('Invalid workspace id');
|
||||
|
||||
expect(await fs.pathExists(outsideDir)).toBe(true);
|
||||
});
|
||||
|
||||
test('rejects unsafe ids when deleting backup workspaces', async () => {
|
||||
const { deleteBackupWorkspace } =
|
||||
await import('@affine/electron/helper/workspace/handlers');
|
||||
const outsideDir = path.join(tmpDir, 'outside-backup-target');
|
||||
|
||||
await fs.ensureDir(outsideDir);
|
||||
|
||||
await expect(
|
||||
deleteBackupWorkspace('../../outside-backup-target')
|
||||
).rejects.toThrow('Invalid workspace id');
|
||||
|
||||
expect(await fs.pathExists(outsideDir)).toBe(true);
|
||||
});
|
||||
|
||||
test('rejects unsafe ids when recovering backup workspaces', async () => {
|
||||
const { recoverBackupWorkspace } =
|
||||
await import('@affine/electron/helper/workspace/handlers');
|
||||
const outsideDir = path.join(tmpDir, 'outside-recover-target');
|
||||
|
||||
await fs.ensureDir(outsideDir);
|
||||
|
||||
await expect(
|
||||
recoverBackupWorkspace('../../outside-recover-target')
|
||||
).rejects.toThrow('Invalid workspace id');
|
||||
|
||||
expect(await fs.pathExists(outsideDir)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user