mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-02 01:49:51 +08:00
feat(native): async recorder (#14700)
#### PR Dependency Tree * **PR #14700** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Durable, resumable import queue with explicit import lifecycle and updated popup/tray status behavior. * Async native recording APIs and ability to abort recordings; audio quality metrics (degraded, overflow count). * Added "Importing..." translation. * **Bug Fixes** * More reliable single-claim import processing, retries and cleanup to avoid duplicate imports. * Improved stop/abort teardown stability and safer shutdown behavior. * **Tests** * New/updated tests covering coordinator, import queue, native async flows and teardown scenarios. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -0,0 +1,417 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
const storageState = new Map<string, unknown>();
|
||||
|
||||
vi.mock('../../src/main/logger', () => ({
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
storageState.clear();
|
||||
|
||||
vi.doMock('../../src/main/shared-storage/storage', () => ({
|
||||
globalStateStorage: {
|
||||
get: (key: string) => storageState.get(key),
|
||||
set: (key: string, value: unknown) => {
|
||||
storageState.set(key, value);
|
||||
},
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
async function createCoordinator(controllerOverrides?: {
|
||||
startRecording?: ReturnType<typeof vi.fn>;
|
||||
stopRecording?: ReturnType<typeof vi.fn>;
|
||||
abortRecording?: ReturnType<typeof vi.fn>;
|
||||
}) {
|
||||
const { RecordingCoordinator } =
|
||||
await import('../../src/main/recording/coordinator');
|
||||
const controller = {
|
||||
startRecording: vi.fn(),
|
||||
stopRecording: vi.fn(),
|
||||
abortRecording: vi.fn(),
|
||||
...controllerOverrides,
|
||||
};
|
||||
|
||||
return {
|
||||
coordinator: new RecordingCoordinator(
|
||||
'/tmp',
|
||||
async filepath => filepath,
|
||||
async () => controller
|
||||
),
|
||||
controller,
|
||||
};
|
||||
}
|
||||
|
||||
describe('RecordingCoordinator', () => {
|
||||
test('disabling can await native abort before allowing another start', async () => {
|
||||
const startRecording = vi.fn().mockResolvedValue({
|
||||
id: 'native-1',
|
||||
filepath: '/tmp/0.opus',
|
||||
sampleRate: 48_000,
|
||||
channels: 2,
|
||||
startedAt: 123,
|
||||
});
|
||||
const stopRecording = vi.fn();
|
||||
let releaseAbort!: () => void;
|
||||
const abortRecording = vi.fn(
|
||||
() =>
|
||||
new Promise<void>(resolve => {
|
||||
releaseAbort = resolve;
|
||||
})
|
||||
);
|
||||
|
||||
const { coordinator } = await createCoordinator({
|
||||
startRecording,
|
||||
stopRecording,
|
||||
abortRecording,
|
||||
});
|
||||
|
||||
await coordinator.start();
|
||||
const abortPromise = coordinator.abortActive();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(startRecording).toHaveBeenCalledTimes(1);
|
||||
expect(abortRecording).toHaveBeenCalledWith('native-1');
|
||||
|
||||
releaseAbort();
|
||||
await abortPromise;
|
||||
|
||||
await coordinator.start();
|
||||
expect(startRecording).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('claiming an import binds it to a workspace and stable doc id', async () => {
|
||||
storageState.set('recordingJobs:v2', [
|
||||
{
|
||||
id: 7,
|
||||
phase: 'recorded',
|
||||
appName: 'Zoom',
|
||||
startTime: 1000,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
artifact: {
|
||||
filepath: '/tmp/meeting.opus',
|
||||
sampleRate: 48_000,
|
||||
numberOfChannels: 2,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const { coordinator } = await createCoordinator();
|
||||
|
||||
const claimed = coordinator.claimImport(7, 'workspace-1');
|
||||
|
||||
expect(claimed).toMatchObject({
|
||||
id: 7,
|
||||
workspaceId: 'workspace-1',
|
||||
docId: 'recording-7',
|
||||
importStatus: 'importing',
|
||||
});
|
||||
expect(coordinator.importQueue()).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 7,
|
||||
workspaceId: 'workspace-1',
|
||||
docId: 'recording-7',
|
||||
importStatus: 'importing',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test('reopens interrupted imports as pending work while suppressing stale terminal popups', async () => {
|
||||
storageState.set('recordingJobs:v2', [
|
||||
{
|
||||
id: 1,
|
||||
phase: 'importing',
|
||||
appName: 'Zoom',
|
||||
startTime: 1000,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
artifact: {
|
||||
filepath: '/tmp/interrupted.opus',
|
||||
},
|
||||
import: {
|
||||
workspaceId: 'workspace-1',
|
||||
docId: 'recording-1',
|
||||
leaseExpiresAt: Date.now() + 10_000,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
phase: 'imported',
|
||||
appName: 'Meet',
|
||||
startTime: 2000,
|
||||
createdAt: 2,
|
||||
updatedAt: 2,
|
||||
artifact: {
|
||||
filepath: '/tmp/imported.opus',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
phase: 'failed',
|
||||
appName: 'Teams',
|
||||
startTime: 3000,
|
||||
createdAt: 3,
|
||||
updatedAt: 3,
|
||||
artifact: {
|
||||
filepath: '/tmp/failed.opus',
|
||||
},
|
||||
error: {
|
||||
stage: 'import',
|
||||
message: 'import failed',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const { coordinator } = await createCoordinator();
|
||||
|
||||
expect(coordinator.importQueue()).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 1,
|
||||
importStatus: 'pending_import',
|
||||
}),
|
||||
]);
|
||||
expect(coordinator.currentStatus()).toBeNull();
|
||||
});
|
||||
|
||||
test('abortActive clears the local job even when native abort fails', async () => {
|
||||
const startRecording = vi.fn().mockResolvedValue({
|
||||
id: 'native-1',
|
||||
filepath: '/tmp/0.opus',
|
||||
sampleRate: 48_000,
|
||||
channels: 2,
|
||||
startedAt: 123,
|
||||
});
|
||||
const abortRecording = vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('native abort failed'));
|
||||
const { coordinator } = await createCoordinator({
|
||||
startRecording,
|
||||
abortRecording,
|
||||
});
|
||||
|
||||
await coordinator.start();
|
||||
|
||||
await expect(coordinator.abortActive()).rejects.toThrow(
|
||||
'native abort failed'
|
||||
);
|
||||
expect(coordinator.currentStatus()).toBeNull();
|
||||
expect(coordinator.jobs).toEqual([]);
|
||||
});
|
||||
|
||||
test('start failures project to start_failed and release the active slot', async () => {
|
||||
const startRecording = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('native start failed'))
|
||||
.mockResolvedValueOnce({
|
||||
id: 'native-2',
|
||||
filepath: '/tmp/1.opus',
|
||||
sampleRate: 48_000,
|
||||
channels: 2,
|
||||
startedAt: 456,
|
||||
});
|
||||
const { coordinator } = await createCoordinator({
|
||||
startRecording,
|
||||
});
|
||||
|
||||
const failed = await coordinator.start();
|
||||
expect(failed).toMatchObject({
|
||||
phase: 'failed',
|
||||
error: {
|
||||
stage: 'start',
|
||||
message: 'native start failed',
|
||||
},
|
||||
});
|
||||
expect(coordinator.currentStatus()).toMatchObject({
|
||||
status: 'start_failed',
|
||||
errorMessage: 'native start failed',
|
||||
});
|
||||
|
||||
const next = await coordinator.start();
|
||||
expect(next).toMatchObject({
|
||||
id: 1,
|
||||
phase: 'recording',
|
||||
});
|
||||
});
|
||||
|
||||
test('stop failures project to finalize_failed and release the active slot', async () => {
|
||||
const startRecording = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
id: 'native-1',
|
||||
filepath: '/tmp/0.opus',
|
||||
sampleRate: 48_000,
|
||||
channels: 2,
|
||||
startedAt: 123,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
id: 'native-2',
|
||||
filepath: '/tmp/1.opus',
|
||||
sampleRate: 48_000,
|
||||
channels: 2,
|
||||
startedAt: 456,
|
||||
});
|
||||
const stopRecording = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('native stop failed'));
|
||||
const { coordinator } = await createCoordinator({
|
||||
startRecording,
|
||||
stopRecording,
|
||||
});
|
||||
|
||||
const first = await coordinator.start();
|
||||
await coordinator.stop(first!.id);
|
||||
|
||||
expect(coordinator.currentStatus()).toMatchObject({
|
||||
id: first!.id,
|
||||
status: 'finalize_failed',
|
||||
errorMessage: 'native stop failed',
|
||||
});
|
||||
|
||||
const next = await coordinator.start();
|
||||
expect(next).toMatchObject({
|
||||
id: 1,
|
||||
phase: 'recording',
|
||||
});
|
||||
});
|
||||
|
||||
test.each([
|
||||
{
|
||||
name: 'successful import completion',
|
||||
settleImport: (coordinator: {
|
||||
completeImport: (id: number) => unknown;
|
||||
}) => coordinator.completeImport(7),
|
||||
expectedStatus: 'imported',
|
||||
},
|
||||
{
|
||||
name: 'failed import completion',
|
||||
settleImport: (coordinator: {
|
||||
failImport: (id: number, errorMessage?: string) => unknown;
|
||||
}) => coordinator.failImport(7, 'import failed'),
|
||||
expectedStatus: 'import_failed',
|
||||
},
|
||||
])(
|
||||
'projects $name without leaking completed queue items into the import queue',
|
||||
async ({ settleImport, expectedStatus }) => {
|
||||
storageState.set('recordingJobs:v2', [
|
||||
{
|
||||
id: 7,
|
||||
phase: 'recorded',
|
||||
appName: 'Zoom',
|
||||
startTime: 1000,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
artifact: {
|
||||
filepath: '/tmp/meeting.opus',
|
||||
sampleRate: 48_000,
|
||||
numberOfChannels: 2,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const { coordinator } = await createCoordinator();
|
||||
settleImport(coordinator as never);
|
||||
|
||||
expect(coordinator.currentStatus()).toMatchObject({
|
||||
id: 7,
|
||||
status: expectedStatus,
|
||||
});
|
||||
expect(coordinator.importQueue()).toEqual([]);
|
||||
}
|
||||
);
|
||||
|
||||
test.each([
|
||||
{
|
||||
name: 'successful imports',
|
||||
seed: {
|
||||
id: 11,
|
||||
phase: 'imported' as const,
|
||||
appName: 'Zoom',
|
||||
startTime: 1000,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
artifact: {
|
||||
filepath: '/tmp/imported.opus',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'failed imports',
|
||||
seed: {
|
||||
id: 12,
|
||||
phase: 'failed' as const,
|
||||
appName: 'Meet',
|
||||
startTime: 1000,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
artifact: {
|
||||
filepath: '/tmp/failed.opus',
|
||||
},
|
||||
error: {
|
||||
stage: 'import' as const,
|
||||
message: 'import failed',
|
||||
},
|
||||
},
|
||||
},
|
||||
])(
|
||||
'suppresses persisted terminal $name from the current status',
|
||||
async ({ seed }) => {
|
||||
storageState.set('recordingJobs:v2', [seed]);
|
||||
|
||||
const { coordinator } = await createCoordinator();
|
||||
|
||||
expect(coordinator.currentStatus()).toBeNull();
|
||||
expect(coordinator.importQueue()).toEqual([]);
|
||||
}
|
||||
);
|
||||
|
||||
test('dismissing an import failure clears the popup projection without dropping the saved artifact', async () => {
|
||||
storageState.set('recordingJobs:v2', [
|
||||
{
|
||||
id: 7,
|
||||
phase: 'failed',
|
||||
appName: 'Zoom',
|
||||
startTime: 1000,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
artifact: {
|
||||
filepath: '/tmp/meeting.opus',
|
||||
},
|
||||
error: {
|
||||
stage: 'import',
|
||||
message: 'import failed',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const { coordinator } = await createCoordinator();
|
||||
coordinator.dismiss(7);
|
||||
|
||||
expect(coordinator.currentStatus()).toBeNull();
|
||||
expect(coordinator.importQueue()).toEqual([]);
|
||||
expect(storageState.get('recordingJobs:v2')).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 7,
|
||||
phase: 'failed',
|
||||
dismissedAt: expect.any(Number),
|
||||
artifact: expect.objectContaining({
|
||||
filepath: '/tmp/meeting.opus',
|
||||
}),
|
||||
error: {
|
||||
stage: 'import',
|
||||
message: 'import failed',
|
||||
},
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -2,22 +2,45 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
const isActiveTab = vi.fn();
|
||||
const readRecordingFile = vi.fn();
|
||||
const setRecordingBlockCreationStatus = vi.fn();
|
||||
const claimRecordingImport = vi.fn();
|
||||
const completeRecordingImport = vi.fn();
|
||||
const failRecordingImport = vi.fn();
|
||||
const getRecordingImportQueue = 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)
|
||||
type RecordingImportStatus = {
|
||||
id: number;
|
||||
appName?: string;
|
||||
workspaceId?: string;
|
||||
docId?: string;
|
||||
filepath: string;
|
||||
startTime: number;
|
||||
sampleRate?: number;
|
||||
numberOfChannels?: number;
|
||||
durationMs?: number;
|
||||
size?: number;
|
||||
degraded?: boolean;
|
||||
overflowCount?: number;
|
||||
errorMessage?: string;
|
||||
importStatus: 'pending_import' | 'importing' | 'imported' | 'import_failed';
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
function withQueueMeta(
|
||||
status: Omit<RecordingImportStatus, 'createdAt' | 'updatedAt'>
|
||||
): RecordingImportStatus {
|
||||
return {
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
...status,
|
||||
};
|
||||
}
|
||||
|
||||
let onRecordingImportQueueChanged:
|
||||
| ((queue: RecordingImportStatus[]) => void)
|
||||
| undefined;
|
||||
|
||||
vi.mock('@affine/core/modules/doc', () => ({
|
||||
@@ -46,16 +69,19 @@ vi.mock('@affine/electron-api', () => ({
|
||||
},
|
||||
recording: {
|
||||
readRecordingFile,
|
||||
setRecordingBlockCreationStatus,
|
||||
claimRecordingImport,
|
||||
completeRecordingImport,
|
||||
failRecordingImport,
|
||||
getRecordingImportQueue,
|
||||
},
|
||||
},
|
||||
events: {
|
||||
recording: {
|
||||
onRecordingStatusChanged: vi.fn(
|
||||
(handler: typeof onRecordingStatusChanged) => {
|
||||
onRecordingStatusChanged = handler;
|
||||
onRecordingImportQueueChanged: vi.fn(
|
||||
(handler: typeof onRecordingImportQueueChanged) => {
|
||||
onRecordingImportQueueChanged = handler;
|
||||
return () => {
|
||||
onRecordingStatusChanged = undefined;
|
||||
onRecordingImportQueueChanged = undefined;
|
||||
};
|
||||
}
|
||||
),
|
||||
@@ -84,46 +110,97 @@ vi.mock('../../../electron-renderer/src/app/effects/utils', () => ({
|
||||
isAiEnabled,
|
||||
}));
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
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();
|
||||
const createdDocs = new Set<string>();
|
||||
const attachments: Array<{
|
||||
id: string;
|
||||
props: { name: string; type: string };
|
||||
}> = [];
|
||||
|
||||
type MockDoc = {
|
||||
workspace: {
|
||||
blobSync: {
|
||||
set: typeof blobSet;
|
||||
};
|
||||
const blockSuiteDoc = {
|
||||
getModelsByFlavour: vi.fn((flavour: string) => {
|
||||
if (flavour === 'affine:page') {
|
||||
return [{ id: 'page-1' }];
|
||||
}
|
||||
if (flavour === 'affine:note') {
|
||||
return [{ id: 'note-1' }];
|
||||
}
|
||||
if (flavour === 'affine:attachment') {
|
||||
return attachments;
|
||||
}
|
||||
return [];
|
||||
}),
|
||||
addBlock: vi.fn(
|
||||
(
|
||||
flavour: string,
|
||||
props: { name?: string; type?: string },
|
||||
_parentId?: string
|
||||
) => {
|
||||
if (flavour === 'affine:attachment') {
|
||||
const id = `attachment-${attachments.length + 1}`;
|
||||
attachments.push({
|
||||
id,
|
||||
props: {
|
||||
name: props.name ?? '',
|
||||
type: props.type ?? '',
|
||||
},
|
||||
});
|
||||
return id;
|
||||
}
|
||||
return `${flavour}-1`;
|
||||
}
|
||||
),
|
||||
getBlock: vi.fn((id: string) => {
|
||||
const attachment = attachments.find(entry => entry.id === id);
|
||||
return attachment ? { model: attachment } : null;
|
||||
}),
|
||||
};
|
||||
|
||||
const createDoc = vi.fn(({ id }: { id: string }) => {
|
||||
createdDocs.add(id);
|
||||
return { id };
|
||||
});
|
||||
|
||||
const open = vi.fn((docId: string) => {
|
||||
if (!createdDocs.has(docId)) {
|
||||
throw new Error(`Doc ${docId} not found`);
|
||||
}
|
||||
return {
|
||||
doc: {
|
||||
workspace: { id: 'workspace-1' },
|
||||
blockSuiteDoc,
|
||||
addPriorityLoad: vi.fn(() => vi.fn()),
|
||||
waitForSyncReady: vi.fn(async () => undefined),
|
||||
},
|
||||
release: vi.fn(),
|
||||
};
|
||||
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 };
|
||||
return {
|
||||
createDoc,
|
||||
open,
|
||||
list: {
|
||||
['doc$']: (docId: string) => ({
|
||||
value: createdDocs.has(docId) ? { id: docId } : null,
|
||||
}),
|
||||
},
|
||||
};
|
||||
case 'WorkbenchService':
|
||||
return { workbench: { openDoc } };
|
||||
case 'AudioAttachmentService':
|
||||
@@ -145,15 +222,20 @@ function createWorkspaceRef() {
|
||||
|
||||
return {
|
||||
ref: {
|
||||
workspace: { scope },
|
||||
workspace: {
|
||||
id: 'workspace-1',
|
||||
scope,
|
||||
docCollection: {
|
||||
blobSync: { set: blobSet },
|
||||
},
|
||||
},
|
||||
dispose,
|
||||
[Symbol.dispose]: dispose,
|
||||
},
|
||||
createDoc,
|
||||
openDoc,
|
||||
blobSet,
|
||||
addBlock,
|
||||
getBlock,
|
||||
attachments,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -162,10 +244,12 @@ describe('recording effect', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
onRecordingStatusChanged = undefined;
|
||||
onRecordingImportQueueChanged = undefined;
|
||||
readRecordingFile.mockResolvedValue(new Uint8Array([1, 2, 3]).buffer);
|
||||
setRecordingBlockCreationStatus.mockResolvedValue(undefined);
|
||||
completeRecordingImport.mockResolvedValue(undefined);
|
||||
failRecordingImport.mockResolvedValue(undefined);
|
||||
isAiEnabled.mockReturnValue(false);
|
||||
getRecordingImportQueue.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -173,84 +257,273 @@ describe('recording effect', () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('retries processing until the active tab has a workspace', async () => {
|
||||
test('retries pending imports until the active tab has a workspace and claims with workspace binding', async () => {
|
||||
const workspace = createWorkspaceRef();
|
||||
const pendingImport = {
|
||||
id: 7,
|
||||
importStatus: 'pending_import' as const,
|
||||
appName: 'Zoom',
|
||||
filepath: '/tmp/meeting.opus',
|
||||
startTime: 1000,
|
||||
};
|
||||
|
||||
isActiveTab.mockResolvedValueOnce(false).mockResolvedValue(true);
|
||||
getCurrentWorkspace
|
||||
.mockReturnValueOnce(undefined)
|
||||
.mockReturnValue(workspace.ref);
|
||||
claimRecordingImport.mockResolvedValue({
|
||||
...withQueueMeta(pendingImport),
|
||||
workspaceId: 'workspace-1',
|
||||
docId: 'recording-7',
|
||||
importStatus: 'importing',
|
||||
});
|
||||
getRecordingImportQueue.mockResolvedValue([withQueueMeta(pendingImport)]);
|
||||
|
||||
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();
|
||||
expect(claimRecordingImport).not.toHaveBeenCalled();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(workspace.createDoc).not.toHaveBeenCalled();
|
||||
expect(setRecordingBlockCreationStatus).not.toHaveBeenCalled();
|
||||
expect(claimRecordingImport).not.toHaveBeenCalled();
|
||||
|
||||
onRecordingImportQueueChanged?.([withQueueMeta(pendingImport)]);
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
|
||||
expect(workspace.createDoc).toHaveBeenCalledTimes(1);
|
||||
expect(workspace.openDoc).toHaveBeenCalledWith('doc-1');
|
||||
expect(claimRecordingImport).toHaveBeenCalledWith(7, 'workspace-1');
|
||||
expect(workspace.createDoc).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'recording-7' })
|
||||
);
|
||||
expect(workspace.openDoc).toHaveBeenCalledWith('recording-7');
|
||||
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()
|
||||
);
|
||||
expect(completeRecordingImport).toHaveBeenCalledWith(7);
|
||||
expect(failRecordingImport).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('retries when the active-tab probe rejects', async () => {
|
||||
test('reuses the same doc when a claimed import already carries docId', 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',
|
||||
const pendingImport = {
|
||||
id: 8,
|
||||
importStatus: 'pending_import' as const,
|
||||
appName: 'Meet',
|
||||
filepath: '/tmp/meeting.opus',
|
||||
startTime: 1000,
|
||||
workspaceId: 'workspace-1',
|
||||
docId: 'recording-8',
|
||||
};
|
||||
|
||||
workspace.createDoc({ id: 'recording-8' });
|
||||
isActiveTab.mockResolvedValue(true);
|
||||
getCurrentWorkspace.mockReturnValue(workspace.ref);
|
||||
claimRecordingImport.mockResolvedValue({
|
||||
...withQueueMeta(pendingImport),
|
||||
importStatus: 'importing',
|
||||
});
|
||||
getRecordingImportQueue.mockResolvedValue([withQueueMeta(pendingImport)]);
|
||||
|
||||
const { setupRecordingEvents } =
|
||||
await import('../../../electron-renderer/src/app/effects/recording');
|
||||
|
||||
setupRecordingEvents({} as never);
|
||||
await Promise.resolve();
|
||||
expect(workspace.createDoc).not.toHaveBeenCalled();
|
||||
expect(setRecordingBlockCreationStatus).not.toHaveBeenCalled();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await Promise.resolve();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(workspace.createDoc).toHaveBeenCalledTimes(1);
|
||||
expect(setRecordingBlockCreationStatus).toHaveBeenCalledWith(9, 'success');
|
||||
expect(workspace.blobSet).toHaveBeenCalledTimes(1);
|
||||
expect(completeRecordingImport).toHaveBeenCalledWith(8);
|
||||
});
|
||||
|
||||
test('marks imports as failed when blob import fails after claim', async () => {
|
||||
const pendingImport = {
|
||||
id: 9,
|
||||
importStatus: 'pending_import' as const,
|
||||
appName: 'Meet',
|
||||
filepath: '/tmp/meeting.opus',
|
||||
startTime: 1000,
|
||||
};
|
||||
const workspace = createWorkspaceRef();
|
||||
workspace.blobSet.mockRejectedValueOnce(new Error('blob import failed'));
|
||||
|
||||
isActiveTab.mockResolvedValue(true);
|
||||
getCurrentWorkspace.mockReturnValue(workspace.ref);
|
||||
claimRecordingImport.mockResolvedValue({
|
||||
...withQueueMeta(pendingImport),
|
||||
workspaceId: 'workspace-1',
|
||||
docId: 'recording-9',
|
||||
importStatus: 'importing',
|
||||
});
|
||||
getRecordingImportQueue.mockResolvedValue([withQueueMeta(pendingImport)]);
|
||||
|
||||
const { setupRecordingEvents } =
|
||||
await import('../../../electron-renderer/src/app/effects/recording');
|
||||
|
||||
setupRecordingEvents({} as never);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(failRecordingImport).toHaveBeenCalledWith(9, 'blob import failed');
|
||||
expect(completeRecordingImport).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('releases priority load when waiting for sync readiness fails', async () => {
|
||||
const pendingImport = {
|
||||
id: 10,
|
||||
importStatus: 'pending_import' as const,
|
||||
appName: 'Meet',
|
||||
filepath: '/tmp/meeting.opus',
|
||||
startTime: 1000,
|
||||
};
|
||||
const disposePriorityLoad = vi.fn();
|
||||
const release = vi.fn();
|
||||
const workspace = {
|
||||
ref: {
|
||||
workspace: {
|
||||
id: 'workspace-1',
|
||||
scope: {
|
||||
get(token: { name?: string }) {
|
||||
switch (token.name) {
|
||||
case 'DocsService':
|
||||
return {
|
||||
createDoc: vi.fn(),
|
||||
open: vi.fn(() => ({
|
||||
doc: {
|
||||
blockSuiteDoc: {},
|
||||
addPriorityLoad: vi.fn(() => disposePriorityLoad),
|
||||
waitForSyncReady: vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('sync failed')),
|
||||
},
|
||||
release,
|
||||
})),
|
||||
list: {
|
||||
['doc$']: vi.fn(() => ({
|
||||
value: { id: 'recording-10' },
|
||||
})),
|
||||
},
|
||||
};
|
||||
case 'WorkbenchService':
|
||||
case 'AudioAttachmentService':
|
||||
return {};
|
||||
default:
|
||||
throw new Error(`Unexpected token: ${token.name}`);
|
||||
}
|
||||
},
|
||||
},
|
||||
docCollection: {
|
||||
blobSync: { set: vi.fn() },
|
||||
},
|
||||
},
|
||||
dispose: vi.fn(),
|
||||
[Symbol.dispose]: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
isActiveTab.mockResolvedValue(true);
|
||||
getCurrentWorkspace.mockReturnValue(workspace.ref);
|
||||
claimRecordingImport.mockResolvedValue({
|
||||
...withQueueMeta(pendingImport),
|
||||
workspaceId: 'workspace-1',
|
||||
docId: 'recording-10',
|
||||
importStatus: 'importing',
|
||||
});
|
||||
getRecordingImportQueue.mockResolvedValue([withQueueMeta(pendingImport)]);
|
||||
|
||||
const { setupRecordingEvents } =
|
||||
await import('../../../electron-renderer/src/app/effects/recording');
|
||||
|
||||
setupRecordingEvents({} as never);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(disposePriorityLoad).toHaveBeenCalledTimes(1);
|
||||
expect(release).toHaveBeenCalledTimes(1);
|
||||
expect(failRecordingImport).toHaveBeenCalledWith(10, 'sync failed');
|
||||
expect(completeRecordingImport).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('processes recording imports one at a time even when the queue changes mid-import', async () => {
|
||||
const firstImport = {
|
||||
id: 7,
|
||||
importStatus: 'pending_import' as const,
|
||||
appName: 'Zoom',
|
||||
filepath: '/tmp/meeting-1.opus',
|
||||
startTime: 1000,
|
||||
};
|
||||
const secondImport = {
|
||||
id: 8,
|
||||
importStatus: 'pending_import' as const,
|
||||
appName: 'Meet',
|
||||
filepath: '/tmp/meeting-2.opus',
|
||||
startTime: 2000,
|
||||
};
|
||||
const firstRead = createDeferred<ArrayBuffer>();
|
||||
const workspace = createWorkspaceRef();
|
||||
|
||||
isActiveTab.mockResolvedValue(true);
|
||||
getCurrentWorkspace.mockReturnValue(workspace.ref);
|
||||
readRecordingFile
|
||||
.mockImplementationOnce(() => firstRead.promise)
|
||||
.mockResolvedValueOnce(new Uint8Array([4, 5, 6]).buffer);
|
||||
claimRecordingImport.mockImplementation(async (id: number) => ({
|
||||
...(id === firstImport.id ? firstImport : secondImport),
|
||||
workspaceId: 'workspace-1',
|
||||
docId: `recording-${id}`,
|
||||
importStatus: 'importing' as const,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
}));
|
||||
getRecordingImportQueue.mockResolvedValue([
|
||||
withQueueMeta(firstImport),
|
||||
withQueueMeta(secondImport),
|
||||
]);
|
||||
|
||||
const { setupRecordingEvents } =
|
||||
await import('../../../electron-renderer/src/app/effects/recording');
|
||||
|
||||
setupRecordingEvents({} as never);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(claimRecordingImport).toHaveBeenCalledTimes(1);
|
||||
expect(claimRecordingImport).toHaveBeenCalledWith(
|
||||
firstImport.id,
|
||||
'workspace-1'
|
||||
);
|
||||
|
||||
onRecordingImportQueueChanged?.([
|
||||
withQueueMeta({
|
||||
...firstImport,
|
||||
workspaceId: 'workspace-1',
|
||||
docId: 'recording-7',
|
||||
importStatus: 'importing',
|
||||
}),
|
||||
withQueueMeta(secondImport),
|
||||
]);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(claimRecordingImport).toHaveBeenCalledTimes(1);
|
||||
|
||||
firstRead.resolve(new Uint8Array([1, 2, 3]).buffer);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
|
||||
expect(claimRecordingImport).toHaveBeenCalledTimes(2);
|
||||
expect(claimRecordingImport).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
secondImport.id,
|
||||
'workspace-1'
|
||||
);
|
||||
expect(completeRecordingImport).toHaveBeenNthCalledWith(1, firstImport.id);
|
||||
expect(completeRecordingImport).toHaveBeenNthCalledWith(2, secondImport.id);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
const nativeStartRecording = vi.fn();
|
||||
const nativeStopRecording = vi.fn();
|
||||
const nativeAbortRecording = vi.fn();
|
||||
const ensureDirSync = vi.fn();
|
||||
const resolveExistingPathInBase = vi.fn(
|
||||
async (_base: string, filepath: string) => filepath
|
||||
);
|
||||
const getMainWindow = vi.fn(async () => ({
|
||||
show: vi.fn(),
|
||||
}));
|
||||
|
||||
const storageState = new Map<string, unknown>();
|
||||
const watchSubjects = new Map<string, BehaviorSubject<unknown>>();
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
storageState.clear();
|
||||
watchSubjects.clear();
|
||||
|
||||
vi.doMock('@affine/native', () => ({
|
||||
ShareableContent: class ShareableContent {
|
||||
static applications() {
|
||||
return [];
|
||||
}
|
||||
|
||||
static applicationWithProcessId() {
|
||||
return null;
|
||||
}
|
||||
|
||||
static isUsingMicrophone() {
|
||||
return false;
|
||||
}
|
||||
|
||||
static onApplicationListChanged() {
|
||||
return { unsubscribe: vi.fn() };
|
||||
}
|
||||
|
||||
static onAppStateChanged() {
|
||||
return { unsubscribe: vi.fn() };
|
||||
}
|
||||
},
|
||||
startRecording: nativeStartRecording,
|
||||
stopRecording: nativeStopRecording,
|
||||
abortRecording: nativeAbortRecording,
|
||||
}));
|
||||
|
||||
vi.doMock('electron', () => ({
|
||||
app: {
|
||||
getPath: vi.fn(() => '/tmp'),
|
||||
on: vi.fn(),
|
||||
},
|
||||
systemPreferences: {
|
||||
getMediaAccessStatus: vi.fn(() => 'granted'),
|
||||
askForMediaAccess: vi.fn(async () => true),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.doMock('fs-extra', () => ({
|
||||
default: {
|
||||
ensureDirSync,
|
||||
removeSync: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.doMock('../../src/shared/utils', async () => {
|
||||
const actual = await vi.importActual('../../src/shared/utils');
|
||||
return {
|
||||
...actual,
|
||||
isMacOS: () => false,
|
||||
isWindows: () => false,
|
||||
resolveExistingPathInBase,
|
||||
};
|
||||
});
|
||||
|
||||
vi.doMock('../../src/main/shared-storage/storage', () => ({
|
||||
globalStateStorage: {
|
||||
get: (key: string) => storageState.get(key),
|
||||
set: (key: string, value: unknown) => {
|
||||
storageState.set(key, value);
|
||||
watchSubjects.get(key)?.next(value);
|
||||
},
|
||||
watch: (key: string) => {
|
||||
const watchSubject$ =
|
||||
watchSubjects.get(key) ?? new BehaviorSubject(storageState.get(key));
|
||||
watchSubjects.set(key, watchSubject$);
|
||||
return watchSubject$.asObservable();
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.doMock('../../src/main/windows-manager', () => ({
|
||||
getMainWindow,
|
||||
}));
|
||||
|
||||
vi.doMock('../../src/main/windows-manager/popup', () => ({
|
||||
popupManager: {
|
||||
get: () => ({
|
||||
showing: false,
|
||||
show: vi.fn(async () => undefined),
|
||||
hide: vi.fn(async () => undefined),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.doMock('lodash-es', () => ({
|
||||
debounce: (fn: (...args: unknown[]) => void) => fn,
|
||||
}));
|
||||
});
|
||||
|
||||
async function loadRecordingFeature() {
|
||||
const feature = await import('../../src/main/recording/feature');
|
||||
feature.setRecordingNativeModuleForTesting({
|
||||
ShareableContent: class ShareableContent {
|
||||
static applications() {
|
||||
return [];
|
||||
}
|
||||
|
||||
static applicationWithProcessId() {
|
||||
return null;
|
||||
}
|
||||
|
||||
static isUsingMicrophone() {
|
||||
return false;
|
||||
}
|
||||
|
||||
static onApplicationListChanged() {
|
||||
return { unsubscribe: vi.fn() };
|
||||
}
|
||||
|
||||
static onAppStateChanged() {
|
||||
return { unsubscribe: vi.fn() };
|
||||
}
|
||||
},
|
||||
startRecording: nativeStartRecording,
|
||||
stopRecording: nativeStopRecording,
|
||||
abortRecording: nativeAbortRecording,
|
||||
} as never);
|
||||
return feature;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllTimers();
|
||||
});
|
||||
|
||||
describe('recording feature', () => {
|
||||
test('slow start exposes starting state before native setup resolves', async () => {
|
||||
const startDeferred = createDeferred<{
|
||||
id: string;
|
||||
filepath: string;
|
||||
sampleRate: number;
|
||||
channels: number;
|
||||
startedAt: number;
|
||||
}>();
|
||||
nativeStartRecording.mockReturnValue(startDeferred.promise);
|
||||
|
||||
const { getCurrentRecordingStatus, startRecording } =
|
||||
await loadRecordingFeature();
|
||||
|
||||
const startPromise = startRecording();
|
||||
expect(getCurrentRecordingStatus()).toMatchObject({
|
||||
status: 'starting',
|
||||
});
|
||||
|
||||
startDeferred.resolve({
|
||||
id: 'native-1',
|
||||
filepath: '/tmp/0.opus',
|
||||
sampleRate: 48_000,
|
||||
channels: 2,
|
||||
startedAt: 123,
|
||||
});
|
||||
|
||||
await startPromise;
|
||||
expect(getCurrentRecordingStatus()).toMatchObject({
|
||||
status: 'recording',
|
||||
});
|
||||
expect(getCurrentRecordingStatus()?.filepath).toContain('0.opus');
|
||||
});
|
||||
|
||||
test('stop handoff moves from finalizing to pending_import without clearing the popup state', async () => {
|
||||
nativeStartRecording.mockResolvedValue({
|
||||
id: 'native-1',
|
||||
filepath: '/tmp/0.opus',
|
||||
sampleRate: 48_000,
|
||||
channels: 2,
|
||||
startedAt: 123,
|
||||
});
|
||||
|
||||
const stopDeferred = createDeferred<{
|
||||
id: string;
|
||||
filepath: string;
|
||||
sampleRate: number;
|
||||
channels: number;
|
||||
durationMs: number;
|
||||
size: number;
|
||||
degraded: boolean;
|
||||
overflowCount: number;
|
||||
}>();
|
||||
nativeStopRecording.mockReturnValue(stopDeferred.promise);
|
||||
|
||||
const {
|
||||
getCurrentRecordingStatus,
|
||||
getRecordingImportQueue,
|
||||
recordingStatus$,
|
||||
startRecording,
|
||||
stopRecording,
|
||||
} = await loadRecordingFeature();
|
||||
|
||||
const started = await startRecording();
|
||||
const seenStatuses: Array<string | null> = [];
|
||||
const subscription = recordingStatus$.subscribe(status => {
|
||||
seenStatuses.push(status?.status ?? null);
|
||||
});
|
||||
|
||||
const stopPromise = stopRecording(started!.id);
|
||||
expect(getCurrentRecordingStatus()).toMatchObject({
|
||||
id: started!.id,
|
||||
status: 'finalizing',
|
||||
});
|
||||
|
||||
stopDeferred.resolve({
|
||||
id: 'native-1',
|
||||
filepath: '/tmp/0.opus',
|
||||
sampleRate: 48_000,
|
||||
channels: 2,
|
||||
durationMs: 2_000,
|
||||
size: 256,
|
||||
degraded: true,
|
||||
overflowCount: 4,
|
||||
});
|
||||
|
||||
await stopPromise;
|
||||
subscription.unsubscribe();
|
||||
|
||||
expect(getCurrentRecordingStatus()).toMatchObject({
|
||||
id: started!.id,
|
||||
status: 'pending_import',
|
||||
degraded: true,
|
||||
overflowCount: 4,
|
||||
});
|
||||
expect(getRecordingImportQueue()).toEqual([
|
||||
expect.objectContaining({
|
||||
id: started!.id,
|
||||
importStatus: 'pending_import',
|
||||
filepath: '/tmp/0.opus',
|
||||
degraded: true,
|
||||
overflowCount: 4,
|
||||
}),
|
||||
]);
|
||||
expect(seenStatuses).toContain('finalizing');
|
||||
expect(seenStatuses).toContain('pending_import');
|
||||
expect(seenStatuses).not.toContain(null);
|
||||
});
|
||||
|
||||
test('system-audio start does not reuse a pending app-scoped prompt', async () => {
|
||||
nativeStartRecording.mockResolvedValue({
|
||||
id: 'native-1',
|
||||
filepath: '/tmp/system.opus',
|
||||
sampleRate: 48_000,
|
||||
channels: 2,
|
||||
startedAt: 123,
|
||||
});
|
||||
|
||||
const { newRecording, startRecording } = await loadRecordingFeature();
|
||||
newRecording({
|
||||
processGroupId: 100,
|
||||
name: 'Zoom',
|
||||
bundleIdentifier: 'us.zoom.xos',
|
||||
icon: undefined,
|
||||
isRunning: true,
|
||||
apps: [
|
||||
{
|
||||
info: {} as never,
|
||||
isRunning: true,
|
||||
processId: 42,
|
||||
processGroupId: 100,
|
||||
bundleIdentifier: 'us.zoom.xos',
|
||||
name: 'Zoom',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await startRecording();
|
||||
|
||||
expect(nativeStartRecording).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
appProcessId: undefined,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('disableRecordingFeature clears the active session so a new recording can start', async () => {
|
||||
nativeStartRecording
|
||||
.mockResolvedValueOnce({
|
||||
id: 'native-1',
|
||||
filepath: '/tmp/0.opus',
|
||||
sampleRate: 48_000,
|
||||
channels: 2,
|
||||
startedAt: 123,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
id: 'native-2',
|
||||
filepath: '/tmp/1.opus',
|
||||
sampleRate: 48_000,
|
||||
channels: 2,
|
||||
startedAt: 456,
|
||||
});
|
||||
|
||||
const feature = await loadRecordingFeature();
|
||||
|
||||
const first = await feature.startRecording();
|
||||
expect(feature.getCurrentRecordingStatus()).toMatchObject({
|
||||
id: first!.id,
|
||||
status: 'recording',
|
||||
});
|
||||
|
||||
await feature.disableRecordingFeature();
|
||||
expect(feature.getCurrentRecordingStatus()).toBeNull();
|
||||
expect(nativeAbortRecording).toHaveBeenCalledWith('native-1');
|
||||
|
||||
const second = await feature.startRecording();
|
||||
expect(second).toMatchObject({
|
||||
id: expect.any(Number),
|
||||
status: 'recording',
|
||||
});
|
||||
expect(second!.id).toBeGreaterThan(first!.id);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
|
||||
const recordingStatus$ = new BehaviorSubject(null);
|
||||
const recordingImportQueue$ = new BehaviorSubject([
|
||||
{
|
||||
id: 7,
|
||||
appName: 'Zoom',
|
||||
workspaceId: 'workspace-1',
|
||||
docId: 'recording-7',
|
||||
startTime: 1000,
|
||||
filepath: '/tmp/meeting.opus',
|
||||
importStatus: 'importing' as const,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
shell: {
|
||||
showItemInFolder: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../src/shared/utils', () => ({
|
||||
isMacOS: () => false,
|
||||
resolvePathInBase: vi.fn((_base: string, subpath: string) => subpath),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/main/security/open-external', () => ({
|
||||
openExternalSafely: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/main/recording/feature', () => ({
|
||||
askForMeetingPermission: vi.fn(),
|
||||
checkMeetingPermissions: vi.fn(),
|
||||
checkRecordingAvailable: vi.fn(),
|
||||
claimRecordingImport: vi.fn(),
|
||||
completeRecordingImport: vi.fn(),
|
||||
disableRecordingFeature: vi.fn(),
|
||||
dismissRecordingStatus: vi.fn(),
|
||||
failRecordingImport: vi.fn(),
|
||||
getCurrentRecordingStatus: vi.fn(),
|
||||
getRecording: vi.fn(),
|
||||
getRecordingImportQueue: vi.fn(),
|
||||
readRecordingFile: vi.fn(),
|
||||
recordingImportQueue$,
|
||||
recordingStatus$,
|
||||
removeRecording: vi.fn(),
|
||||
SAVED_RECORDINGS_DIR: '/tmp',
|
||||
serializeRecordingStatus: vi.fn(),
|
||||
setupRecordingFeature: vi.fn(),
|
||||
startRecording: vi.fn(),
|
||||
stopRecording: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('recording index events', () => {
|
||||
test('onRecordingImportQueueChanged preserves workspace binding metadata', async () => {
|
||||
const { recordingEvents } = await import('../../src/main/recording');
|
||||
const handler = vi.fn();
|
||||
|
||||
const unsubscribe = recordingEvents.onRecordingImportQueueChanged(handler);
|
||||
|
||||
expect(handler).toHaveBeenCalledWith([
|
||||
expect.objectContaining({
|
||||
id: 7,
|
||||
workspaceId: 'workspace-1',
|
||||
docId: 'recording-7',
|
||||
importStatus: 'importing',
|
||||
}),
|
||||
]);
|
||||
|
||||
unsubscribe();
|
||||
});
|
||||
});
|
||||
@@ -1,116 +0,0 @@
|
||||
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();
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -132,51 +132,46 @@ 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');
|
||||
test.each([
|
||||
{
|
||||
name: 'deleting a workspace',
|
||||
outsideDirName: 'outside-delete-target',
|
||||
call: async () => {
|
||||
const { deleteWorkspace } =
|
||||
await import('@affine/electron/helper/workspace/handlers');
|
||||
return deleteWorkspace(
|
||||
universalId({
|
||||
peer: 'local',
|
||||
type: 'workspace',
|
||||
id: '../../outside-delete-target',
|
||||
})
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'deleting backup workspaces',
|
||||
outsideDirName: 'outside-backup-target',
|
||||
call: async () => {
|
||||
const { deleteBackupWorkspace } =
|
||||
await import('@affine/electron/helper/workspace/handlers');
|
||||
return deleteBackupWorkspace('../../outside-backup-target');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'recovering backup workspaces',
|
||||
outsideDirName: 'outside-recover-target',
|
||||
call: async () => {
|
||||
const { recoverBackupWorkspace } =
|
||||
await import('@affine/electron/helper/workspace/handlers');
|
||||
return recoverBackupWorkspace('../../outside-recover-target');
|
||||
},
|
||||
},
|
||||
])('rejects unsafe ids when $name', async ({ outsideDirName, call }) => {
|
||||
const outsideDir = path.join(tmpDir, outsideDirName);
|
||||
|
||||
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');
|
||||
|
||||
await expect(call()).rejects.toThrow('Invalid workspace id');
|
||||
expect(await fs.pathExists(outsideDir)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user