// SPDX-License-Identifier: MIT // Copyright (c) 2022 The Pybricks Authors import 'fake-indexeddb/auto'; import Dexie from 'dexie'; import 'dexie-observable'; import { AsyncSaga, uuid } from '../../test'; import { createCountFunc } from '../utils/iter'; import { FD, FileOpenMode, fileStorageClose, fileStorageCopyFile, fileStorageDeleteFile, fileStorageDidClose, fileStorageDidCopyFile, fileStorageDidDeleteFile, fileStorageDidDumpAllFiles, fileStorageDidFailToCopyFile, fileStorageDidFailToDeleteFile, fileStorageDidFailToDumpAllFiles, fileStorageDidFailToInitialize, fileStorageDidFailToOpen, fileStorageDidFailToRead, fileStorageDidFailToReadFile, fileStorageDidFailToRenameFile, fileStorageDidFailToWrite, fileStorageDidFailToWriteFile, fileStorageDidInitialize, fileStorageDidOpen, fileStorageDidRead, fileStorageDidReadFile, fileStorageDidRenameFile, fileStorageDidWrite, fileStorageDidWriteFile, fileStorageDumpAllFiles, fileStorageOpen, fileStorageRead, fileStorageReadFile, fileStorageRenameFile, fileStorageWrite, fileStorageWriteFile, } from './actions'; import fileStorage from './sagas'; import { FileMetadata, FileStorageDb } from '.'; beforeEach(() => { // deterministic UUID generator for repeatable tests const nextId = createCountFunc(); Dexie.Observable.createUUID = () => uuid(nextId()); }); afterEach(async () => { jest.restoreAllMocks(); await new Promise((resolve, reject) => { const request = indexedDB.deleteDatabase('test'); request.addEventListener('success', resolve); request.addEventListener('error', reject); }); localStorage.clear(); }); /** * helper function that writes test file to storage for later use in a test * @param saga The saga. * @returns The test file metadata and test file contents. */ async function setUpTestFile(saga: AsyncSaga): Promise<[FileMetadata, string]> { const testFilePath = 'test.file'; const testFileId = uuid(0); const testFileContents = 'test file contents'; const testFileContentsSha256 = 'c4fa968a745586faaa030054f51fb1cafd5e9ae25fa6b137ac6477715fdc81b1'; const testFile: FileMetadata = { uuid: testFileId, path: testFilePath, sha256: testFileContentsSha256, viewState: null, }; saga.put(fileStorageOpen(testFilePath, 'w', true)); const didOpen = await saga.take(); if (!fileStorageDidOpen.matches(didOpen)) { fail(didOpen); } saga.put(fileStorageWrite(didOpen.fd, testFileContents)); await expect(saga.take()).resolves.toEqual(fileStorageDidWrite(didOpen.fd)); saga.put(fileStorageClose(didOpen.fd)); await expect(saga.take()).resolves.toEqual(fileStorageDidClose(didOpen.fd)); return [testFile, testFileContents]; } describe('initialize', () => { it('should migrate old program from local storage during initialization', async () => { const oldProgramKey = 'program'; const oldProgramContents = '# test program'; const oldProgramContentsSha256 = '31c21eb39c9276341d9364f6d4bcac46a4aa3768bc2626f8aa742c46e3e0fdd6'; // add item to localStorage to simulate an existing program localStorage.setItem(oldProgramKey, oldProgramContents); expect(localStorage.getItem(oldProgramKey)).toBe(oldProgramContents); const saga = new AsyncSaga(fileStorage, { fileStorage: new FileStorageDb('test'), }); // initialization should remove the localStorage entry and add add it to // new storage backend await expect(saga.take()).resolves.toEqual( fileStorageDidInitialize([ { uuid: uuid(0), path: 'main.py', sha256: oldProgramContentsSha256, viewState: null, }, ]), ); expect(localStorage.getItem(oldProgramKey)).toBeNull(); await saga.end(); }); it('should catch error', async () => { const testError = new Error('test error'); jest.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { throw testError; }); const saga = new AsyncSaga(fileStorage, { fileStorage: new FileStorageDb('test'), }); await expect(saga.take()).resolves.toEqual( fileStorageDidFailToInitialize(testError), ); await saga.end(); }); }); describe('open', () => { let saga: AsyncSaga; beforeEach(async () => { saga = new AsyncSaga(fileStorage, { fileStorage: new FileStorageDb('test') }); await expect(saga.take()).resolves.toEqual(fileStorageDidInitialize([])); }); it('should fail to open for reading if file does not exist', async () => { saga.put(fileStorageOpen('test.file', 'r', false)); await expect(saga.take()).resolves.toEqual( fileStorageDidFailToOpen( 'test.file', new Error("file 'test.file' does not exist"), ), ); }); describe('should open file for writing if file does not exist', () => { beforeEach(async () => { saga.put(fileStorageOpen('test.file', 'w', true)); await expect(saga.take()).resolves.toEqual( fileStorageDidOpen('test.file', uuid(0), 0 as FD), ); }); describe('should fail to open if file is already open for writing', () => { it.each(['r', 'w'])('mode: %o', async (mode) => { saga.put(fileStorageOpen('test.file', mode, true)); await expect(saga.take()).resolves.toEqual( fileStorageDidFailToOpen( 'test.file', new Error("file 'test.file' is already in use"), ), ); }); }); afterEach(async () => { saga.put(fileStorageClose(0 as FD)); await expect(saga.take()).resolves.toEqual(fileStorageDidClose(0 as FD)); }); }); describe('should open file if file exists', () => { beforeEach(async () => { await setUpTestFile(saga); }); it.each(['r', 'w'])('mode: %o', async (mode) => { saga.put(fileStorageOpen('test.file', mode, false)); await expect(saga.take()).resolves.toEqual( fileStorageDidOpen('test.file', uuid(0), 1 as FD), ); }); }); it('should allow multiple readers', async () => { await setUpTestFile(saga); saga.put(fileStorageOpen('test.file', 'r', false)); await expect(saga.take()).resolves.toEqual( fileStorageDidOpen('test.file', uuid(0), 1 as FD), ); saga.put(fileStorageOpen('test.file', 'r', false)); await expect(saga.take()).resolves.toEqual( fileStorageDidOpen('test.file', uuid(0), 2 as FD), ); }); it('should fail to open for writing if already open for reading', async () => { await setUpTestFile(saga); saga.put(fileStorageOpen('test.file', 'r', false)); await expect(saga.take()).resolves.toEqual( fileStorageDidOpen('test.file', uuid(0), 1 as FD), ); saga.put(fileStorageOpen('test.file', 'w', false)); await expect(saga.take()).resolves.toEqual( fileStorageDidFailToOpen( 'test.file', new Error("file 'test.file' is already in use"), ), ); }); it('should allow calling close multiple times', async () => { saga.put(fileStorageOpen('test.file', 'w', true)); await expect(saga.take()).resolves.toEqual( fileStorageDidOpen('test.file', uuid(0), 0 as FD), ); saga.put(fileStorageClose(0 as FD)); await expect(saga.take()).resolves.toEqual(fileStorageDidClose(0 as FD)); saga.put(fileStorageClose(0 as FD)); await expect(saga.take()).resolves.toEqual(fileStorageDidClose(0 as FD)); }); afterEach(async () => { await saga.end(); }); }); describe('read', () => { let saga: AsyncSaga; beforeEach(async () => { saga = new AsyncSaga(fileStorage, { fileStorage: new FileStorageDb('test') }); await expect(saga.take()).resolves.toEqual(fileStorageDidInitialize([])); }); describe('should read an open file', () => { it.each(['r', 'w'])('mode: %o', async (mode) => { const [, contents] = await setUpTestFile(saga); saga.put(fileStorageOpen('test.file', mode, false)); await expect(saga.take()).resolves.toEqual( fileStorageDidOpen('test.file', uuid(0), 1 as FD), ); saga.put(fileStorageRead(1 as FD)); await expect(saga.take()).resolves.toEqual( fileStorageDidRead(1 as FD, contents), ); }); }); describe('should fail to read a closed file', () => { it.each(['r', 'w'])('mode: %o', async (mode) => { await setUpTestFile(saga); saga.put(fileStorageOpen('test.file', mode, false)); await expect(saga.take()).resolves.toEqual( fileStorageDidOpen('test.file', uuid(0), 1 as FD), ); saga.put(fileStorageClose(1 as FD)); await expect(saga.take()).resolves.toEqual(fileStorageDidClose(1 as FD)); saga.put(fileStorageRead(1 as FD)); await expect(saga.take()).resolves.toEqual( fileStorageDidFailToRead( 1 as FD, new Error('file descriptor 1 is not open'), ), ); }); }); afterEach(async () => { await saga.end(); }); }); describe('write', () => { let saga: AsyncSaga; beforeEach(async () => { saga = new AsyncSaga(fileStorage, { fileStorage: new FileStorageDb('test') }); await expect(saga.take()).resolves.toEqual(fileStorageDidInitialize([])); }); it('should write a file open for writing', async () => { await setUpTestFile(saga); saga.put(fileStorageOpen('test.file', 'w', false)); await expect(saga.take()).resolves.toEqual( fileStorageDidOpen('test.file', uuid(0), 1 as FD), ); saga.put(fileStorageWrite(1 as FD, 'new contents')); await expect(saga.take()).resolves.toEqual(fileStorageDidWrite(1 as FD)); }); it('should fail to write a file open for reading', async () => { await setUpTestFile(saga); saga.put(fileStorageOpen('test.file', 'r', false)); await expect(saga.take()).resolves.toEqual( fileStorageDidOpen('test.file', uuid(0), 1 as FD), ); saga.put(fileStorageWrite(1 as FD, 'new contents')); await expect(saga.take()).resolves.toEqual( fileStorageDidFailToWrite( 1 as FD, new Error('file descriptor 1 is not open for writing'), ), ); }); describe('should fail to write a closed file', () => { it.each(['r', 'w'])('mode: %o', async (mode) => { await setUpTestFile(saga); saga.put(fileStorageOpen('test.file', mode, false)); await expect(saga.take()).resolves.toEqual( fileStorageDidOpen('test.file', uuid(0), 1 as FD), ); saga.put(fileStorageClose(1 as FD)); await expect(saga.take()).resolves.toEqual(fileStorageDidClose(1 as FD)); saga.put(fileStorageWrite(1 as FD, 'new contents')); await expect(saga.take()).resolves.toEqual( fileStorageDidFailToWrite( 1 as FD, new Error('file descriptor 1 is not open'), ), ); }); }); afterEach(async () => { await saga.end(); }); }); describe('readFile', () => { let saga: AsyncSaga; beforeEach(async () => { saga = new AsyncSaga(fileStorage, { fileStorage: new FileStorageDb('test') }); await expect(saga.take()).resolves.toEqual(fileStorageDidInitialize([])); saga.put(fileStorageReadFile('test.file')); await expect(saga.take()).resolves.toEqual( fileStorageOpen('test.file', 'r', false), ); }); it('should forward open error', async () => { const error = new Error('open test file failed'); saga.put(fileStorageDidFailToOpen('test.file', error)); await expect(saga.take()).resolves.toEqual( fileStorageDidFailToReadFile('test.file', error), ); }); describe('should open file', () => { beforeEach(async () => { saga.put(fileStorageDidOpen('test.file', uuid(0), 0 as FD)); await expect(saga.take()).resolves.toEqual(fileStorageRead(0 as FD)); }); it('should forward read error', async () => { const error = new Error('test fail to read'); saga.put(fileStorageDidFailToRead(0 as FD, error)); // file should be closed before we get fileStorageDidFailToReadFile await expect(saga.take()).resolves.toEqual(fileStorageClose(0 as FD)); saga.put(fileStorageDidClose(0 as FD)); await expect(saga.take()).resolves.toEqual( fileStorageDidFailToReadFile('test.file', error), ); }); it('should return file contents', async () => { const contents = 'test read contents'; saga.put(fileStorageDidRead(0 as FD, contents)); // file should be closed before we get fileStorageDidReadFile await expect(saga.take()).resolves.toEqual(fileStorageClose(0 as FD)); saga.put(fileStorageDidClose(0 as FD)); await expect(saga.take()).resolves.toEqual( fileStorageDidReadFile('test.file', contents), ); }); }); afterEach(async () => { await saga.end(); }); }); describe('writeFile', () => { let saga: AsyncSaga; const contents = 'test write file contents'; beforeEach(async () => { saga = new AsyncSaga(fileStorage, { fileStorage: new FileStorageDb('test') }); await expect(saga.take()).resolves.toEqual(fileStorageDidInitialize([])); saga.put(fileStorageWriteFile('test.file', contents)); await expect(saga.take()).resolves.toEqual( fileStorageOpen('test.file', 'w', true), ); }); it('should forward open error', async () => { const error = new Error('open test file failed'); saga.put(fileStorageDidFailToOpen('test.file', error)); await expect(saga.take()).resolves.toEqual( fileStorageDidFailToWriteFile('test.file', error), ); }); describe('should open file', () => { beforeEach(async () => { saga.put(fileStorageDidOpen('test.file', uuid(0), 0 as FD)); await expect(saga.take()).resolves.toEqual( fileStorageWrite(0 as FD, contents), ); }); it('should forward write error', async () => { const error = new Error('test fail to write'); saga.put(fileStorageDidFailToWrite(0 as FD, error)); // file should be closed before we get fileStorageDidFailToWriteFile await expect(saga.take()).resolves.toEqual(fileStorageClose(0 as FD)); saga.put(fileStorageDidClose(0 as FD)); await expect(saga.take()).resolves.toEqual( fileStorageDidFailToWriteFile('test.file', error), ); }); it('should return', async () => { saga.put(fileStorageDidWrite(0 as FD)); // file should be closed before we get fileStorageDidWriteFile await expect(saga.take()).resolves.toEqual(fileStorageClose(0 as FD)); saga.put(fileStorageDidClose(0 as FD)); await expect(saga.take()).resolves.toEqual( fileStorageDidWriteFile('test.file', uuid(0)), ); }); }); afterEach(async () => { await saga.end(); }); }); describe('copyFile', () => { let saga: AsyncSaga; beforeEach(async () => { saga = new AsyncSaga(fileStorage, { fileStorage: new FileStorageDb('test') }); await expect(saga.take()).resolves.toEqual(fileStorageDidInitialize([])); await setUpTestFile(saga); }); it('should fail if file does not exist', async () => { saga.put(fileStorageCopyFile('other.file', 'new.file')); await expect(saga.take()).resolves.toEqual( fileStorageDidFailToCopyFile( 'other.file', new Error("file 'other.file' does not exist"), ), ); }); it('should fail if new file is open', async () => { saga.put(fileStorageOpen('new.file', 'w', true)); await expect(saga.take()).resolves.toEqual( fileStorageDidOpen('new.file', uuid(1), 1 as FD), ); saga.put(fileStorageCopyFile('test.file', 'new.file')); await expect(saga.take()).resolves.toEqual( fileStorageDidFailToCopyFile( 'test.file', new Error("file 'new.file' is in use"), ), ); }); it('should fail if new file exists', async () => { saga.put(fileStorageOpen('new.file', 'w', true)); await expect(saga.take()).resolves.toEqual( fileStorageDidOpen('new.file', uuid(1), 1 as FD), ); saga.put(fileStorageClose(1 as FD)); await expect(saga.take()).resolves.toEqual(fileStorageDidClose(1 as FD)); saga.put(fileStorageCopyFile('test.file', 'new.file')); await expect(saga.take()).resolves.toEqual( fileStorageDidFailToCopyFile( 'test.file', new Error("file 'new.file' already exists"), ), ); }); it('should copy file', async () => { saga.put(fileStorageCopyFile('test.file', 'new.file')); await expect(saga.take()).resolves.toEqual(fileStorageDidCopyFile('test.file')); }); afterEach(async () => { await saga.end(); }); }); describe('deleteFile', () => { let saga: AsyncSaga; beforeEach(async () => { saga = new AsyncSaga(fileStorage, { fileStorage: new FileStorageDb('test') }); await expect(saga.take()).resolves.toEqual(fileStorageDidInitialize([])); await setUpTestFile(saga); }); it('should fail if file does not exist', async () => { saga.put(fileStorageDeleteFile('other.file')); await expect(saga.take()).resolves.toEqual( fileStorageDidFailToDeleteFile( 'other.file', new Error("file 'other.file' does not exist"), ), ); }); it('should fail if file is open', async () => { saga.put(fileStorageOpen('test.file', 'r', false)); await expect(saga.take()).resolves.toEqual( fileStorageDidOpen('test.file', uuid(0), 1 as FD), ); saga.put(fileStorageDeleteFile('test.file')); await expect(saga.take()).resolves.toEqual( fileStorageDidFailToDeleteFile( 'test.file', new Error("file 'test.file' is in use"), ), ); }); it('should remove file', async () => { saga.put(fileStorageDeleteFile('test.file')); await expect(saga.take()).resolves.toEqual( fileStorageDidDeleteFile('test.file'), ); }); afterEach(async () => { await saga.end(); }); }); describe('renameFile', () => { let saga: AsyncSaga; let testFile: FileMetadata; const newPath = 'new.file'; beforeEach(async () => { saga = new AsyncSaga(fileStorage, { fileStorage: new FileStorageDb('test') }); await expect(saga.take()).resolves.toEqual(fileStorageDidInitialize([])); [testFile] = await setUpTestFile(saga); }); it('should fail if file does not exist', async () => { saga.put(fileStorageRenameFile('other.file', newPath)); await expect(saga.take()).resolves.toEqual( fileStorageDidFailToRenameFile( 'other.file', new Error("file 'other.file' does not exist"), ), ); }); it('should fail if file is open', async () => { saga.put(fileStorageOpen('test.file', 'r', false)); await expect(saga.take()).resolves.toEqual( fileStorageDidOpen('test.file', uuid(0), 1 as FD), ); saga.put(fileStorageRenameFile('test.file', newPath)); await expect(saga.take()).resolves.toEqual( fileStorageDidFailToRenameFile( 'test.file', new Error("file 'test.file' is in use"), ), ); }); it('should fail if new path already exists', async () => { // there are two paths here that result in the same error // the first is if the file is open, e.g. in another tab saga.put(fileStorageOpen(newPath, 'w', true)); await expect(saga.take()).resolves.toEqual( fileStorageDidOpen(newPath, uuid(1), 1 as FD), ); saga.put(fileStorageRenameFile('test.file', newPath)); await expect(saga.take()).resolves.toEqual( fileStorageDidFailToRenameFile( 'test.file', new Error("file 'new.file' already exists"), ), ); // the second is if the file is not open but still exists in storage saga.put(fileStorageClose(1 as FD)); await expect(saga.take()).resolves.toEqual(fileStorageDidClose(1 as FD)); saga.put(fileStorageRenameFile('test.file', newPath)); await expect(saga.take()).resolves.toEqual( fileStorageDidFailToRenameFile( 'test.file', new Error("file 'new.file' already exists"), ), ); }); it('should change file', async () => { saga.put(fileStorageRenameFile(testFile.path, newPath)); await expect(saga.take()).resolves.toEqual( fileStorageDidRenameFile(testFile.path), ); }); afterEach(async () => { await saga.end(); }); }); describe('dump all files', () => { let saga: AsyncSaga; let testFile: FileMetadata; let testFileContents: string; beforeEach(async () => { saga = new AsyncSaga(fileStorage, { fileStorage: new FileStorageDb('test') }); await expect(saga.take()).resolves.toEqual(fileStorageDidInitialize([])); [testFile, testFileContents] = await setUpTestFile(saga); }); it('should archive file', async () => { saga.put(fileStorageDumpAllFiles()); await expect(saga.take()).resolves.toEqual( fileStorageDidDumpAllFiles([ { path: testFile.path, contents: testFileContents }, ]), ); }); it('should catch error', async () => { const testError = new Error('test error'); jest.spyOn(Dexie.prototype, 'transaction').mockImplementation(() => { throw testError; }); saga.put(fileStorageDumpAllFiles()); await expect(saga.take()).resolves.toEqual( fileStorageDidFailToDumpAllFiles(testError), ); }); afterEach(async () => { await saga.end(); }); });