diff --git a/e2e/playwright/flow-tests.spec.ts b/e2e/playwright/flow-tests.spec.ts index 260d7b770..fd558ddf1 100644 --- a/e2e/playwright/flow-tests.spec.ts +++ b/e2e/playwright/flow-tests.spec.ts @@ -1,5 +1,5 @@ import { test, expect, Page } from '@playwright/test' -import { makeTemplate, getUtils } from './test-utils' +import { makeTemplate, getUtils, doExport } from './test-utils' import waitOn from 'wait-on' import { roundOff, uuidv4 } from 'lib/utils' import { SaveSettingsPayload } from 'lib/settings/settingsTypes' @@ -3811,3 +3811,75 @@ test('Engine disconnect & reconnect in sketch mode', async ({ page }) => { page.getByRole('button', { name: 'Exit Sketch' }) ).not.toBeVisible() }) + +test('Successful export shows a success toast', async ({ page }) => { + // FYI this test doesn't work with only engine running locally + // And you will need to have the KittyCAD CLI installed + const u = await getUtils(page) + await page.addInitScript(async () => { + ;(window as any).playwrightSkipFilePicker = true + localStorage.setItem( + 'persistCode', + `const topAng = 25 +const bottomAng = 35 +const baseLen = 3.5 +const baseHeight = 1 +const totalHeightHalf = 2 +const armThick = 0.5 +const totalLen = 9.5 +const part001 = startSketchOn('-XZ') + |> startProfileAt([0, 0], %) + |> yLine(baseHeight, %) + |> xLine(baseLen, %) + |> angledLineToY({ + angle: topAng, + to: totalHeightHalf, + }, %, 'seg04') + |> xLineTo(totalLen, %, 'seg03') + |> yLine(-armThick, %, 'seg01') + |> angledLineThatIntersects({ + angle: HALF_TURN, + offset: -armThick, + intersectTag: 'seg04' + }, %) + |> angledLineToY([segAng('seg04', %) + 180, ZERO], %) + |> angledLineToY({ + angle: -bottomAng, + to: -totalHeightHalf - armThick, + }, %, 'seg02') + |> xLineTo(segEndX('seg03', %) + 0, %) + |> yLine(-segLen('seg01', %), %) + |> angledLineThatIntersects({ + angle: HALF_TURN, + offset: -armThick, + intersectTag: 'seg02' + }, %) + |> angledLineToY([segAng('seg02', %) + 180, -baseHeight], %) + |> xLineTo(ZERO, %) + |> close(%) + |> extrude(4, %)` + ) + }) + await page.setViewportSize({ width: 1200, height: 500 }) + await page.goto('/') + await u.waitForAuthSkipAppStart() + await u.openDebugPanel() + await u.expectCmdLog('[data-message-type="execution-done"]') + await u.waitForCmdReceive('extrude') + await page.waitForTimeout(1000) + await u.clearAndCloseDebugPanel() + + await doExport( + { + type: 'gltf', + storage: 'embedded', + presentation: 'pretty', + }, + page + ) + + // This is the main thing we're testing, + // We test the export functionality across all + // file types in snapshot-tests.spec.ts + await expect(page.getByText('Exported successfully')).toBeVisible() +}) diff --git a/e2e/playwright/snapshot-tests.spec.ts b/e2e/playwright/snapshot-tests.spec.ts index 949e35a25..8f32fb688 100644 --- a/e2e/playwright/snapshot-tests.spec.ts +++ b/e2e/playwright/snapshot-tests.spec.ts @@ -1,10 +1,10 @@ -import { test, expect, Download } from '@playwright/test' +import { test, expect } from '@playwright/test' import { secrets } from './secrets' -import { getUtils } from './test-utils' +import { Paths, doExport, getUtils } from './test-utils' import { Models } from '@kittycad/lib' import fsp from 'fs/promises' import { spawn } from 'child_process' -import { APP_NAME, KCL_DEFAULT_LENGTH } from 'lib/constants' +import { KCL_DEFAULT_LENGTH } from 'lib/constants' import JSZip from 'jszip' import path from 'path' import { TEST_SETTINGS, TEST_SETTINGS_KEY } from './storageStates' @@ -99,78 +99,6 @@ const part001 = startSketchOn('-XZ') await page.waitForTimeout(1000) await u.clearAndCloseDebugPanel() - interface Paths { - modelPath: string - imagePath: string - outputType: string - } - const doExport = async ( - output: Models['OutputFormat_type'] - ): Promise => { - await page.getByRole('button', { name: APP_NAME }).click() - await expect( - page.getByRole('button', { name: 'Export Part' }) - ).toBeVisible() - await page.getByRole('button', { name: 'Export Part' }).click() - await expect(page.getByTestId('command-bar')).toBeVisible() - - // Go through export via command bar - await page.getByRole('option', { name: output.type, exact: false }).click() - await page.locator('#arg-form').waitFor({ state: 'detached' }) - if ('storage' in output) { - await page.getByTestId('arg-name-storage').waitFor({ timeout: 1000 }) - await page.getByRole('button', { name: 'storage', exact: false }).click() - await page - .getByRole('option', { name: output.storage, exact: false }) - .click() - await page.locator('#arg-form').waitFor({ state: 'detached' }) - } - await expect(page.getByText('Confirm Export')).toBeVisible() - - const getPromiseAndResolve = () => { - let resolve: any = () => {} - const promise = new Promise((r) => { - resolve = r - }) - return [promise, resolve] - } - - const [downloadPromise1, downloadResolve1] = getPromiseAndResolve() - let downloadCnt = 0 - - page.on('download', async (download) => { - if (downloadCnt === 0) { - downloadResolve1(download) - } - downloadCnt++ - }) - await page.getByRole('button', { name: 'Submit command' }).click() - - // Handle download - const download = await downloadPromise1 - const downloadLocationer = (extra = '', isImage = false) => - `./e2e/playwright/export-snapshots/${output.type}-${ - 'storage' in output ? output.storage : '' - }${extra}.${isImage ? 'png' : output.type}` - const downloadLocation = downloadLocationer() - - await download.saveAs(downloadLocation) - - if (output.type === 'step') { - // stable timestamps for step files - const fileContents = await fsp.readFile(downloadLocation, 'utf-8') - const newFileContents = fileContents.replace( - /[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]+[0-9]+[0-9]\+[0-9]{2}:[0-9]{2}/g, - '1970-01-01T00:00:00.0+00:00' - ) - await fsp.writeFile(downloadLocation, newFileContents) - } - return { - modelPath: downloadLocation, - imagePath: downloadLocationer('', true), - outputType: output.type, - } - } const axisDirectionPair: Models['AxisDirectionPair_type'] = { axis: 'z', direction: 'positive', @@ -186,84 +114,114 @@ const part001 = startSketchOn('-XZ') // just note that only `type` and `storage` are used for selecting the drop downs is the app // the rest are only there to make typescript happy exportLocations.push( - await doExport({ - type: 'step', - coords: sysType, - }) + await doExport( + { + type: 'step', + coords: sysType, + }, + page + ) ) exportLocations.push( - await doExport({ - type: 'ply', - coords: sysType, - selection: { type: 'default_scene' }, - storage: 'ascii', - units: 'in', - }) + await doExport( + { + type: 'ply', + coords: sysType, + selection: { type: 'default_scene' }, + storage: 'ascii', + units: 'in', + }, + page + ) ) exportLocations.push( - await doExport({ - type: 'ply', - storage: 'binary_little_endian', - coords: sysType, - selection: { type: 'default_scene' }, - units: 'in', - }) + await doExport( + { + type: 'ply', + storage: 'binary_little_endian', + coords: sysType, + selection: { type: 'default_scene' }, + units: 'in', + }, + page + ) ) exportLocations.push( - await doExport({ - type: 'ply', - storage: 'binary_big_endian', - coords: sysType, - selection: { type: 'default_scene' }, - units: 'in', - }) + await doExport( + { + type: 'ply', + storage: 'binary_big_endian', + coords: sysType, + selection: { type: 'default_scene' }, + units: 'in', + }, + page + ) ) exportLocations.push( - await doExport({ - type: 'stl', - storage: 'ascii', - coords: sysType, - units: 'in', - selection: { type: 'default_scene' }, - }) + await doExport( + { + type: 'stl', + storage: 'ascii', + coords: sysType, + units: 'in', + selection: { type: 'default_scene' }, + }, + page + ) ) exportLocations.push( - await doExport({ - type: 'stl', - storage: 'binary', - coords: sysType, - units: 'in', - selection: { type: 'default_scene' }, - }) + await doExport( + { + type: 'stl', + storage: 'binary', + coords: sysType, + units: 'in', + selection: { type: 'default_scene' }, + }, + page + ) ) exportLocations.push( - await doExport({ - // obj seems to be a little flaky, times out tests sometimes - type: 'obj', - coords: sysType, - units: 'in', - }) + await doExport( + { + // obj seems to be a little flaky, times out tests sometimes + type: 'obj', + coords: sysType, + units: 'in', + }, + page + ) ) exportLocations.push( - await doExport({ - type: 'gltf', - storage: 'embedded', - presentation: 'pretty', - }) + await doExport( + { + type: 'gltf', + storage: 'embedded', + presentation: 'pretty', + }, + page + ) ) exportLocations.push( - await doExport({ - type: 'gltf', - storage: 'binary', - presentation: 'pretty', - }) + await doExport( + { + type: 'gltf', + storage: 'binary', + presentation: 'pretty', + }, + page + ) ) exportLocations.push( - await doExport({ - type: 'gltf', - storage: 'standard', - presentation: 'pretty', - }) + await doExport( + { + type: 'gltf', + storage: 'standard', + presentation: 'pretty', + }, + page + ) ) // close page to disconnect websocket since we can only have one open atm diff --git a/e2e/playwright/test-utils.ts b/e2e/playwright/test-utils.ts index fde96423a..578502426 100644 --- a/e2e/playwright/test-utils.ts +++ b/e2e/playwright/test-utils.ts @@ -1,9 +1,11 @@ -import { test, expect, Page } from '@playwright/test' +import { test, expect, Page, Download } from '@playwright/test' import { EngineCommand } from '../../src/lang/std/engineConnection' import fsp from 'fs/promises' import pixelMatch from 'pixelmatch' import { PNG } from 'pngjs' import { Protocol } from 'playwright-core/types/protocol' +import type { Models } from '@kittycad/lib' +import { APP_NAME } from 'lib/constants' async function waitForPageLoad(page: Page) { // wait for 'Loading stream...' spinner @@ -277,3 +279,77 @@ export const makeTemplate: ( ), } } + +export interface Paths { + modelPath: string + imagePath: string + outputType: string +} + +export const doExport = async ( + output: Models['OutputFormat_type'], + page: Page +): Promise => { + await page.getByRole('button', { name: APP_NAME }).click() + await expect(page.getByRole('button', { name: 'Export Part' })).toBeVisible() + await page.getByRole('button', { name: 'Export Part' }).click() + await expect(page.getByTestId('command-bar')).toBeVisible() + + // Go through export via command bar + await page.getByRole('option', { name: output.type, exact: false }).click() + await page.locator('#arg-form').waitFor({ state: 'detached' }) + if ('storage' in output) { + await page.getByTestId('arg-name-storage').waitFor({ timeout: 1000 }) + await page.getByRole('button', { name: 'storage', exact: false }).click() + await page + .getByRole('option', { name: output.storage, exact: false }) + .click() + await page.locator('#arg-form').waitFor({ state: 'detached' }) + } + await expect(page.getByText('Confirm Export')).toBeVisible() + + const getPromiseAndResolve = () => { + let resolve: any = () => {} + const promise = new Promise((r) => { + resolve = r + }) + return [promise, resolve] + } + + const [downloadPromise1, downloadResolve1] = getPromiseAndResolve() + let downloadCnt = 0 + + page.on('download', async (download) => { + if (downloadCnt === 0) { + downloadResolve1(download) + } + downloadCnt++ + }) + await page.getByRole('button', { name: 'Submit command' }).click() + + // Handle download + const download = await downloadPromise1 + const downloadLocationer = (extra = '', isImage = false) => + `./e2e/playwright/export-snapshots/${output.type}-${ + 'storage' in output ? output.storage : '' + }${extra}.${isImage ? 'png' : output.type}` + const downloadLocation = downloadLocationer() + + await download.saveAs(downloadLocation) + + if (output.type === 'step') { + // stable timestamps for step files + const fileContents = await fsp.readFile(downloadLocation, 'utf-8') + const newFileContents = fileContents.replace( + /[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]+[0-9]+[0-9]\+[0-9]{2}:[0-9]{2}/g, + '1970-01-01T00:00:00.0+00:00' + ) + await fsp.writeFile(downloadLocation, newFileContents) + } + + return { + modelPath: downloadLocation, + imagePath: downloadLocationer('', true), + outputType: output.type, + } +} diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index ae240a9c5..d85261ff0 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2602,7 +2602,7 @@ dependencies = [ "wasm-bindgen-futures", "web-sys", "winnow 0.5.40", - "zip 1.3.0", + "zip 2.1.1", ] [[package]] @@ -4500,9 +4500,9 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.202" +version = "1.0.203" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "226b61a0d411b2ba5ff6d7f73a476ac4f8bb900373459cd00fab8512828ba395" +checksum = "7253ab4de971e72fb7be983802300c30b5a7f0c2e56fab8abfc6a214307c0094" dependencies = [ "serde_derive", ] @@ -4518,9 +4518,9 @@ dependencies = [ [[package]] name = "serde_derive" -version = "1.0.202" +version = "1.0.203" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6048858004bcff69094cd972ed40a32500f153bd3be9f716b2eed2e8217c4838" +checksum = "500cbc0ebeb6f46627f50f3f5811ccf6bf00643be300b4c3eabc0ef55dc5b5ba" dependencies = [ "proc-macro2", "quote", @@ -7106,15 +7106,16 @@ dependencies = [ [[package]] name = "zip" -version = "1.3.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1f4a27345eb6f7aa7bd015ba7eb4175fa4e1b462a29874b779e0bbcf96c6ac7" +checksum = "1dd56a4d5921bc2f99947ac5b3abe5f510b1be7376fdc5e9fce4a23c6a93e87c" dependencies = [ "arbitrary", "crc32fast", "crossbeam-utils", "displaydoc", "indexmap 2.2.6", + "memchr", "thiserror", ] diff --git a/src/components/ModelingMachineProvider.tsx b/src/components/ModelingMachineProvider.tsx index 2663209a1..8a355acf4 100644 --- a/src/components/ModelingMachineProvider.tsx +++ b/src/components/ModelingMachineProvider.tsx @@ -314,8 +314,9 @@ export const ModelingMachineProvider = ({ return {} }), - 'Engine export': (_, event) => { + 'Engine export': async (_, event) => { if (event.type !== 'Export' || TEST) return + console.log('exporting', event.data) const format = { ...event.data, } as Partial @@ -359,9 +360,16 @@ export const ModelingMachineProvider = ({ format.selection = { type: 'default_scene' } } - exportFromEngine({ - format: format as Models['OutputFormat_type'], - }).catch((e) => toast.error('Error while exporting', e)) // TODO I think we need to throw the error from engineCommandManager + toast.promise( + exportFromEngine({ + format: format as Models['OutputFormat_type'], + }), + { + loading: 'Exporting...', + success: 'Exported successfully', + error: 'Error while exporting', + } + ) }, }, guards: { diff --git a/src/lang/std/engineConnection.ts b/src/lang/std/engineConnection.ts index b8bccac92..99f30b517 100644 --- a/src/lang/std/engineConnection.ts +++ b/src/lang/std/engineConnection.ts @@ -994,6 +994,10 @@ export class EngineCommandManager { engineConnection?: EngineConnection defaultPlanes: DefaultPlanes | null = null commandLogs: CommandLog[] = [] + pendingExport?: { + resolve: (filename?: string) => void + reject: (reason: any) => void + } _commandLogCallBack: (command: CommandLog[]) => void = () => {} private resolveReady = () => {} /** Folks should realize that wait for ready does not get called _everytime_ @@ -1150,7 +1154,9 @@ export class EngineCommandManager { // because in all other cases we send JSON strings. But in the case of // export we send a binary blob. // Pass this to our export function. - void exportSave(event.data) + exportSave(event.data).then(() => { + this.pendingExport?.resolve() + }, this.pendingExport?.reject) } else { const message: Models['WebSocketResponse_type'] = JSON.parse( event.data @@ -1548,6 +1554,12 @@ export class EngineCommandManager { this.outSequence++ this.engineConnection?.unreliableSend(command) return Promise.resolve() + } else if (cmd.type === 'export') { + const promise = new Promise((resolve, reject) => { + this.pendingExport = { resolve, reject } + }) + this.engineConnection?.send(command) + return promise } if ( command.cmd.type === 'default_camera_look_at' ||