diff --git a/docs/tutorials/screenshots/dataset-created.png b/docs/assets/tutorials/dataset-created.png similarity index 100% rename from docs/tutorials/screenshots/dataset-created.png rename to docs/assets/tutorials/dataset-created.png diff --git a/docs/tutorials/screenshots/dataset-creation.png b/docs/assets/tutorials/dataset-creation.png similarity index 100% rename from docs/tutorials/screenshots/dataset-creation.png rename to docs/assets/tutorials/dataset-creation.png diff --git a/docs/assets/tutorials/home-page.png b/docs/assets/tutorials/home-page.png new file mode 100644 index 000000000..254021257 Binary files /dev/null and b/docs/assets/tutorials/home-page.png differ diff --git a/docs/assets/tutorials/single/all-interfaces-added.png b/docs/assets/tutorials/single/all-interfaces-added.png new file mode 100644 index 000000000..6ef05b8d5 Binary files /dev/null and b/docs/assets/tutorials/single/all-interfaces-added.png differ diff --git a/docs/assets/tutorials/single/conversion-results-page.png b/docs/assets/tutorials/single/conversion-results-page.png new file mode 100644 index 000000000..9e04440fa Binary files /dev/null and b/docs/assets/tutorials/single/conversion-results-page.png differ diff --git a/docs/assets/tutorials/single/fail-name.png b/docs/assets/tutorials/single/fail-name.png new file mode 100644 index 000000000..af8d0fc2a Binary files /dev/null and b/docs/assets/tutorials/single/fail-name.png differ diff --git a/docs/assets/tutorials/single/format-options.png b/docs/assets/tutorials/single/format-options.png new file mode 100644 index 000000000..2b466bbf5 Binary files /dev/null and b/docs/assets/tutorials/single/format-options.png differ diff --git a/docs/assets/tutorials/single/formats-page.png b/docs/assets/tutorials/single/formats-page.png new file mode 100644 index 000000000..e8a463813 Binary files /dev/null and b/docs/assets/tutorials/single/formats-page.png differ diff --git a/docs/assets/tutorials/single/home-page-complete.png b/docs/assets/tutorials/single/home-page-complete.png new file mode 100644 index 000000000..b804f49e1 Binary files /dev/null and b/docs/assets/tutorials/single/home-page-complete.png differ diff --git a/docs/assets/tutorials/single/info-page.png b/docs/assets/tutorials/single/info-page.png new file mode 100644 index 000000000..90a466f7e Binary files /dev/null and b/docs/assets/tutorials/single/info-page.png differ diff --git a/docs/assets/tutorials/single/inspect-page.png b/docs/assets/tutorials/single/inspect-page.png new file mode 100644 index 000000000..fa912aa80 Binary files /dev/null and b/docs/assets/tutorials/single/inspect-page.png differ diff --git a/docs/assets/tutorials/single/interface-added.png b/docs/assets/tutorials/single/interface-added.png new file mode 100644 index 000000000..6988b5858 Binary files /dev/null and b/docs/assets/tutorials/single/interface-added.png differ diff --git a/docs/assets/tutorials/single/intro-page.png b/docs/assets/tutorials/single/intro-page.png new file mode 100644 index 000000000..cf87fbbbe Binary files /dev/null and b/docs/assets/tutorials/single/intro-page.png differ diff --git a/docs/assets/tutorials/single/metadata-ecephys.png b/docs/assets/tutorials/single/metadata-ecephys.png new file mode 100644 index 000000000..7700d0c1b Binary files /dev/null and b/docs/assets/tutorials/single/metadata-ecephys.png differ diff --git a/docs/assets/tutorials/single/metadata-nwbfile.png b/docs/assets/tutorials/single/metadata-nwbfile.png new file mode 100644 index 000000000..ff4178a39 Binary files /dev/null and b/docs/assets/tutorials/single/metadata-nwbfile.png differ diff --git a/docs/assets/tutorials/single/metadata-page.png b/docs/assets/tutorials/single/metadata-page.png new file mode 100644 index 000000000..e422b1e7c Binary files /dev/null and b/docs/assets/tutorials/single/metadata-page.png differ diff --git a/docs/assets/tutorials/single/metadata-subject-complete.png b/docs/assets/tutorials/single/metadata-subject-complete.png new file mode 100644 index 000000000..82e59f8fa Binary files /dev/null and b/docs/assets/tutorials/single/metadata-subject-complete.png differ diff --git a/docs/assets/tutorials/single/preview-page.png b/docs/assets/tutorials/single/preview-page.png new file mode 100644 index 000000000..45b0b9900 Binary files /dev/null and b/docs/assets/tutorials/single/preview-page.png differ diff --git a/docs/assets/tutorials/single/search-behavior.png b/docs/assets/tutorials/single/search-behavior.png new file mode 100644 index 000000000..41dac8ba6 Binary files /dev/null and b/docs/assets/tutorials/single/search-behavior.png differ diff --git a/docs/assets/tutorials/single/sourcedata-page-specified.png b/docs/assets/tutorials/single/sourcedata-page-specified.png new file mode 100644 index 000000000..d9e1b6317 Binary files /dev/null and b/docs/assets/tutorials/single/sourcedata-page-specified.png differ diff --git a/docs/assets/tutorials/single/sourcedata-page.png b/docs/assets/tutorials/single/sourcedata-page.png new file mode 100644 index 000000000..fbd840b64 Binary files /dev/null and b/docs/assets/tutorials/single/sourcedata-page.png differ diff --git a/docs/assets/tutorials/single/valid-name.png b/docs/assets/tutorials/single/valid-name.png new file mode 100644 index 000000000..30974a351 Binary files /dev/null and b/docs/assets/tutorials/single/valid-name.png differ diff --git a/docs/assets/tutorials/single/workflow-page.png b/docs/assets/tutorials/single/workflow-page.png new file mode 100644 index 000000000..36e93d0dc Binary files /dev/null and b/docs/assets/tutorials/single/workflow-page.png differ diff --git a/docs/tutorials/dataset.rst b/docs/tutorials/dataset.rst index ce640a71f..c0b2aca0a 100644 --- a/docs/tutorials/dataset.rst +++ b/docs/tutorials/dataset.rst @@ -11,38 +11,47 @@ To get you started as quickly as possible, we’ve created a way to generate thi Navigate to the **Settings** page using the main sidebar. Then press the **Generate** button in the top-right corner to initiate the dataset creation. -.. figure:: ./screenshots/dataset-creation.png +.. figure:: ../assets/tutorials/dataset-creation.png :align: center :alt: Dataset Creation Screen Press the Generate button on the Settings page to create the dataset. -The generated dataset will be organized as follows: +The generated data will populate in the `~/NWB_GUIDE/test_data` directory and include a `data` folder with the original data as well as a `dataset` folder that duplicates this `data` across multiple subjects and sessions. .. code-block:: bash - dataset/ - ├── mouse1/ - │ ├── mouse1_Session1/ - │ │ ├── mouse1_Session1_g0/ - │ │ │ ├── mouse1_Session1_g0_imec/ - │ │ │ │ ├── mouse1_Session1_g0_imec.ap.bin - │ │ │ │ ├── mouse1_Session1_g0_imec.ap.meta - │ │ │ │ ├── mouse1_Session1_g0_imec.lf.bin - │ │ │ │ └── mouse1_Session1_g0_imec.lf.meta - │ │ │ └── mouse1_Session1_phy/ - │ │ │ - │ │ └── mouse1_Session2/ - │ │ ├── mouse1_Session2_g0/ - │ │ │ ... - │ │ └── mouse1_Session2_phy/ - │ │ ... - │ │ - └── mouse2/ - ├── mouse2_Session1/ - │ ... - │ - └── mouse2_Session2/ - ... + test-data/ + ├── data/ + │ ├── spikeglx/ + │ │ ├── Session1_g0/ + │ │ │ ├── Session1_g0_imec0/ + │ │ │ │ ├── Session1_g0_t0.imec0.ap.bin + │ │ │ │ ├── Session1_g0_t0.imec0.ap.meta + │ │ │ │ ├── Session1_g0_t0.imec0.lf.bin + │ │ │ │ └── Session1_g0_t0.imec0.lf.meta + │ │ └── phy/ + ├── dataset/ + │ ├── mouse1/ + │ │ ├── mouse1_Session1/ + │ │ │ ├── mouse1_Session1_g0/ + │ │ │ │ ├── mouse1_Session1_g0_imec0/ + │ │ │ │ │ ├── mouse1_Session1_g0_t0.imec0.ap.bin + │ │ │ │ │ ├── mouse1_Session1_g0_t0.imec0.ap.meta + │ │ │ │ │ ├── mouse1_Session1_g0_t0.imec0.lf.bin + │ │ │ │ │ └── mouse1_Session1_g0_t0.imec0.lf.meta + │ │ │ │ └── mouse1_Session1_phy/ + │ │ │ └── mouse1_Session2/ + │ │ │ ├── mouse1_Session2_g0/ + │ │ │ │ ... + │ │ │ └── mouse1_Session2_phy/ + │ │ │ ... + │ ├── mouse2/ + │ │ ├── mouse2_Session1/ + │ │ │ ... + │ │ └── mouse2_Session2/ + │ │ ... + + Now you’re ready to start your first conversion using the NWB GUIDE! diff --git a/docs/tutorials/single_session.rst b/docs/tutorials/single_session.rst index 51629c0f4..6f1ba5744 100644 --- a/docs/tutorials/single_session.rst +++ b/docs/tutorials/single_session.rst @@ -1,3 +1,169 @@ Converting a Single Session -======================================= -Coming soon... +=========================== + +As a researcher, you’ve just completed an experimental session and you’d like to convert your data to NWB right away. + +Upon launching the GUIDE, you'll begin on the Conversions page. If you’re opening the application for the first time, there should be no pipelines listed on this page. + +.. figure:: ../assets/tutorials/home-page.png + :align: center + :alt: Home page + +Press the **Create a new conversion pipeline** button to start the conversion process. + +Project Structure +----------------- + +Project Setup +^^^^^^^^^^^^^ + +The Project Setup page will have you define two pieces of information about your pipeline: the **name** and, optionally, the **output location** for your NWB files. + +.. note:: + Choosing a good output location is important for two reasons, namely **conversion speed** and **disk space**. + + 1. SSDs will be much faster than HDDs. We’d recommend moving the output location to an SSD if available. + 2. If you don’t have much disk space available on your main drive, we recommend changing the output location to a drive that has ample space. + + +You’ll notice that the name property has a red asterisk next to it, which identifies it as a required property. + +.. figure:: ../assets/tutorials/single/info-page.png + :align: center + :alt: Project Setup page with no name (invalid) + + +After specifying a unique project name, the colored background and error message will disappear, allowing you to advance to the next page. + +.. figure:: ../assets/tutorials/single/valid-name.png + :align: center + :alt: Project Setup page with valid name + +Workflow Configuration +^^^^^^^^^^^^^^^^^^^^^^ +On this page, you’ll specify the type of **workflow** you’d like to follow for this conversion pipeline. + +Since this is a single-session workflow, you’ll need to specify a **Subject ID** and **Session ID** to identify the data you’ll be converting. + +.. figure:: ../assets/tutorials/single/workflow-page.png + :align: center + :alt: Workflow page + +Additionally, we’ll turn off the option to upload to the DANDI Archive and approach this in a later tutorial. + +Data Formats +^^^^^^^^^^^^ +Next, you’ll specify the data formats you’re working with on the Data Formats page. The GUIDE supports 40+ total neurophysiology formats. A full registry of available formats is available :doc:`here `. + +.. figure:: ../assets/tutorials/single/formats-page.png + :align: center + :alt: Date Formats page + +The tutorial we're working with uses the SpikeGLX and Phy formats, a common output for NeuroPixel recordings and subsequent spike sorting. To specify that your pipeline will handle these files, you’ll press the “Add Format” button. + +.. figure:: ../assets/tutorials/single/format-options.png + :align: center + :alt: Format pop-up on the Data Formats page + +Then, select the relevant formats—in this case, **SpikeGLX Recording** and **Phy Sorting**—from the pop-up list. Use the search bar to filter for the format you need. + + +.. figure:: ../assets/tutorials/single/search-behavior.png + :align: center + :alt: Searching for SpikeGLX in the format pop-up + +The selected formats will then display above the button. + + +.. figure:: ../assets/tutorials/single/interface-added.png + :align: center + :alt: Data Formats page with SpikeGLX Recording added to the list + +Advance to the next page when you have **SpikeGLX Recording** and **Phy Sorting** selected. + +.. figure:: ../assets/tutorials/single/all-interfaces-added.png + :align: center + :alt: Data Formats page with both SpikeGLX Recording and Phy Sorting added to the list + +Data Entry +----------- + +Source Data Information +^^^^^^^^^^^^^^^^^^^^^^^ +On this page, specify the relevant **.bin** (Spikeglx) file and **phy** folder so that the GUIDE can find this source data to complete the conversion. + +As discussed in the :doc:`Dataset Generation ` tutorial, these can be found in the `~/NWB_GUIDE/test-data/data` directory. + +You can either click the file selector to navigate to the file or drag-and-drop into the GUIDE from your file navigator. + +.. figure:: ../assets/tutorials/single/sourcedata-page-specified.png + :align: center + :alt: Source Data page with source locations specified + + +Session Metadata +^^^^^^^^^^^^^^^^ +The file metadata page is a great opportunity to add rich annotations to the file, which will be read by anyone reusing your data in the future! + +The Session Start Time in the General Metadata section is already specified because this field was automatically extracted from the SpikeGLX source data. + +.. figure:: ../assets/tutorials/single/metadata-nwbfile.png + :align: center + :alt: Metadata page with invalid Subject information + + +However, we still need to add the Subject information—as noted by the red accents around that item. Let’s say that our subject is a male mouse with an age of P30D. + +.. figure:: ../assets/tutorials/single/metadata-subject-complete.png + :align: center + :alt: Metadata page with valid Subject information + + The status of the Subject information will update in real-time as you fill out the form. + + +This dataset will also have **Ecephys** metadata extracted from the SpikeGLX source data. + +.. figure:: ../assets/tutorials/single/metadata-ecephys.png + :align: center + :alt: Ecephys metadata extracted from the SpikeGLX source data + + +Let's leave this as-is and advance to the next page. + +The next step generates a preview file and displays real-time progress throughout the conversion process. + +File Conversion +--------------- + +Inspector Report +^^^^^^^^^^^^^^^^ + +The Inspector Report page allows you to validate the preview file against the latest Best Practices and make suggestions to improve the content or representations. + +.. figure:: ../assets/tutorials/single/inspect-page.png + :align: center + :alt: NWB Inspector report + + + +Conversion Preview +^^^^^^^^^^^^^^^^^^ +On the Conversion Preview, Neurosift allows you to explore the structure of the NWB file and ensure the packaged data matches your expectations. + + +.. figure:: ../assets/tutorials/single/preview-page.png + :align: center + :alt: Neurosift preview visualization + +Advancing from this page will trigger the full conversion of your data to the NWB format, a process that may take some time depending on the dataset size. + +Conversion Review +^^^^^^^^^^^^^^^^^ + +Congratulations on finishing your first conversion of neurophysiology files using the NWB GUIDE! + +.. figure:: ../assets/tutorials/single/conversion-results-page.png + :align: center + :alt: Conversion results page with a list of converted files + +This was a straightforward workflow with only a single session... But what if you have multiple sessions to convert? diff --git a/tests/e2e.test.ts b/tests/e2e.test.ts deleted file mode 100644 index 038882485..000000000 --- a/tests/e2e.test.ts +++ /dev/null @@ -1,543 +0,0 @@ -import { beforeAll, describe, expect, test } from 'vitest' -import { connect, sleep } from './puppeteer' - -import { mkdirSync, existsSync, rmSync } from 'node:fs' -import { join, dirname } from 'node:path' -import { fileURLToPath } from 'node:url' -import { homedir } from 'node:os' - -import paths from "../paths.config.json" assert { type: "json" }; -import { ScreenshotOptions } from 'puppeteer' - -// ------------------------------------------------------------------ -// ------------------------ Path Definitions ------------------------ -// ------------------------------------------------------------------ - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const screenshotPath = join(__dirname, '..', 'docs', 'tutorials', 'screenshots') -const guideRootPath = join(homedir(), paths.root) -const testRootPath = join(guideRootPath, '.test') -const testDataRootPath = join(testRootPath, 'test-data') -const testDataPath = join(testDataRootPath, 'data') -const testDatasetPath = join(testDataRootPath, 'dataset') - -const windowDims = { - width: 1280, - height: 800 -} - -const alwaysDelete = [ - join(testRootPath, 'pipelines'), - join(testRootPath, 'conversions'), - join(testRootPath, 'preview'), - join(testRootPath, 'config.json') -] - - -// ----------------------------------------------------------------------- -// ------------------------ Configuration Options ------------------------ -// ----------------------------------------------------------------------- - -const testInterfaceInfo = { - common: { - SpikeGLXRecordingInterface: { - id: 'SpikeGLX Recording', - }, - PhySortingInterface: { - id: 'Phy Sorting' - } - }, - multi: { - SpikeGLXRecordingInterface: { - format: '{subject_id}/{subject_id}_{session_id}/{subject_id}_{session_id}_g0/{subject_id}_{session_id}_g0_imec0/{subject_id}_{session_id}_g0_t0.imec0.ap.bin' - }, - PhySortingInterface: { - format: '{subject_id}/{subject_id}_{session_id}/{subject_id}_{session_id}_phy' - } - }, - single: { - SpikeGLXRecordingInterface: { - file_path: join(testDataPath, 'spikeglx', 'Session1_g0', 'Session1_g0_imec0', 'Session1_g0_t0.imec0.ap.bin') - }, - PhySortingInterface: { - folder_path: join(testDataPath, 'phy') - } - } -} - -const subjectInfo = { - sex: 'M', - species: 'Mus musculus', - age: 'P30D' -} - -// const regenerateTestData = !existsSync(testDataRootPath) || false // Generate only if doesn't exist -const regenerateTestData = true // Force regeneration - -const dandiInfo = { - id: '212750', - token: process.env.DANDI_STAGING_API_KEY -} - -// ------------------------------------------------------- -// ------------------------ Tests ------------------------ -// ------------------------------------------------------- - -const skipUpload = true // dandiInfo.token ? false : true - -if (skipUpload) console.log('No DANDI API key provided. Will skip upload step...') - -beforeAll(() => { - - if (regenerateTestData) { - if (existsSync(testDataRootPath)) rmSync(testDataRootPath, { recursive: true }) - } - - alwaysDelete.forEach(path => existsSync(path) ? rmSync(path, { recursive: true }) : '') - - if (existsSync(screenshotPath)) rmSync(screenshotPath, { recursive: true }) - mkdirSync(screenshotPath, { recursive: true }) -}) - -describe('E2E Test', () => { - - const references = connect() - - const takeScreenshot = async (label, delay = 0, options: ScreenshotOptions = { fullPage: true }) => { - if (delay) await sleep(delay) - - const pathToScreenshot = join(screenshotPath, `${label}.png`) - - if (existsSync(pathToScreenshot)) return console.error(`Screenshot already exists: ${pathToScreenshot}`) - - await references.page.screenshot({ path: pathToScreenshot, ...options }); - } - - const evaluate = async (...args) => await references.page.evaluate(...args) - - const toNextPage = async (path?: null | string) => { - const pageId = await evaluate(async () => { - const dashboard = document.querySelector('nwb-dashboard') - await dashboard.page.save() // Ensure always saved - await dashboard.next() // Advance one page - return dashboard.page.info.id - }).catch((e) => { - console.error('ERROR', e) - expect(path).toBe(null) - }) - - if (path) expect(pageId).toBe(`//${path}`) - - return pageId - - } - - const toHome = async () => { - const pageId = await evaluate(async () => { - const dashboard = document.querySelector('nwb-dashboard') - await dashboard.page.to('/') - return dashboard.page.info.id - }) - - expect(pageId).toBe('/') // Ensure you are on the home page - } - - - - test('Ensure number of test pipelines starts at zero', async () => { - - await sleep(500) // Wait for full notification to render - const nPipelines = await evaluate(() => document.getElementById('guided-div-resume-progress-cards').children.length) - await takeScreenshot('home-page') - - // Assert no pipelines yet - expect(nPipelines).toBe(0) - }) - - describe('Manually run through the pipeline', async () => { - - const datasetTestFunction = regenerateTestData ? test : test.skip - - datasetTestFunction('Create tutorial dataset', async () => { - - const x = 250 // Sidebar size - const width = windowDims.width - x - - const screenshotClip = { - x, - y: 0, - width, - height: 220 - } - - - await evaluate(async () => { - - // Transition to settings page - const dashboard = document.querySelector('nwb-dashboard') - dashboard.sidebar.select('settings') - - // Generate test data - const page = dashboard.page - page.deleteTestData() - }) - - await takeScreenshot('dataset-creation', 300, { clip: screenshotClip }) - - const outputLocation = await evaluate(async () => { - const dashboard = document.querySelector('nwb-dashboard') - const page = dashboard.page - const outputLocation = await page.generateTestData() - page.requestUpdate() - return outputLocation - }) - - // Take image after dataset generation - await takeScreenshot('dataset-created', 500, { clip: screenshotClip }) - - expect(existsSync(outputLocation)).toBe(true) - - }) - - test('Create new pipeline by specifying a name', async () => { - - // Ensure you are on the home page - let pageId = await evaluate(() => { - const dashboard = document.querySelector('nwb-dashboard') - dashboard.sidebar.select('/') - return dashboard.page.info.id - }) - - expect(pageId).toBe('/') - - // Advance to instructions page - await toNextPage('start') - - await takeScreenshot('intro-page', 300) - - // Advance to general information page - await toNextPage('details') - - await takeScreenshot('info-page', 300) - - - // Fail to advance without name - await toNextPage('details') - - await takeScreenshot('fail-name', 500) - - // Fill in name of the test pipeline - await evaluate(() => { - const dashboard = document.querySelector('nwb-dashboard') - const page = dashboard.page - page.dismiss() // Dismiss all internal notifications - - const nameInput = page.form.getFormElement(['name']) - nameInput.updateData('My Test Pipeline') - }) - - await takeScreenshot('valid-name', 300) - - // Advance to formats page - await toNextPage('workflow') - - }) - - test('View the pre-form workflow page', async () => { - - await references.page.evaluate(() => { - const dashboard = document.querySelector('nwb-dashboard') - const page = dashboard.page - - const subjectId = page.form.getFormElement(['subject_id']) - subjectId.updateData('subject1') - - const sessionId = page.form.getFormElement(['session_id']) - sessionId.updateData('session1') - }) - - await takeScreenshot('workflow-page', 300) - - - await toNextPage('structure') - }) - - test('Specify data formats', async () => { - - await takeScreenshot('formats-page', 300) - - await evaluate(() => { - const dashboard = document.querySelector('nwb-dashboard') - const page = dashboard.page - page.addButton.onClick() - }) - - await takeScreenshot('format-options', 1000) - - await evaluate(() => { - const dashboard = document.querySelector('nwb-dashboard') - const page = dashboard.page - page.search.value = 'SpikeGLX' - }) - - await takeScreenshot('search-behavior') - - await evaluate((interfaces) => { - const dashboard = document.querySelector('nwb-dashboard') - const page = dashboard.page - const [name, info] = Object.entries(interfaces)[0] - page.list.add({ key: info.id, value: name }); - page.searchModal.toggle(false); - }, testInterfaceInfo.common) - - await takeScreenshot('interface-added', 1000) - - await evaluate((interfaces) => { - const dashboard = document.querySelector('nwb-dashboard') - const page = dashboard.page - Object.entries(interfaces).slice(1).forEach(([ name, info ]) => page.list.add({ key: info.id, value: name })) - }, testInterfaceInfo.common) - - await takeScreenshot('all-interfaces-added') - - // await toNextPage('locate') - // await toNextPage('subjects') - await toNextPage('sourcedata') - - }) - - // NOTE: Locate data is skipped in single session mode - test.skip('Locate all your source data programmatically', async () => { - - await evaluate(async () => { - const dashboard = document.querySelector('nwb-dashboard') - const page = dashboard.page - Object.values(page.form.accordions).forEach(accordion => accordion.toggle(true)) - }, testInterfaceInfo.multi) - - await takeScreenshot('pathexpansion-page') - - // Fill out the path expansion information - await evaluate((interfaceInfo, basePath) => { - const dashboard = document.querySelector('nwb-dashboard') - const form = dashboard.page.form - - Object.entries(interfaceInfo).forEach(([name, info]) => { - const baseInput = form.getFormElement([name, 'base_directory']) - baseInput.updateData(basePath) - - const formatInput = form.getFormElement([name, 'format_string_path']) - formatInput.updateData(info.format) - }) - - dashboard.main.querySelector('main > section').scrollTop = 200 - - }, - testInterfaceInfo.multi, - testDatasetPath - ) - - - await takeScreenshot('pathexpansion-completed', 300) - - await toNextPage('subjects') - }) - - - // NOTE: Subject information is skipped in single session mode - test.skip('Provide subject information', async () => { - - await takeScreenshot('subject-page', 300) - - - // Set invalid age - await evaluate(async () => { - const dashboard = document.querySelector('nwb-dashboard') - const table = dashboard.page.table - - const data = { ...table.data } - data[Object.keys(data)[0]].age = '30' - table.data = data - }) - - await takeScreenshot('subject-invalid', 600) - - await toNextPage(null) - - await takeScreenshot('subject-error', 500) - - await evaluate(() => { - const dashboard = document.querySelector('nwb-dashboard') - - const page = dashboard.page - page.dismiss() - - const table = page.table - - const data = { ...table.data } - - for (let name in data) { - data[name] = { ...data[name], ...subjectInfo } - } - - table.data = data // This changes the render but not the update flag - - }) - - await takeScreenshot('subject-complete', 500) - - await toNextPage('sourcedata') - - }) - - // NOTE: This isn't pre-filled in single session mode - test.skip('Review source data information', async () => { - - await takeScreenshot('sourcedata-page', 100) - await toNextPage('metadata') - - }) - - test('Specify source data information', async () => { - - await references.page.evaluate(async () => { - const dashboard = document.querySelector('nwb-dashboard') - const page = dashboard.page - Object.values(page.forms[0].form.accordions).forEach(accordion => accordion.toggle(true)) - }) - - await takeScreenshot('sourcedata-page', 100) - - await references.page.evaluate(({ single, common }) => { - const dashboard = document.querySelector('nwb-dashboard') - const page = dashboard.page - - Object.entries(common).forEach(([name, info]) => { - const form = page.forms[0].form.forms[info.id] - - const interfaceInfo = single[name] - for (let key in single[name]) { - const input = form.getFormElement([ key ]) - input.updateData(interfaceInfo[key]) - } - }) - }, testInterfaceInfo) - - await takeScreenshot('sourcedata-page-specified', 100) - - - await toNextPage('metadata') - - }) - - test('Review metadata', async () => { - - await takeScreenshot('metadata-page', 100) - - await evaluate(() => { - const dashboard = document.querySelector('nwb-dashboard') - const page = dashboard.page - page.forms[0].form.accordions["Subject"].toggle(true) - }) - - // Update for single session - await evaluate((subjectInfo) => { - const dashboard = document.querySelector('nwb-dashboard') - const page = dashboard.page - const form = page.forms[0].form.forms['Subject'] - - for (let key in subjectInfo) { - const input = form.getFormElement([ key ]) - input.updateData(subjectInfo[key]) - } - }, subjectInfo) - - await takeScreenshot('metadata-open', 100) - - await toNextPage('inspect') - - }) // Wait for conversion preview to complete - - test('Review NWB Inspector output', async () => { - - await takeScreenshot('inspect-page', 2000) // Finish file inspection - await toNextPage('preview') - - }) - - test('Review Neurosift visualization', async () => { - await takeScreenshot('preview-page', 1000) // Finish loading Neurosift - await toNextPage('conversion') - }) - - test('View the conversion results', async () => { - - await takeScreenshot('conversion-results-page', 1000) - await toNextPage('upload') - if (skipUpload) await toHome() - - }) - - const uploadDescribe = skipUpload ? describe.skip: describe - - uploadDescribe('Upload to DANDI', () => { - - test('Upload pipeline output to DANDI', async () => { - - await takeScreenshot('upload-page', 100) - - await evaluate(async () => { - const dashboard = document.querySelector('nwb-dashboard') - const page = dashboard.page - await page.rendered - page.click() // Ensure page is clicked (otherwise, Electron crashes after DANDI upload after this...) - }) - - await takeScreenshot('upload-page-api-tokens', 100) - - await evaluate(async (dandiAPIToken) => { - const dashboard = document.querySelector('nwb-dashboard') - const page = dashboard.page - const modal = page.globalModal - const stagingKeyInput = modal.form.getFormElement([ 'staging_api_key' ]) - stagingKeyInput.updateData(dandiAPIToken) - }, dandiInfo.token) - - await takeScreenshot('upload-page-api-token-added', 100) - - await evaluate(async (dandisetId) => { - const dashboard = document.querySelector('nwb-dashboard') - const page = dashboard.page - const modal = page.globalModal - await modal.footer.onClick() // Validate and submit value - const idInput = page.form.getFormElement(["dandiset"]) - idInput.updateData(dandisetId) - }, dandiInfo.id) - - await takeScreenshot('upload-page-with-id', 100) - - await sleep(500) // Wait for input status to update - - await toNextPage('review') - - }) // Wait for upload to finish (~2min on M2) - - - test('Review upload results', async () => { - - await takeScreenshot('review-page', 1000) - await toNextPage() - - }) - - }) - - test('Ensure there is one completed pipeline', async () => { - await takeScreenshot('home-page-complete', 100) - const nPipelines = await evaluate(() => document.getElementById('guided-div-resume-progress-cards').children.length) - expect(nPipelines).toBe(1) - }) - - }) - -}) diff --git a/tests/e2e/config.ts b/tests/e2e/config.ts new file mode 100644 index 000000000..f7a510e62 --- /dev/null +++ b/tests/e2e/config.ts @@ -0,0 +1,88 @@ +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { homedir } from 'node:os' +import { existsSync } from 'node:fs' + +import paths from "../../paths.config.json" assert { type: "json" }; +import { connect } from '../puppeteer'; + +// ------------------------------------------------------------------ +// ------------------------ Path Definitions ------------------------ +// ------------------------------------------------------------------ + +const __dirname = dirname(fileURLToPath(import.meta.url)); +export const screenshotPath = join(__dirname, '..', '..', 'docs', 'assets', 'tutorials') +const guideRootPath = join(homedir(), paths.root) +const testRootPath = join(guideRootPath, '.test') +export const testDataRootPath = join(testRootPath, 'test-data') +const testDataPath = join(testDataRootPath, 'data') +export const testDatasetPath = join(testDataRootPath, 'dataset') + +export const windowDims = { + width: 1280, + height: 800 +} + +export const alwaysDelete = [ + join(testRootPath, 'pipelines'), + join(testRootPath, 'conversions'), + join(testRootPath, 'preview'), + join(testRootPath, 'config.json') +] + + +// ----------------------------------------------------------------------- +// ------------------------ Configuration Options ------------------------ +// ----------------------------------------------------------------------- + +export const testInterfaceInfo = { + common: { + SpikeGLXRecordingInterface: { + id: 'SpikeGLX Recording', + }, + PhySortingInterface: { + id: 'Phy Sorting' + } + }, + multi: { + SpikeGLXRecordingInterface: { + format: '{subject_id}/{subject_id}_{session_id}/{subject_id}_{session_id}_g0/{subject_id}_{session_id}_g0_imec0/{subject_id}_{session_id}_g0_t0.imec0.ap.bin' + }, + PhySortingInterface: { + format: '{subject_id}/{subject_id}_{session_id}/{subject_id}_{session_id}_phy' + } + }, + single: { + SpikeGLXRecordingInterface: { + file_path: join(testDataPath, 'spikeglx', 'Session1_g0', 'Session1_g0_imec0', 'Session1_g0_t0.imec0.ap.bin') + }, + PhySortingInterface: { + folder_path: join(testDataPath, 'phy') + } + } +} + +export const subjectInfo = { + sex: 'M', + species: 'Mus musculus', + age: 'P30D' +} + +// export const regenerateTestData = !existsSync(testDataRootPath) || false // Generate only if doesn't exist +export const regenerateTestData = true // Force regeneration + +export const dandiInfo = { + id: '212750', + token: process.env.DANDI_STAGING_API_KEY +} + +// ------------------------------------------------------- +// ------------------------ Tests ------------------------ +// ------------------------------------------------------- + +export const publish = dandiInfo.token ? true : false + +if (!publish) console.log('No DANDI API key provided. Will skip dataset publication step...') + + +export const references = connect() diff --git a/tests/e2e/e2e.test.ts b/tests/e2e/e2e.test.ts new file mode 100644 index 000000000..27f43d4e5 --- /dev/null +++ b/tests/e2e/e2e.test.ts @@ -0,0 +1,115 @@ +import { beforeAll, describe, expect, test } from 'vitest' +import { sleep } from '../puppeteer' + +import { mkdirSync, existsSync, rmSync } from 'node:fs' +import { join } from 'node:path' + +import * as config from './config' +import runWorkflow from './workflow' +import { evaluate, takeScreenshot } from './utils' + +beforeAll(() => { + + if (config.regenerateTestData) { + if (existsSync(config.testDataRootPath)) rmSync(config.testDataRootPath, { recursive: true }) + } + + config.alwaysDelete.forEach(path => existsSync(path) ? rmSync(path, { recursive: true }) : '') + + if (existsSync(config.screenshotPath)) rmSync(config.screenshotPath, { recursive: true }) + mkdirSync(config.screenshotPath, { recursive: true }) +}) + +describe('E2E Test', () => { + + // NOTE: This is where you should be connecting... + + test('Ensure number of test pipelines starts at zero', async () => { + + await sleep(500) // Wait for full notification to render + const nPipelines = await evaluate(() => document.getElementById('guided-div-resume-progress-cards').children.length) + await takeScreenshot('home-page') + + // Assert no pipelines yet + expect(nPipelines).toBe(0) + }) + + describe('Manually run through the pipeline', async () => { + + const datasetTestFunction = config.regenerateTestData ? test : test.skip + + datasetTestFunction('Create tutorial dataset', async () => { + + const x = 250 // Sidebar size + const width = config.windowDims.width - x + + const screenshotClip = { + x, + y: 0, + width, + height: 220 + } + + + await evaluate(async () => { + + // Transition to settings page + const dashboard = document.querySelector('nwb-dashboard') + dashboard.sidebar.select('settings') + + // Generate test data + const page = dashboard.page + page.deleteTestData() + }) + + await takeScreenshot('dataset-creation', 300, { clip: screenshotClip }) + + const outputLocation = await evaluate(async () => { + const dashboard = document.querySelector('nwb-dashboard') + const page = dashboard.page + const outputLocation = await page.generateTestData() + page.requestUpdate() + return outputLocation + }) + + // Take image after dataset generation + await takeScreenshot('dataset-created', 500, { clip: screenshotClip }) + + expect(existsSync(outputLocation)).toBe(true) + + // Navigate back to the home page + let pageId = await evaluate(() => { + const dashboard = document.querySelector('nwb-dashboard') + dashboard.sidebar.select('/') + return dashboard.page.info.id + }) + + expect(pageId).toBe('/') + }) + + describe('Complete a single-session workflow', async () => { + const subdirectory = 'single' + await runWorkflow('Single Session Workflow', { upload_to_dandi: false, multiple_sessions: false, subject_id: 'sub1', session_id: 'ses1' }, subdirectory) + + test('Ensure there is one completed pipeline', async () => { + await takeScreenshot(join(subdirectory, 'home-page-complete'), 100) + const nPipelines = await evaluate(() => document.getElementById('guided-div-resume-progress-cards').children.length) + expect(nPipelines).toBe(1) + }) + }) + + describe.skip('Complete a multi-session workflow', async () => { + const subdirectory = 'multiple' + await runWorkflow('Multi Session Workflow', { upload_to_dandi: false, multiple_sessions: true, locate_data: true }, subdirectory) + + test('Ensure there are two completed pipelines', async () => { + await takeScreenshot(join(subdirectory, 'home-page-complete'), 100) + const nPipelines = await evaluate(() => document.getElementById('guided-div-resume-progress-cards').children.length) + expect(nPipelines).toBe(2) + }) + }) + + + }) + +}) diff --git a/tests/e2e/utils.ts b/tests/e2e/utils.ts new file mode 100644 index 000000000..2f268c84d --- /dev/null +++ b/tests/e2e/utils.ts @@ -0,0 +1,56 @@ +import { expect } from "vitest" +import { ScreenshotOptions } from 'puppeteer' + +import { sleep } from '../puppeteer' + +import { mkdirSync, existsSync } from 'node:fs' +import { join, sep } from 'node:path' + +import { references, screenshotPath } from "./config" + +export const takeScreenshot = async (relativePath, delay = 0, options: ScreenshotOptions = { fullPage: true }) => { + if (delay) await sleep(delay) + + const splitPath = relativePath.split(sep) + const subdirectory = splitPath.slice(0, -1).join(sep) + const label = splitPath.slice(-1)[0] + const fullScreenshotPath = join(screenshotPath, subdirectory) + + if (!existsSync(fullScreenshotPath)) mkdirSync(fullScreenshotPath, { recursive: true }) + + const pathToScreenshot = join(fullScreenshotPath, `${label}.png`) + + if (existsSync(pathToScreenshot)) return console.error(`Screenshot already exists: ${pathToScreenshot}`) + + + await references.page.screenshot({ path: pathToScreenshot, ...options }); + } + + export const evaluate = async (...args) => await references.page.evaluate(...args) + + export const toNextPage = async (path?: null | string) => { + const pageId = await evaluate(async () => { + const dashboard = document.querySelector('nwb-dashboard') + await dashboard.page.save() // Ensure always saved + await dashboard.next() // Advance one page + return dashboard.page.info.id + }).catch((e) => { + console.error('ERROR', e) + expect(path).toBe(null) + }) + + if (path) expect(pageId).toBe(`//${path}`) + + return pageId + + } + + export const toHome = async () => { + const pageId = await evaluate(async () => { + const dashboard = document.querySelector('nwb-dashboard') + await dashboard.page.to('/') + return dashboard.page.info.id + }) + + expect(pageId).toBe('/') // Ensure you are on the home page + } diff --git a/tests/e2e/workflow.ts b/tests/e2e/workflow.ts new file mode 100644 index 000000000..fc0f84381 --- /dev/null +++ b/tests/e2e/workflow.ts @@ -0,0 +1,381 @@ +import { describe, test } from "vitest" + +import { sleep } from '../puppeteer' + +import { join } from 'node:path' +import { evaluate, takeScreenshot, toNextPage } from "./utils" +import { dandiInfo, subjectInfo, testDatasetPath, testInterfaceInfo } from "./config" + + +export default async function runWorkflow (name, workflow, identifier) { + + const willLocateData = workflow.multiple_sessions && workflow.locate_data + const willProvideSubjectInfo = workflow.multiple_sessions + + test('Create new pipeline by specifying a name', async () => { + + // Advance to instructions page + await toNextPage('start') + + await takeScreenshot(join(identifier, 'intro-page'), 300) + + // Advance to general information page + await toNextPage('details') + + await takeScreenshot(join(identifier, 'info-page'), 300) + + // Fail to advance without name + await toNextPage('details') + + await takeScreenshot(join(identifier, 'fail-name'), 500) + + // Fill in name of the test pipeline + await evaluate(({ name }) => { + const dashboard = document.querySelector('nwb-dashboard') + const page = dashboard.page + page.dismiss() // Dismiss all internal notifications + + const nameInput = page.form.getFormElement(['name']) + nameInput.updateData(name) + }, { name }) + + await takeScreenshot(join(identifier, 'valid-name'), 600) // Ensure name error disappears + + // Advance to formats page + await toNextPage('workflow') + + }) + + test('View the pre-form workflow page', async () => { + + await evaluate(( workflow ) => { + const dashboard = document.querySelector('nwb-dashboard') + const page = dashboard.page + + for (let key in workflow) { + const input = page.form.getFormElement([ key ]) + input.updateData(workflow[key]) + } + + page.form.requestUpdate() // Ensure the form is updated visually + + }, workflow) + + await takeScreenshot(join(identifier, 'workflow-page'), 300) + + + await toNextPage('structure') + }) + + test('Specify data formats', async () => { + + await takeScreenshot(join(identifier, 'formats-page'), 300) + + await evaluate(() => { + const dashboard = document.querySelector('nwb-dashboard') + const page = dashboard.page + page.addButton.onClick() + }) + + await takeScreenshot(join(identifier, 'format-options'), 1000) + + await evaluate(() => { + const dashboard = document.querySelector('nwb-dashboard') + const page = dashboard.page + page.search.value = 'SpikeGLX' + }) + + await takeScreenshot(join(identifier, 'search-behavior')) + + await evaluate((interfaces) => { + const dashboard = document.querySelector('nwb-dashboard') + const page = dashboard.page + const [name, info] = Object.entries(interfaces)[0] + page.list.add({ key: info.id, value: name }); + page.searchModal.toggle(false); + }, testInterfaceInfo.common) + + await takeScreenshot(join(identifier, 'interface-added'), 1000) + + await evaluate((interfaces) => { + const dashboard = document.querySelector('nwb-dashboard') + const page = dashboard.page + Object.entries(interfaces).slice(1).forEach(([name, info]) => page.list.add({ key: info.id, value: name })) + }, testInterfaceInfo.common) + + await takeScreenshot(join(identifier, 'all-interfaces-added')) + + if (willLocateData) await toNextPage('locate') + else if (willProvideSubjectInfo) await toNextPage('subjects') + else await toNextPage('sourcedata') + + }) + + const locateDataTest = willLocateData ? test : test.skip + + // NOTE: Locate data is skipped in single session mode + locateDataTest('Locate all your source data programmatically', async () => { + + await evaluate(async () => { + const dashboard = document.querySelector('nwb-dashboard') + const page = dashboard.page + Object.values(page.form.accordions).forEach(accordion => accordion.toggle(true)) + }, testInterfaceInfo.multi) + + await takeScreenshot(join(identifier, 'pathexpansion-page')) + + // Fill out the path expansion information + await evaluate(({ multi, common }, basePath) => { + const dashboard = document.querySelector('nwb-dashboard') + const form = dashboard.page.form + + Object.entries(common).forEach(([ name, info ]) => { + + const id = info.id + const baseInput = form.getFormElement([id, 'base_directory']) + baseInput.updateData(basePath) + + const { format } = multi[name] + + const formatInput = form.getFormElement([id, 'format_string_path']) + formatInput.updateData(format) + }) + + dashboard.main.querySelector('main > section').scrollTop = 200 + + }, + testInterfaceInfo, + testDatasetPath + ) + + + await takeScreenshot(join(identifier, 'pathexpansion-completed'), 300) + + await toNextPage('subjects') + + }) + + + const subjectTableTest = willProvideSubjectInfo ? test : test.skip + + // NOTE: Subject information is skipped in single session mode + subjectTableTest('Provide subject information', async () => { + + await takeScreenshot(join(identifier, 'subject-page'), 300) + + // Set invalid age + await evaluate(async () => { + const dashboard = document.querySelector('nwb-dashboard') + const table = dashboard.page.table + + const data = { ...table.data } + data[Object.keys(data)[0]].age = '30' + table.data = data + }) + + await takeScreenshot(join(identifier, 'subject-invalid'), 600) + + await toNextPage(null) + + await takeScreenshot(join(identifier, 'subject-error'), 500) + + await evaluate(( subjectInfo ) => { + const dashboard = document.querySelector('nwb-dashboard') + + const page = dashboard.page + page.dismiss() + + const table = page.table + + const data = { ...table.data } + + + for (let name in data) { + data[name] = { ...data[name], ...subjectInfo } + } + + table.data = data // This changes the render but not the update flag + + }, subjectInfo) + + await takeScreenshot(join(identifier, 'subject-complete'), 500) + + await toNextPage('sourcedata') + + }) + + if (willLocateData) { + + test('Review source data information', async () => { + + await takeScreenshot(join(identifier, 'sourcedata-page'), 100) + await toNextPage('metadata') + + }) + + } + + else { + + test('Specify source data information', async () => { + + await evaluate(async () => { + const dashboard = document.querySelector('nwb-dashboard') + const page = dashboard.page + Object.values(page.forms[0].form.accordions).forEach(accordion => accordion.toggle(true)) + }) + + await takeScreenshot(join(identifier, 'sourcedata-page'), 100) + + await evaluate(({ single, common }) => { + const dashboard = document.querySelector('nwb-dashboard') + const page = dashboard.page + + Object.entries(common).forEach(([name, info]) => { + const form = page.forms[0].form.forms[info.id] + + const interfaceInfo = single[name] + for (let key in single[name]) { + const input = form.getFormElement([key]) + input.updateData(interfaceInfo[key]) + } + }) + }, testInterfaceInfo) + + await takeScreenshot(join(identifier, 'sourcedata-page-specified'), 100) + + + await toNextPage('metadata') + + }) + + } + + test('Review metadata', async () => { + + await takeScreenshot(join(identifier, 'metadata-page'), 300) // Ensures no user-select highlight + + await evaluate(() => { + const dashboard = document.querySelector('nwb-dashboard') + const page = dashboard.page + const firstSessionForm = page.forms[0].form + firstSessionForm.accordions["NWBFile"].toggle(true) + window.getSelection().empty() // Remove annoying user-select highlight + }) + + await takeScreenshot(join(identifier, 'metadata-nwbfile'), 100) + + await evaluate(() => { + const dashboard = document.querySelector('nwb-dashboard') + const page = dashboard.page + const firstSessionForm = page.forms[0].form + firstSessionForm.accordions["Subject"].toggle(true) + firstSessionForm.accordions["NWBFile"].toggle(false) + }) + + if (!willProvideSubjectInfo) { + + // Update for single session + await evaluate((subjectInfo) => { + const dashboard = document.querySelector('nwb-dashboard') + const page = dashboard.page + const firstSessionForm = page.forms[0].form + const form = firstSessionForm.forms['Subject'] + + for (let key in subjectInfo) { + const input = form.getFormElement([key]) + input.updateData(subjectInfo[key]) + } + }, subjectInfo) + + } + + await takeScreenshot(join(identifier, 'metadata-subject-complete'), 100) + + await evaluate(() => { + const dashboard = document.querySelector('nwb-dashboard') + const page = dashboard.page + const firstSessionForm = page.forms[0].form + firstSessionForm.accordions["Ecephys"].toggle(true) + firstSessionForm.accordions["Subject"].toggle(false) + }) + + await takeScreenshot(join(identifier, 'metadata-ecephys'), 100) + + + await toNextPage('inspect') + + }) // Wait for conversion preview to complete + + test('Review NWB Inspector output', async () => { + + await takeScreenshot(join(identifier, 'inspect-page'), 2000) // Finish file inspection + await toNextPage('preview') + + }) + + test('Review Neurosift visualization', async () => { + await takeScreenshot(join(identifier, 'preview-page'), 1000) // Finish loading Neurosift + await toNextPage('conversion') + }) + + test('View the conversion results', async () => { + + await takeScreenshot(join(identifier, 'conversion-results-page'), 300) + if (workflow.upload_to_dandi) await toNextPage('upload') + else await toNextPage('') + }) + + const uploadDescribe = workflow.upload_to_dandi ? describe : describe.skip + + uploadDescribe('Upload to DANDI', () => { + + test('Upload pipeline output to DANDI', async () => { + + await takeScreenshot(join('dandi', 'upload-page'), 100) + + await evaluate(async () => { + const dashboard = document.querySelector('nwb-dashboard') + const page = dashboard.page + await page.rendered + page.click() // Ensure page is clicked (otherwise, Electron crashes after DANDI upload after this...) + }) + + await takeScreenshot(join('dandi', 'upload-page-api-tokens'), 100) + + await evaluate(async (dandiAPIToken) => { + const dashboard = document.querySelector('nwb-dashboard') + const page = dashboard.page + const modal = page.globalModal + const stagingKeyInput = modal.form.getFormElement(['staging_api_key']) + stagingKeyInput.updateData(dandiAPIToken) + }, dandiInfo.token) + + await takeScreenshot(join('dandi', 'upload-page-api-token-added'), 100) + + await evaluate(async (dandisetId) => { + const dashboard = document.querySelector('nwb-dashboard') + const page = dashboard.page + const modal = page.globalModal + await modal.footer.onClick() // Validate and submit value + const idInput = page.form.getFormElement(["dandiset"]) + idInput.updateData(dandisetId) + }, dandiInfo.id) + + await takeScreenshot(join('dandi', 'upload-page-with-id'), 100) + + await sleep(500) // Wait for input status to update + + await toNextPage('review') + + }) // Wait for upload to finish (~2min on M2) + + + test('Review upload results', async () => { + + await takeScreenshot(join('dandi', 'review-page'), 1000) + await toNextPage() + + }) + }) +}