// Copyright (c) Jupyter Development Team. // Distributed under the terms of the Modified BSD License. import 'jest'; import expect from 'expect'; import { PageConfig } from '@jupyterlab/coreutils'; import { UUID } from '@lumino/coreutils'; import { DocumentManager, IDocumentManager } from '@jupyterlab/docmanager'; import { DocumentRegistry, TextModelFactory } from '@jupyterlab/docregistry'; import { StateDB } from '@jupyterlab/statedb'; import { FileBrowserModel, CHUNK_SIZE, LARGE_FILE_SIZE } from '../src'; import { Contents, ServiceManager } from '@jupyterlab/services'; import { acceptDialog, dismissDialog, signalToPromises, sleep } from '@jupyterlab/testutils'; import * as Mock from '@jupyterlab/testutils/lib/mock'; import { toArray } from '@lumino/algorithm'; /** * A contents manager that delays requests by less each time it is called * in order to simulate out-of-order responses from the server. */ class DelayedContentsManager extends Mock.ContentsManagerMock { get( path: string, options?: Contents.IFetchOptions ): Promise { return new Promise(resolve => { const delay = this._delay; this._delay -= 500; void super.get(path, options).then(contents => { setTimeout(() => { resolve(contents); }, Math.max(delay, 0)); }); }); } private _delay = 1000; } describe('filebrowser/model', () => { let manager: IDocumentManager; let serviceManager: ServiceManager.IManager; let registry: DocumentRegistry; let model: FileBrowserModel; let name: string; let subDir: string; let state: StateDB; const opener: DocumentManager.IWidgetOpener = { open: widget => { /* no op */ } }; beforeAll(() => { registry = new DocumentRegistry({ textModelFactory: new TextModelFactory() }); serviceManager = new Mock.ServiceManagerMock(); manager = new DocumentManager({ registry, opener, manager: serviceManager }); state = new StateDB(); }); beforeEach(async () => { await state.clear(); model = new FileBrowserModel({ manager, state }); let contents = await manager.newUntitled({ type: 'file' }); name = contents.name; contents = await manager.newUntitled({ type: 'directory' }); subDir = contents.path; return model.cd(); }); afterEach(() => { model.dispose(); }); describe('FileBrowserModel', () => { describe('#constructor()', () => { it('should construct a new file browser model', () => { model = new FileBrowserModel({ manager }); expect(model).toBeInstanceOf(FileBrowserModel); }); }); describe('#pathChanged', () => { it('should be emitted when the path changes', async () => { let called = false; model.pathChanged.connect((sender, args) => { expect(sender).toBe(model); expect(args.name).toBe('path'); expect(args.oldValue).toBe(''); expect(args.newValue).toBe(subDir); called = true; }); await model.cd(subDir); expect(called).toBe(true); }); }); describe('#refreshed', () => { it('should be emitted after a refresh', async () => { let called = false; model.refreshed.connect((sender, arg) => { expect(sender).toBe(model); expect(arg).toBeUndefined(); called = true; }); await model.cd(); expect(called).toBe(true); }); it('should be emitted when the path changes', async () => { let called = false; model.refreshed.connect((sender, arg) => { expect(sender).toBe(model); expect(arg).toBeUndefined(); called = true; }); await model.cd(subDir); expect(called).toBe(true); }); }); describe('#fileChanged', () => { it('should be emitted when a file is created', async () => { let called = false; model.fileChanged.connect((sender, args) => { expect(sender).toBe(model); expect(args.type).toBe('new'); expect(args.oldValue).toBeNull(); expect(args.newValue!.type).toBe('file'); called = true; }); await manager.newUntitled({ type: 'file' }); expect(called).toBe(true); }); it('should be emitted when a file is renamed', async () => { let called = false; model.fileChanged.connect((sender, args) => { expect(sender).toBe(model); expect(args.type).toBe('rename'); expect(args.oldValue!.path).toBe(name); expect(args.newValue!.path).toBe(name + '.bak'); called = true; }); await manager.rename(name, name + '.bak'); expect(called).toBe(true); }); it('should be emitted when a file is deleted', async () => { let called = false; model.fileChanged.connect((sender, args) => { expect(sender).toBe(model); expect(args.type).toBe('delete'); expect(args.oldValue!.path).toBe(name); expect(args.newValue).toBeNull(); called = true; }); await manager.deleteFile(name); expect(called).toBe(true); }); }); describe('#path', () => { it('should be the current path of the model', async () => { expect(model.path).toBe(''); await model.cd(subDir); expect(model.path).toBe(subDir); }); }); describe('#items()', () => { it('should get an iterator of items in the current path', () => { const items = model.items(); expect(items.next()).toBeTruthy(); }); }); describe('#isDisposed', () => { it('should test whether the model is disposed', () => { expect(model.isDisposed).toBe(false); model.dispose(); expect(model.isDisposed).toBe(true); }); }); describe('#sessions()', () => { it('should be the session models for the active notebooks', async () => { const contents = await manager.newUntitled({ type: 'notebook' }); const session = await serviceManager.sessions.startNew({ name: '', path: contents.path, type: 'test' }); await model.cd(); expect(model.sessions().next()).toBeTruthy(); await session.shutdown(); }); }); describe('#dispose()', () => { it('should dispose of the resources held by the model', () => { model.dispose(); expect(model.isDisposed).toBe(true); }); it('should be safe to call more than once', () => { model.dispose(); model.dispose(); expect(model.isDisposed).toBe(true); }); }); describe('#refresh()', () => { it('should refresh the contents', () => { return model.refresh(); }); }); describe('#cd()', () => { it('should change directory', async () => { await model.cd(subDir); expect(model.path).toBe(subDir); }); it('should accept a relative path', async () => { await model.cd(subDir); expect(model.path).toBe(subDir); }); it('should accept a parent directory', async () => { await model.cd(subDir); await model.cd('..'); expect(model.path).toBe(''); }); it('should be resilient to a slow initial fetch', async () => { const delayedServiceManager = new Mock.ServiceManagerMock(); (delayedServiceManager as any).contents = new DelayedContentsManager(); const contents = await delayedServiceManager.contents.newUntitled({ type: 'directory' }); subDir = contents.path; const manager = new DocumentManager({ registry, opener, manager: delayedServiceManager }); model = new FileBrowserModel({ manager, state }); // Should delay 1000ms // An initial refresh is called in the constructor. // If it is too slow, it can come in after the directory change, // causing a directory set by, e.g., the tree handler to be wrong. // This checks to make sure we are handling that case correctly. await model.cd(subDir); // should delay 500ms await sleep(2000); expect(model.path).toBe(subDir); manager.dispose(); delayedServiceManager.contents.dispose(); delayedServiceManager.dispose(); model.dispose(); }); }); describe('#restore()', () => { it('should restore based on ID', async () => { const id = 'foo'; const model2 = new FileBrowserModel({ manager, state }); await model.restore(id); await model.cd(subDir); expect(model.path).toBe(subDir); expect(model2.path).toBe(''); await model2.restore(id); expect(model2.path).toBe(subDir); model2.dispose(); }); it('should be safe to call multiple times', async () => { const id = 'bar'; const model2 = new FileBrowserModel({ manager, state }); await model.restore(id); await model.cd(subDir); expect(model.path).toBe(subDir); expect(model2.path).toBe(''); await model2.restore(id); await model2.restore(id); expect(model2.path).toBe(subDir); model2.dispose(); }); }); describe('#download()', () => { it('should download the file without error', () => { // TODO: how to test this? }); }); describe('#upload()', () => { it('should upload a file object', async () => { const fname = UUID.uuid4() + '.html'; const file = new File(['

