441 lines
14 KiB
TypeScript
441 lines
14 KiB
TypeScript
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
|
|
await page.getByTestId('loading-stream').waitFor()
|
|
// wait for all spinners to be gone
|
|
await page.getByTestId('loading').waitFor({ state: 'detached' })
|
|
|
|
await page.getByTestId('start-sketch').waitFor()
|
|
}
|
|
|
|
async function removeCurrentCode(page: Page) {
|
|
const hotkey = process.platform === 'darwin' ? 'Meta' : 'Control'
|
|
await page.click('.cm-content')
|
|
await page.keyboard.down(hotkey)
|
|
await page.keyboard.press('a')
|
|
await page.keyboard.up(hotkey)
|
|
await page.keyboard.press('Backspace')
|
|
await expect(page.locator('.cm-content')).toHaveText('')
|
|
}
|
|
|
|
async function sendCustomCmd(page: Page, cmd: EngineCommand) {
|
|
await page.fill('[data-testid="custom-cmd-input"]', JSON.stringify(cmd))
|
|
await page.click('[data-testid="custom-cmd-send-button"]')
|
|
}
|
|
|
|
async function clearCommandLogs(page: Page) {
|
|
await page.click('[data-testid="clear-commands"]')
|
|
}
|
|
|
|
async function expectCmdLog(page: Page, locatorStr: string) {
|
|
await expect(page.locator(locatorStr).last()).toBeVisible()
|
|
}
|
|
|
|
async function waitForDefaultPlanesToBeVisible(page: Page) {
|
|
await page.waitForFunction(
|
|
() =>
|
|
document.querySelectorAll('[data-receive-command-type="object_visible"]')
|
|
.length >= 3
|
|
)
|
|
}
|
|
|
|
async function openKclCodePanel(page: Page) {
|
|
const paneLocator = page.getByRole('tab', { name: 'KCL Code', exact: false })
|
|
const isOpen = (await paneLocator?.getAttribute('aria-selected')) === 'true'
|
|
|
|
if (!isOpen) {
|
|
await paneLocator.click()
|
|
await paneLocator.and(page.locator('[aria-selected="true"]')).waitFor()
|
|
}
|
|
}
|
|
|
|
async function closeKclCodePanel(page: Page) {
|
|
const paneLocator = page.getByRole('tab', { name: 'KCL Code', exact: false })
|
|
const isOpen = (await paneLocator?.getAttribute('aria-selected')) === 'true'
|
|
if (isOpen) {
|
|
await paneLocator.click()
|
|
await paneLocator
|
|
.and(page.locator(':not([aria-selected="true"])'))
|
|
.waitFor()
|
|
}
|
|
}
|
|
|
|
async function openDebugPanel(page: Page) {
|
|
const debugLocator = page.getByRole('tab', { name: 'Debug', exact: false })
|
|
const isOpen = (await debugLocator?.getAttribute('aria-selected')) === 'true'
|
|
|
|
if (!isOpen) {
|
|
await debugLocator.click()
|
|
await debugLocator.and(page.locator('[aria-selected="true"]')).waitFor()
|
|
}
|
|
}
|
|
|
|
async function closeDebugPanel(page: Page) {
|
|
const debugLocator = page.getByRole('tab', { name: 'Debug', exact: false })
|
|
const isOpen = (await debugLocator?.getAttribute('aria-selected')) === 'true'
|
|
if (isOpen) {
|
|
await debugLocator.click()
|
|
await debugLocator
|
|
.and(page.locator(':not([aria-selected="true"])'))
|
|
.waitFor()
|
|
}
|
|
}
|
|
|
|
async function waitForCmdReceive(page: Page, commandType: string) {
|
|
return page
|
|
.locator(`[data-receive-command-type="${commandType}"]`)
|
|
.first()
|
|
.waitFor()
|
|
}
|
|
|
|
export async function getUtils(page: Page) {
|
|
// Chrome devtools protocol session only works in Chromium
|
|
const browserType = page.context().browser()?.browserType().name()
|
|
const cdpSession =
|
|
browserType !== 'chromium' ? null : await page.context().newCDPSession(page)
|
|
|
|
let click00rCenter = { x: 0, y: 0 }
|
|
const click00 = (x: number, y: number) =>
|
|
page.mouse.click(click00rCenter.x + x, click00rCenter.y + y)
|
|
let click00rLastPos = { x: 0, y: 0 }
|
|
|
|
// The way we truncate is kinda odd apparently, so we need this function
|
|
// "[k]itty[c]ad round"
|
|
const kcRound = (n: number) => Math.trunc(n * 100) / 100
|
|
|
|
// To translate between screen and engine ("[U]nit") coordinates
|
|
// NOTE: these pretty much can't be perfect because of screen scaling.
|
|
// Handle on a case-by-case.
|
|
const toU = (x: number, y: number) => [
|
|
kcRound(x * 0.0854),
|
|
kcRound(-y * 0.0854), // Y is inverted in our coordinate system
|
|
]
|
|
|
|
// Turn the array into a string with specific formatting
|
|
const fromUToString = (xy: number[]) => `[${xy[0]}, ${xy[1]}]`
|
|
|
|
// Combine because used often
|
|
const toSU = (xy: number[]) => fromUToString(toU(xy[0], xy[1]))
|
|
|
|
return {
|
|
waitForAuthSkipAppStart: () => waitForPageLoad(page),
|
|
removeCurrentCode: () => removeCurrentCode(page),
|
|
sendCustomCmd: (cmd: EngineCommand) => sendCustomCmd(page, cmd),
|
|
updateCamPosition: async (xyz: [number, number, number]) => {
|
|
const fillInput = async (axis: 'x' | 'y' | 'z', value: number) => {
|
|
await page.fill(`[data-testid="cam-${axis}-position"]`, String(value))
|
|
await page.waitForTimeout(100)
|
|
}
|
|
|
|
await fillInput('x', xyz[0])
|
|
await fillInput('y', xyz[1])
|
|
await fillInput('z', xyz[2])
|
|
},
|
|
clearCommandLogs: () => clearCommandLogs(page),
|
|
expectCmdLog: (locatorStr: string) => expectCmdLog(page, locatorStr),
|
|
openKclCodePanel: () => openKclCodePanel(page),
|
|
closeKclCodePanel: () => closeKclCodePanel(page),
|
|
openDebugPanel: () => openDebugPanel(page),
|
|
closeDebugPanel: () => closeDebugPanel(page),
|
|
openAndClearDebugPanel: async () => {
|
|
await openDebugPanel(page)
|
|
return clearCommandLogs(page)
|
|
},
|
|
clearAndCloseDebugPanel: async () => {
|
|
await clearCommandLogs(page)
|
|
return closeDebugPanel(page)
|
|
},
|
|
waitForCmdReceive: (commandType: string) =>
|
|
waitForCmdReceive(page, commandType),
|
|
getSegmentBodyCoords: async (locator: string, px = 30) => {
|
|
const overlay = page.locator(locator)
|
|
const bbox = await overlay
|
|
.boundingBox()
|
|
.then((box) => ({ ...box, x: box?.x || 0, y: box?.y || 0 }))
|
|
const angle = Number(await overlay.getAttribute('data-overlay-angle'))
|
|
const angleXOffset = Math.cos(((angle - 180) * Math.PI) / 180) * px
|
|
const angleYOffset = Math.sin(((angle - 180) * Math.PI) / 180) * px
|
|
return {
|
|
x: bbox.x + angleXOffset,
|
|
y: bbox.y - angleYOffset,
|
|
}
|
|
},
|
|
getAngle: async (locator: string) => {
|
|
const overlay = page.locator(locator)
|
|
return Number(await overlay.getAttribute('data-overlay-angle'))
|
|
},
|
|
getBoundingBox: async (locator: string) =>
|
|
page
|
|
.locator(locator)
|
|
.boundingBox()
|
|
.then((box) => ({ ...box, x: box?.x || 0, y: box?.y || 0 })),
|
|
doAndWaitForCmd: async (
|
|
fn: () => Promise<void>,
|
|
commandType: string,
|
|
endWithDebugPanelOpen = true
|
|
) => {
|
|
await openDebugPanel(page)
|
|
await clearCommandLogs(page)
|
|
await closeDebugPanel(page)
|
|
await fn()
|
|
await openDebugPanel(page)
|
|
await waitForCmdReceive(page, commandType)
|
|
if (!endWithDebugPanelOpen) {
|
|
await closeDebugPanel(page)
|
|
}
|
|
},
|
|
doAndWaitForImageDiff: (fn: () => Promise<any>, diffCount = 200) =>
|
|
new Promise(async (resolve) => {
|
|
await page.screenshot({
|
|
path: './e2e/playwright/temp1.png',
|
|
fullPage: true,
|
|
})
|
|
await fn()
|
|
const isImageDiff = async () => {
|
|
await page.screenshot({
|
|
path: './e2e/playwright/temp2.png',
|
|
fullPage: true,
|
|
})
|
|
const screenshot1 = PNG.sync.read(
|
|
await fsp.readFile('./e2e/playwright/temp1.png')
|
|
)
|
|
const screenshot2 = PNG.sync.read(
|
|
await fsp.readFile('./e2e/playwright/temp2.png')
|
|
)
|
|
const actualDiffCount = pixelMatch(
|
|
screenshot1.data,
|
|
screenshot2.data,
|
|
null,
|
|
screenshot1.width,
|
|
screenshot2.height
|
|
)
|
|
return actualDiffCount > diffCount
|
|
}
|
|
|
|
// run isImageDiff every 50ms until it returns true or 5 seconds have passed (100 times)
|
|
let count = 0
|
|
const interval = setInterval(async () => {
|
|
count++
|
|
if (await isImageDiff()) {
|
|
clearInterval(interval)
|
|
resolve(true)
|
|
} else if (count > 100) {
|
|
clearInterval(interval)
|
|
resolve(false)
|
|
}
|
|
}, 50)
|
|
}),
|
|
emulateNetworkConditions: async (
|
|
networkOptions: Protocol.Network.emulateNetworkConditionsParameters
|
|
) => {
|
|
// Skip on non-Chromium browsers, since we need to use the CDP.
|
|
test.skip(
|
|
cdpSession === null,
|
|
'Network emulation is only supported in Chromium'
|
|
)
|
|
|
|
cdpSession?.send('Network.emulateNetworkConditions', networkOptions)
|
|
},
|
|
expectCodeToBe: async (str: string) => {
|
|
await expect(page.locator('.cm-content')).toHaveText(str)
|
|
await page.waitForTimeout(100)
|
|
},
|
|
click00rSetCenter: (x: number, y: number) => {
|
|
click00rCenter = { x, y }
|
|
},
|
|
click00r: (x?: number, y?: number) => {
|
|
// reset relative coordinates when anything is undefined
|
|
if (x === undefined || y === undefined) {
|
|
click00rLastPos.x = 0
|
|
click00rLastPos.y = 0
|
|
return
|
|
}
|
|
|
|
const ret = click00(click00rLastPos.x + x, click00rLastPos.y + y)
|
|
click00rLastPos.x += x
|
|
click00rLastPos.y += y
|
|
|
|
// Returns the new absolute coordinate if you need it.
|
|
return ret.then(() => [click00rLastPos.x, click00rLastPos.y])
|
|
},
|
|
toSU,
|
|
wiggleMove: async (
|
|
x: number,
|
|
y: number,
|
|
steps: number,
|
|
dist: number,
|
|
ang: number,
|
|
amplitude: number,
|
|
freq: number
|
|
) => {
|
|
const tau = Math.PI * 2
|
|
const deg = tau / 360
|
|
const step = dist / steps
|
|
for (let i = 0, j = 0; i < dist; i += step, j += 1) {
|
|
const y1 = Math.sin((tau / steps) * j * freq) * amplitude
|
|
const [x2, y2] = [
|
|
Math.cos(-ang * deg) * i - Math.sin(-ang * deg) * y1,
|
|
Math.sin(-ang * deg) * i + Math.cos(-ang * deg) * y1,
|
|
]
|
|
const [xr, yr] = [x2, y2]
|
|
await page.mouse.move(x + xr, y + yr, { steps: 2 })
|
|
}
|
|
},
|
|
}
|
|
}
|
|
|
|
type TemplateOptions = Array<number | Array<number>>
|
|
|
|
type makeTemplateReturn = {
|
|
regExp: RegExp
|
|
genNext: (
|
|
templateParts: TemplateStringsArray,
|
|
...options: TemplateOptions
|
|
) => makeTemplateReturn
|
|
}
|
|
|
|
const escapeRegExp = (string: string) => {
|
|
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // $& means the whole matched string
|
|
}
|
|
|
|
const _makeTemplate = (
|
|
templateParts: TemplateStringsArray,
|
|
...options: TemplateOptions
|
|
) => {
|
|
const length = Math.max(...options.map((a) => (Array.isArray(a) ? a[0] : 0)))
|
|
let reExpTemplate = ''
|
|
for (let i = 0; i < length; i++) {
|
|
const currentStr = templateParts.map((str, index) => {
|
|
const currentOptions = options[index]
|
|
return (
|
|
escapeRegExp(str) +
|
|
String(
|
|
Array.isArray(currentOptions)
|
|
? currentOptions[i]
|
|
: typeof currentOptions === 'number'
|
|
? currentOptions
|
|
: ''
|
|
)
|
|
)
|
|
})
|
|
reExpTemplate += '|' + currentStr.join('')
|
|
}
|
|
return new RegExp(reExpTemplate)
|
|
}
|
|
|
|
/**
|
|
* Tool for making templates to match code snippets in the editor with some fudge factor,
|
|
* as there's some level of non-determinism.
|
|
*
|
|
* Usage is as such:
|
|
* ```typescript
|
|
* const result = makeTemplate`const myVar = aFunc(${[1, 2, 3]})`
|
|
* await expect(page.locator('.cm-content')).toHaveText(result.regExp)
|
|
* ```
|
|
* Where the value `1`, `2` or `3` are all valid and should make the test pass.
|
|
*
|
|
* The function also has a `genNext` function that allows you to chain multiple templates
|
|
* together without having to repeat previous parts of the template.
|
|
* ```typescript
|
|
* const result2 = result.genNext`const myVar2 = aFunc(${[4, 5, 6]})`
|
|
* ```
|
|
*/
|
|
export const makeTemplate: (
|
|
templateParts: TemplateStringsArray,
|
|
...values: TemplateOptions
|
|
) => makeTemplateReturn = (templateParts, ...options) => {
|
|
return {
|
|
regExp: _makeTemplate(templateParts, ...options),
|
|
genNext: (
|
|
nextTemplateParts: TemplateStringsArray,
|
|
...nextOptions: TemplateOptions
|
|
) =>
|
|
makeTemplate(
|
|
[...templateParts, ...nextTemplateParts] as any as TemplateStringsArray,
|
|
[...options, ...nextOptions] as any
|
|
),
|
|
}
|
|
}
|
|
|
|
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,
|
|
}
|
|
}
|