Files
modeling-app/e2e/playwright/fixtures/cmdBarFixture.ts
Pierre Jacquier 599ab33e40 Add bidirectional and twist optional args to point-and-click Extrude (#7496)
* WIP: Add bidirectional args to point-and-click Extrude
Will eventually close #7495

* Wire up edit flow for symmetric

* Show skip true args in header in review phase

* Add bidirectionalLength

* Make currentArg always part of header

* WIP

* Add twistAng

* Proper optional args line in review

* Labels in progress button and option arg section heading

* Clean up extrude specific changes

* Clean up to separate from #7506

* Clean up to separate from #7506

* More clean up across branches

* More UI polish

* Remove options bool icon

* Fix labels for tests

* Upgrade e2e tests to cmdBar fixtures with fixes

* More fixes

* Fixed up more tests related to sweep behavior change

* Fix nodeToEdit not having hidden: true on Shell

* Add typecheck

* WIP: footer buttons

* back to reg width

* Clean up

* Update snapshots

* Update snapshots

* Clean up

* Fix tests and remove label

* Refactor

* Fix offset plane test

* Add CommandBarDivider

* Fix step back

* Update snapshots

* Add comment

* Fix it, thanks bot

* Clean up and inline optional heading

* Little case tweak

* Update src/components/CommandBar/CommandBarReview.tsx

Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com>

* Rename to CommandBarHeaderFooter

* Rename to CommandBarHeaderFooter

* Clean things up and fix edit

* Add test

* Revert something quick

* Reorg args to match kcl order

* Clean up edit arg retrieval error checks

* Lint

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com>
2025-06-23 14:53:01 +00:00

346 lines
11 KiB
TypeScript

import * as fs from 'fs'
import * as path from 'path'
import type { Locator, Page, Request, Route, TestInfo } from '@playwright/test'
import { expect } from '@playwright/test'
export type CmdBarSerialised =
| {
stage: 'commandBarClosed'
}
| {
stage: 'pickCommand'
// TODO this will need more properties when implemented in _serialiseCmdBar
}
| {
stage: 'arguments'
currentArgKey: string
currentArgValue: string
headerArguments: Record<string, string>
highlightedHeaderArg: string
commandName: string
}
| {
stage: 'review'
headerArguments: Record<string, string>
commandName: string
}
export class CmdBarFixture {
public page: Page
public cmdBarOpenBtn!: Locator
public cmdBarElement!: Locator
constructor(page: Page) {
this.page = page
this.cmdBarOpenBtn = this.page.getByTestId('command-bar-open-button')
this.cmdBarElement = this.page.getByTestId('command-bar')
}
get currentArgumentInput() {
return this.page.getByTestId('cmd-bar-arg-value')
}
private _serialiseCmdBar = async (): Promise<CmdBarSerialised> => {
if (!(await this.page.getByTestId('command-bar-wrapper').isVisible())) {
return { stage: 'commandBarClosed' }
} else if (await this.page.getByTestId('cmd-bar-search').isVisible()) {
return { stage: 'pickCommand' }
}
const reviewForm = this.page.locator('#review-form')
const getHeaderArgs = async () => {
const inputs = await this.page.getByTestId('cmd-bar-input-tab').all()
const entries = await Promise.all(
inputs.map((input) => {
const key = input
.locator('[data-test-name="arg-name"]')
.innerText()
.then((a) => a.trim())
const value = input
.getByTestId('header-arg-value')
.innerText()
.then((a) => a.trim())
return Promise.all([key, value])
})
)
return Object.fromEntries(entries)
}
const getCommandName = () =>
this.page.getByTestId('command-name').textContent()
if (await reviewForm.isVisible()) {
const [headerArguments, commandName] = await Promise.all([
getHeaderArgs(),
getCommandName(),
])
return {
stage: 'review',
headerArguments,
commandName: commandName || '',
}
}
const [
currentArgKey,
currentArgValue,
headerArguments,
highlightedHeaderArg,
commandName,
] = await Promise.all([
this.page.getByTestId('cmd-bar-arg-name').textContent(),
this.page.getByTestId('cmd-bar-arg-value').textContent(),
getHeaderArgs(),
this.page
.locator('[data-is-current-arg="true"]')
.locator('[data-test-name="arg-name"]')
.textContent(),
getCommandName(),
])
return {
stage: 'arguments',
currentArgKey: currentArgKey || '',
currentArgValue: currentArgValue || '',
headerArguments,
highlightedHeaderArg: highlightedHeaderArg || '',
commandName: commandName || '',
}
}
expectState = async (expected: CmdBarSerialised) => {
return expect.poll(() => this._serialiseCmdBar()).toEqual(expected)
}
/**
* This method is used to progress the command bar to the next step, defaulting to clicking the next button.
* Optionally, with the `shouldUseKeyboard` parameter, it will hit `Enter` to progress.
* * TODO: This method assumes the user has a valid input to the current stage,
* and assumes we are past the `pickCommand` step.
*/
progressCmdBar = async (shouldUseKeyboard = false) => {
await this.page.waitForTimeout(2000)
if (shouldUseKeyboard) {
await this.page.keyboard.press('Enter')
return
}
const arrowButton = this.page.getByTestId('command-bar-continue')
if (await arrowButton.isVisible()) {
await this.continue()
} else {
await this.submit()
}
}
// Added data-testid to the command bar buttons
// command-bar-continue are the buttons to go to the next step
// does not include the submit which is the final button press
// aka the right arrow button
continue = async () => {
const continueButton = this.page.getByTestId('command-bar-continue')
await continueButton.click()
}
// Added data-testid to the command bar buttons
// command-bar-submit is the button for the final step to submit
// the command bar flow aka the checkmark button.
submit = async () => {
const submitButton = this.page.getByTestId('command-bar-submit')
await submitButton.click()
}
openCmdBar = async (selectCmd?: 'promptToEdit') => {
await this.cmdBarOpenBtn.click()
await expect(this.page.getByPlaceholder('Search commands')).toBeVisible()
if (selectCmd === 'promptToEdit') {
const promptEditCommand = this.selectOption({ name: 'Text-to-CAD Edit' })
await expect(promptEditCommand.first()).toBeVisible()
await promptEditCommand.first().scrollIntoViewIfNeeded()
await promptEditCommand.first().click()
}
}
closeCmdBar = async () => {
const cmdBarCloseBtn = this.page.getByTestId('command-bar-close-button')
await cmdBarCloseBtn.click()
await expect(this.cmdBarElement).not.toBeVisible()
}
get cmdSearchInput() {
return this.page.getByTestId('cmd-bar-search')
}
get argumentInput() {
return this.page.getByTestId('cmd-bar-arg-value')
}
get variableCheckbox() {
return this.page.getByTestId('cmd-bar-variable-checkbox')
}
get cmdOptions() {
return this.page.getByTestId('cmd-bar-option')
}
chooseCommand = async (commandName: string) => {
await this.cmdOptions.getByText(commandName).click()
}
/**
* Select an option from the command bar
*/
selectOption = (options: Parameters<typeof this.page.getByRole>[1]) => {
return this.page.getByRole('option', options)
}
/**
* Select an optional argument from the command bar during review
*/
clickOptionalArgument = async (argName: string) => {
await this.page.getByTestId(`cmd-bar-add-optional-arg-${argName}`).click()
}
/**
* Clicks the Create new variable button for kcl input
*/
createNewVariable = async () => {
await this.variableCheckbox.click()
}
/**
* Captures a snapshot of the request sent to the text-to-cad API endpoint
* and saves it to a file named after the current test.
*
* The snapshot file will be saved in the specified directory with a filename
* derived from the test's full path (including describe blocks).
*
* @param testInfoInOrderToGetTestTitle The TestInfo object from the test context
* @param customOutputDir Optional custom directory for the output file
*/
async captureTextToCadRequestSnapshot(
testInfoInOrderToGetTestTitle: TestInfo,
customOutputDir = 'e2e/playwright/snapshots/prompt-to-edit'
) {
// First sanitize each title component individually
const sanitizedTitleComponents = [
...testInfoInOrderToGetTestTitle.titlePath.slice(0, -1), // Get all parent titles
testInfoInOrderToGetTestTitle.title, // Add the test title
].map(
(component) =>
component
.replace(/[^a-z0-9]/gi, '-') // Replace non-alphanumeric chars with hyphens
.toLowerCase()
.replace(/-+/g, '-') // Replace multiple consecutive hyphens with a single one
.replace(/^-|-$/g, '') // Remove leading/trailing hyphens
)
// Join the sanitized components with -- as a clear separator
const sanitizedTestName = sanitizedTitleComponents.join('--')
// Create the output path
const outputPath = path.join(
customOutputDir,
`${sanitizedTestName}.snap.json`
)
// Create a handler function that saves request bodies to a file
const requestHandler = (route: Route, request: Request) => {
try {
// Get the raw post data
const postData = request.postData()
if (!postData) {
console.error('No post data found in request')
return
}
// Extract all parts from the multipart form data
const boundary = postData.match(/------WebKitFormBoundary[^\r\n]*/)?.[0]
if (!boundary) {
console.error('Could not find form boundary')
return
}
const parts = postData.split(boundary).filter((part) => part.trim())
const files: Record<string, string> = {}
let eventData = null
for (const part of parts) {
// Skip the final boundary marker
if (part.startsWith('--')) continue
const nameMatch = part.match(/name="([^"]+)"/)
if (!nameMatch) continue
const name = nameMatch[1]
const content = part.split(/\r?\n\r?\n/)[1]?.trim()
if (!content) continue
if (name === 'event') {
eventData = JSON.parse(content)
} else {
files[name] = content
}
}
if (!eventData) {
console.error('Could not find event JSON in multipart form data')
return
}
const requestBody = {
...eventData,
files,
}
// Ensure directory exists
const dir = path.dirname(outputPath)
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true })
}
// Write the request body to the file
fs.writeFileSync(outputPath, JSON.stringify(requestBody, null, 2))
console.log(`Saved text-to-cad API request to: ${outputPath}`)
} catch (error) {
console.error('Error processing text-to-cad request:', error)
}
// Use void to explicitly mark the promise as ignored
void route.continue()
}
// Start monitoring requests
await this.page.route(
'**/ml/text-to-cad/multi-file/iteration',
requestHandler
)
console.log(
`Monitoring text-to-cad API requests. Output will be saved to: ${outputPath}`
)
}
async toBeOpened() {
// Check that the command bar is opened
await expect(this.cmdBarElement).toBeVisible({ timeout: 10_000 })
}
async toBeClosed() {
// Check that the command bar is closed
await expect(this.cmdBarElement).not.toBeVisible({ timeout: 10_000 })
}
async expectArgValue(value: string) {
// Check the placeholder project name exists
const actualArgument = await this.cmdBarElement
.getByTestId('cmd-bar-arg-value')
.inputValue()
const expectedArgument = value
expect(actualArgument).toBe(expectedArgument)
}
async expectCommandName(value: string) {
// Check the placeholder project name exists
const actual = await this.cmdBarElement
.getByTestId('command-name')
.textContent()
const expected = value
expect(actual).toBe(expected)
}
}