Add a promise-based toast when exporting (#2541)

* Add loading and success toasts to export engine command

* Move doExport out to a test utility, test visibility of loading spinner

* Add playwright test for export success toast

* Update Cargo.lock

* Remove loading assertion, it flashes too quickly for Playwright to pick up
This commit is contained in:
Frank Noirot
2024-05-29 18:04:27 -04:00
committed by GitHub
parent 450afb1605
commit e6641e68f3
6 changed files with 276 additions and 149 deletions

View File

@ -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()
})

View File

@ -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<Paths> => {
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<Download>((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

View File

@ -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<Paths> => {
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<Download>((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,
}
}

15
src-tauri/Cargo.lock generated
View File

@ -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",
]

View File

@ -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<Models['OutputFormat_type']>
@ -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: {

View File

@ -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' ||