import { test, expect } from '@playwright/test' import * as fsp from 'fs/promises' import { join } from 'path' import { getUtils, setup, setupElectron, tearDown, executorInputPath, } from './test-utils' import { SaveSettingsPayload } from 'lib/settings/settingsTypes' import { TEST_SETTINGS_KEY, TEST_SETTINGS_CORRUPTED } from './storageStates' import * as TOML from '@iarna/toml' test.beforeEach(async ({ context, page }) => { await setup(context, page) }) test.afterEach(async ({ page }, testInfo) => { await tearDown(page, testInfo) }) test.describe('Testing settings', () => { test('Stored settings are validated and fall back to defaults', async ({ page, }) => { const u = await getUtils(page) // Override beforeEach test setup // with corrupted settings await page.addInitScript( async ({ settingsKey, settings }) => { localStorage.setItem(settingsKey, settings) }, { settingsKey: TEST_SETTINGS_KEY, settings: TOML.stringify({ settings: TEST_SETTINGS_CORRUPTED }), } ) await page.setViewportSize({ width: 1200, height: 500 }) await u.waitForAuthSkipAppStart() // Check the settings were reset const storedSettings = TOML.parse( await page.evaluate( ({ settingsKey }) => localStorage.getItem(settingsKey) || '', { settingsKey: TEST_SETTINGS_KEY } ) ) as { settings: SaveSettingsPayload } expect(storedSettings.settings?.app?.theme).toBe(undefined) // Check that the invalid settings were removed expect(storedSettings.settings?.modeling?.defaultUnit).toBe(undefined) expect(storedSettings.settings?.modeling?.mouseControls).toBe(undefined) expect(storedSettings.settings?.app?.projectDirectory).toBe(undefined) expect(storedSettings.settings?.projects?.defaultProjectName).toBe( undefined ) }) test('Project settings can be set and override user settings', async ({ page, }) => { const u = await getUtils(page) await page.setViewportSize({ width: 1200, height: 500 }) await u.waitForAuthSkipAppStart() await page .getByRole('button', { name: 'Start Sketch' }) .waitFor({ state: 'visible' }) const paneButtonLocator = page.getByTestId('debug-pane-button') const headingLocator = page.getByRole('heading', { name: 'Settings', exact: true, }) const inputLocator = page.locator('input[name="modeling-showDebugPanel"]') // Open the settings modal with the browser keyboard shortcut await page.keyboard.press('ControlOrMeta+Shift+,') await expect(headingLocator).toBeVisible() await page.locator('#showDebugPanel').getByText('OffOn').click() // Close it and open again with keyboard shortcut, while KCL editor is focused // Put the cursor in the editor await test.step('Open settings with keyboard shortcut', async () => { await page.getByTestId('settings-close-button').click() await page.locator('.cm-content').click() await page.keyboard.press('ControlOrMeta+Shift+,') await expect(headingLocator).toBeVisible() }) // Verify the toast appeared await expect( page.getByText(`Set show debug panel to "false" for this project`) ).toBeVisible() // Check that the theme changed await expect(paneButtonLocator).not.toBeVisible() // Check that the user setting was not changed await page.getByRole('radio', { name: 'User' }).click() await expect(inputLocator).toBeChecked() // Roll back to default of "off" await await page .getByText('show debug panelRoll back show debug panelRoll back to match') .hover() await page .getByRole('button', { name: 'Roll back show debug panel', }) .click() await expect(inputLocator).not.toBeChecked() // Check that the project setting did not change await page.getByRole('radio', { name: 'Project' }).click() await expect( page.locator('input[name="modeling-showDebugPanel"]') ).not.toBeChecked() }) test('Keybindings display the correct hotkey for Command Palette', async ({ page, }) => { const u = await getUtils(page) await page.setViewportSize({ width: 1200, height: 500 }) await u.waitForAuthSkipAppStart() await test.step('Open keybindings settings', async () => { // Open the settings modal with the browser keyboard shortcut await page.keyboard.press('ControlOrMeta+Shift+,') // Go to Keybindings tab. const keybindingsTab = page.getByRole('radio', { name: 'Keybindings' }) await keybindingsTab.click() }) // Go to the hotkey for Command Palette. const commandPalette = page.getByText('Toggle Command Palette') await commandPalette.scrollIntoViewIfNeeded() // The heading is above it and should be in view now. const commandPaletteHeading = page.getByRole('heading', { name: 'Command Palette', }) // The hotkey is in a kbd element next to the heading. const hotkey = commandPaletteHeading.locator('+ div kbd') const text = process.platform === 'darwin' ? 'Command+K' : 'Control+K' await expect(hotkey).toHaveText(text) }) test('Project and user settings can be reset', async ({ page }) => { const u = await getUtils(page) await page.setViewportSize({ width: 1200, height: 500 }) await u.waitForAuthSkipAppStart() await page .getByRole('button', { name: 'Start Sketch' }) .waitFor({ state: 'visible' }) const projectSettingsTab = page.getByRole('radio', { name: 'Project' }) const userSettingsTab = page.getByRole('radio', { name: 'User' }) const resetButton = page.getByRole('button', { name: 'Restore default settings', }) const themeColorSetting = page.locator('#themeColor').getByRole('slider') const settingValues = { default: '259', user: '120', project: '50', } // Open the settings modal with lower-right button await page.getByRole('link', { name: 'Settings' }).last().click() await expect( page.getByRole('heading', { name: 'Settings', exact: true }) ).toBeVisible() await test.step('Set up theme color', async () => { // Verify we're looking at the project-level settings, // and it's set to default value await expect(projectSettingsTab).toBeChecked() await expect(themeColorSetting).toHaveValue(settingValues.default) // Set project-level value to 50 await themeColorSetting.fill(settingValues.project) // Set user-level value to 120 await userSettingsTab.click() await themeColorSetting.fill(settingValues.user) await projectSettingsTab.click() }) await test.step('Reset project settings', async () => { // Click the reset settings button. await resetButton.click() await expect(page.getByText('Settings restored to default')).toBeVisible() await expect( page.getByText('Settings restored to default') ).not.toBeVisible() // Verify it is now set to the inherited user value await expect(themeColorSetting).toHaveValue(settingValues.default) // Check that the user setting also rolled back await userSettingsTab.click() await expect(themeColorSetting).toHaveValue(settingValues.default) await projectSettingsTab.click() // Set project-level value to 50 again to test the user-level reset await themeColorSetting.fill(settingValues.project) await userSettingsTab.click() }) await test.step('Reset user settings', async () => { // Change the setting and click the reset settings button. await themeColorSetting.fill(settingValues.user) await resetButton.click() // Verify it is now set to the default value await expect(themeColorSetting).toHaveValue(settingValues.default) // Check that the project setting also changed await projectSettingsTab.click() await expect(themeColorSetting).toHaveValue(settingValues.default) }) }) test( `Project settings override user settings on desktop`, { tag: '@electron' }, async ({ browser: _ }, testInfo) => { test.skip( process.platform === 'win32', 'TODO: remove this skip https://github.com/KittyCAD/modeling-app/issues/3557' ) const { electronApp, page } = await setupElectron({ testInfo, folderSetupFn: async (dir) => { const bracketDir = join(dir, 'bracket') await fsp.mkdir(bracketDir, { recursive: true }) await fsp.copyFile( executorInputPath('focusrite_scarlett_mounting_braket.kcl'), join(bracketDir, 'main.kcl') ) }, }) await page.setViewportSize({ width: 1200, height: 500 }) page.on('console', console.log) // Selectors and constants const userThemeColor = '120' const projectThemeColor = '50' const settingsOpenButton = page.getByRole('link', { name: 'settings Settings', }) const themeColorSetting = page.locator('#themeColor').getByRole('slider') const projectSettingsTab = page.getByRole('radio', { name: 'Project' }) const userSettingsTab = page.getByRole('radio', { name: 'User' }) const settingsCloseButton = page.getByTestId('settings-close-button') const projectLink = page.getByText('bracket') const logoLink = page.getByTestId('app-logo') // Open the app and set the user theme color await test.step('Set user theme color on home', async () => { await expect(settingsOpenButton).toBeVisible() await settingsOpenButton.click() // The user tab should be selected by default on home await expect(userSettingsTab).toBeChecked() await themeColorSetting.fill(userThemeColor) await expect(logoLink).toHaveCSS('--primary-hue', userThemeColor) await settingsCloseButton.click() }) await test.step('Set project theme color', async () => { // Open the project await projectLink.click() await settingsOpenButton.click() // The project tab should be selected by default within a project await expect(projectSettingsTab).toBeChecked() await themeColorSetting.fill(projectThemeColor) await expect(logoLink).toHaveCSS('--primary-hue', projectThemeColor) }) await test.step('Refresh the application and see project setting applied', async () => { await page.reload() await expect(logoLink).toHaveCSS('--primary-hue', projectThemeColor) await settingsCloseButton.click() }) await test.step(`Navigate back to the home view and see user setting applied`, async () => { await logoLink.click() await expect(logoLink).toHaveCSS('--primary-hue', userThemeColor) }) await electronApp.close() } ) test( `Load desktop app with no settings file`, { tag: '@electron' }, async ({ browser: _ }, testInfo) => { const { electronApp, page } = await setupElectron({ // This is what makes no settings file get created cleanProjectDir: false, testInfo, }) await page.setViewportSize({ width: 1200, height: 500 }) // Selectors and constants const errorHeading = page.getByRole('heading', { name: 'An unextected error occurred', }) const projectDirLink = page.getByText('Loaded from') // If the app loads without exploding we're in the clear await expect(errorHeading).not.toBeVisible() await expect(projectDirLink).toBeVisible() await electronApp.close() } ) test( `Load desktop app with a settings file, but no project directory setting`, { tag: '@electron' }, async ({ browser: _ }, testInfo) => { const { electronApp, page } = await setupElectron({ testInfo, appSettings: { app: { themeColor: '259', }, }, }) await page.setViewportSize({ width: 1200, height: 500 }) // Selectors and constants const errorHeading = page.getByRole('heading', { name: 'An unextected error occurred', }) const projectDirLink = page.getByText('Loaded from') // If the app loads without exploding we're in the clear await expect(errorHeading).not.toBeVisible() await expect(projectDirLink).toBeVisible() await electronApp.close() } ) test( `Closing settings modal should go back to the original file being viewed`, { tag: '@electron' }, async ({ browser: _ }, testInfo) => { const { electronApp, page } = await setupElectron({ testInfo, folderSetupFn: async () => {}, }) const { panesOpen, createAndSelectProject, pasteCodeInEditor, clickPane, createNewFileAndSelect, editorTextMatches, } = await getUtils(page, test) await page.setViewportSize({ width: 1200, height: 500 }) page.on('console', console.log) await panesOpen([]) await test.step('Precondition: No projects exist', async () => { await expect(page.getByTestId('home-section')).toBeVisible() const projectLinksPre = page.getByTestId('project-link') await expect(projectLinksPre).toHaveCount(0) }) await createAndSelectProject('project-000') await clickPane('code') const kclCube = await fsp.readFile( 'src/wasm-lib/tests/executor/inputs/cube.kcl', 'utf-8' ) await pasteCodeInEditor(kclCube) await clickPane('files') await createNewFileAndSelect('2.kcl') const kclCylinder = await fsp.readFile( 'src/wasm-lib/tests/executor/inputs/cylinder.kcl', 'utf-8' ) await pasteCodeInEditor(kclCylinder) const settingsOpenButton = page.getByRole('link', { name: 'settings Settings', }) const settingsCloseButton = page.getByTestId('settings-close-button') await test.step('Open and close settings', async () => { await settingsOpenButton.click() await settingsCloseButton.click() }) await test.step('Postcondition: Same file content is in editor as before settings opened', async () => { await editorTextMatches(kclCylinder) }) await electronApp.close() } ) test('Changing modeling default unit', async ({ page }) => { const u = await getUtils(page) await page.setViewportSize({ width: 1200, height: 500 }) await u.waitForAuthSkipAppStart() await page .getByRole('button', { name: 'Start Sketch' }) .waitFor({ state: 'visible' }) const userSettingsTab = page.getByRole('radio', { name: 'User' }) // Open the settings modal with lower-right button await page.getByRole('link', { name: 'Settings' }).last().click() await expect( page.getByRole('heading', { name: 'Settings', exact: true }) ).toBeVisible() const resetButton = page.getByRole('button', { name: 'Restore default settings', }) // Default unit should be mm await resetButton.click() await test.step('Change modeling default unit within project tab', async () => { const changeUnitOfMeasureInProjectTab = async (unitOfMeasure: string) => { await test.step(`Set modeling default unit to ${unitOfMeasure}`, async () => { await page .getByTestId('modeling-defaultUnit') .selectOption(`${unitOfMeasure}`) const toastMessage = page.getByText( `Set default unit to "${unitOfMeasure}" for this project` ) await expect(toastMessage).toBeVisible() }) } await changeUnitOfMeasureInProjectTab('in') await changeUnitOfMeasureInProjectTab('ft') await changeUnitOfMeasureInProjectTab('yd') await changeUnitOfMeasureInProjectTab('mm') await changeUnitOfMeasureInProjectTab('cm') await changeUnitOfMeasureInProjectTab('m') }) // Go to the user tab await userSettingsTab.click() await test.step('Change modeling default unit within user tab', async () => { const changeUnitOfMeasureInUserTab = async (unitOfMeasure: string) => { await test.step(`Set modeling default unit to ${unitOfMeasure}`, async () => { await page .getByTestId('modeling-defaultUnit') .selectOption(`${unitOfMeasure}`) const toastMessage = page.getByText( `Set default unit to "${unitOfMeasure}" as a user default` ) await expect(toastMessage).toBeVisible() }) } await changeUnitOfMeasureInUserTab('in') await changeUnitOfMeasureInUserTab('ft') await changeUnitOfMeasureInUserTab('yd') await changeUnitOfMeasureInUserTab('mm') await changeUnitOfMeasureInUserTab('cm') await changeUnitOfMeasureInUserTab('m') }) // Close settings const settingsCloseButton = page.getByTestId('settings-close-button') await settingsCloseButton.click() await test.step('Change modeling default unit within command bar', async () => { const commands = page.getByRole('button', { name: 'Commands' }) const changeUnitOfMeasureInCommandBar = async (unitOfMeasure: string) => { // Open command bar await commands.click() const settingsModelingDefaultUnitCommand = page.getByText( 'Settings · modeling · default unit' ) await settingsModelingDefaultUnitCommand.click() const commandOption = page.getByRole('option', { name: unitOfMeasure, exact: true, }) await commandOption.click() const toastMessage = page.getByText( `Set default unit to "${unitOfMeasure}" for this project` ) await expect(toastMessage).toBeVisible() } await changeUnitOfMeasureInCommandBar('in') await changeUnitOfMeasureInCommandBar('ft') await changeUnitOfMeasureInCommandBar('yd') await changeUnitOfMeasureInCommandBar('mm') await changeUnitOfMeasureInCommandBar('cm') await changeUnitOfMeasureInCommandBar('m') }) await test.step('Change modeling default unit within gizmo', async () => { const changeUnitOfMeasureInGizmo = async ( unitOfMeasure: string, copy: string ) => { const gizmo = page.getByRole('button', { name: 'Current units are: ', }) await gizmo.click() const button = page.getByRole('button', { name: copy, exact: true, }) await button.click() const toastMessage = page.getByText( `Set default unit to "${unitOfMeasure}" for this project` ) await expect(toastMessage).toBeVisible() } await changeUnitOfMeasureInGizmo('in', 'Inches') await changeUnitOfMeasureInGizmo('ft', 'Feet') await changeUnitOfMeasureInGizmo('yd', 'Yards') await changeUnitOfMeasureInGizmo('mm', 'Millimeters') await changeUnitOfMeasureInGizmo('cm', 'Centimeters') await changeUnitOfMeasureInGizmo('m', 'Meters') }) }) })