Hello world!

'], fname, { type: 'text/html' }); const contents = await model.upload(file); expect(contents.name).toBe(fname); }); it('should overwrite', async () => { const fname = UUID.uuid4() + '.html'; const file = new File(['

Hello world!

'], fname, { type: 'text/html' }); const contents = await model.upload(file); expect(contents.name).toBe(fname); const promise = model.upload(file); await acceptDialog(); await promise; expect(contents.name).toBe(fname); }); it('should not overwrite', async () => { const fname = UUID.uuid4() + '.html'; const file = new File(['

Hello world!

'], fname, { type: 'text/html' }); const contents = await model.upload(file); expect(contents.name).toBe(fname); const promise = model.upload(file); await dismissDialog(); await expect(promise).rejects.toThrow('File not uploaded'); }); it('should emit the fileChanged signal', async () => { const fname = UUID.uuid4() + '.html'; let called = false; model.fileChanged.connect((sender, args) => { expect(sender).toBe(model); expect(args.type).toBe('save'); expect(args.oldValue).toBeNull(); expect(args.newValue!.path).toBe(fname); called = true; }); const file = new File(['

Hello world!

'], fname, { type: 'text/html' }); await model.upload(file); expect(called).toBe(true); }); describe('older notebook version', () => { let prevNotebookVersion: string; beforeAll(() => { prevNotebookVersion = PageConfig.setOption( 'notebookVersion', JSON.stringify([5, 0, 0]) ); }); it('should not upload large file', async () => { const fname = UUID.uuid4() + '.html'; const file = new File([new ArrayBuffer(LARGE_FILE_SIZE + 1)], fname); await expect(model.upload(file)).rejects.toThrow( `Cannot upload file (>15 MB). ${fname}` ); }); afterAll(() => { PageConfig.setOption('notebookVersion', prevNotebookVersion); }); }); describe('newer notebook version', () => { let prevNotebookVersion: string; beforeAll(() => { prevNotebookVersion = PageConfig.setOption( 'notebookVersion', JSON.stringify([5, 1, 0]) ); }); for (const ending of ['.txt', '.ipynb']) { for (const size of [ CHUNK_SIZE - 1, CHUNK_SIZE, CHUNK_SIZE + 1, 2 * CHUNK_SIZE ]) { it(`should upload a large ${ending} file of size ${size}`, async () => { const fname = UUID.uuid4() + ending; // minimal valid (according to server) notebook let content = '{"nbformat": 4, "metadata": {"_": ""}, "nbformat_minor": 2, "cells": []}'; // make metadata longer so that total document is `size` long content = content.replace( '"_": ""', `"_": "${' '.repeat(size - content.length)}"` ); const file = new File([content], fname, { type: 'text/plain' }); await model.upload(file); // Ensure we get the file back. const contentModel = await model.manager.services.contents.get( fname ); expect(contentModel.content.length).toBeGreaterThan(0); }); } } it(`should produce progress as a large file uploads`, async () => { const fname = UUID.uuid4() + '.txt'; const file = new File([new ArrayBuffer(2 * CHUNK_SIZE)], fname); const [start, first, second, finished] = signalToPromises( model.uploadChanged, 4 ); const uploaded = model.upload(file); expect(toArray(model.uploads())).toEqual([]); expect(await start).toEqual([ model, { name: 'start', oldValue: null, newValue: { path: fname, progress: 0 } } ]); expect(toArray(model.uploads())).toEqual([ { path: fname, progress: 0 } ]); expect(await first).toEqual([ model, { name: 'update', oldValue: { path: fname, progress: 0 }, newValue: { path: fname, progress: 0 } } ]); expect(toArray(model.uploads())).toEqual([ { path: fname, progress: 0 } ]); expect(await second).toEqual([ model, { name: 'update', oldValue: { path: fname, progress: 0 }, newValue: { path: fname, progress: 1 / 2 } } ]); expect(toArray(model.uploads())).toEqual([ { path: fname, progress: 1 / 2 } ]); expect(await finished).toEqual([ model, { name: 'finish', oldValue: { path: fname, progress: 1 / 2 }, newValue: null } ]); expect(toArray(model.uploads())).toEqual([]); await uploaded; }); afterAll(() => { PageConfig.setOption('notebookVersion', prevNotebookVersion); }); }); }); }); });