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:
DarkSky
2026-03-23 22:38:52 +08:00
committed by GitHub
parent b0251c93cb
commit 9b56a05159
26 changed files with 3560 additions and 1277 deletions
@@ -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);
});
});