diff --git a/examples/todomvc/tests/integration.spec.ts b/examples/todomvc/tests/integration.spec.ts index 11ede886eb..ec3d4af040 100644 --- a/examples/todomvc/tests/integration.spec.ts +++ b/examples/todomvc/tests/integration.spec.ts @@ -15,7 +15,6 @@ const TODO_ITEMS = [ 'book a doctors appointment' ]; - test.describe('New Todo', () => { test('should allow me to add todo items', async ({ page }) => { // create a new todo locator diff --git a/packages/playwright-core/src/server/chromium/crApp.ts b/packages/playwright-core/src/server/chromium/crApp.ts index f06b50f917..1ae096078c 100644 --- a/packages/playwright-core/src/server/chromium/crApp.ts +++ b/packages/playwright-core/src/server/chromium/crApp.ts @@ -16,6 +16,7 @@ import fs from 'fs'; import path from 'path'; +import { isUnderTest } from 'playwright-core/lib/utils'; import type { Page } from '../page'; import { registryDirectory } from '../registry'; import type { CRPage } from './crPage'; @@ -29,23 +30,21 @@ export async function installAppIcon(page: Page) { } export async function syncLocalStorageWithSettings(page: Page, appName: string) { + if (isUnderTest()) + return; const settingsFile = path.join(registryDirectory, '.settings', `${appName}.json`); - await page.exposeBinding('saveSettings', false, (_, settings: any) => { + await page.exposeBinding('_saveSerializedSettings', false, (_, settings) => { fs.mkdirSync(path.dirname(settingsFile), { recursive: true }); fs.writeFileSync(settingsFile, settings); }); const settings = await fs.promises.readFile(settingsFile, 'utf-8').catch(() => ('{}')); - await page.addInitScript(`(${String((settings: any) => { - Object.entries(settings).map(([k, v]) => localStorage[k] = v); - - let lastValue = JSON.stringify(localStorage); - setInterval(() => { - const value = JSON.stringify(localStorage); - if (value !== lastValue) { - lastValue = value; - window.saveSettings(value); - } - }, 2000); - })})(${settings})`); + await page.addInitScript( + `(${String((settings: any) => { + Object.entries(settings).map(([k, v]) => localStorage[k] = v); + (window as any).saveSettings = () => { + (window as any)._saveSerializedSettings(JSON.stringify({ ...localStorage })); + }; + })})(${settings}); + `); } diff --git a/packages/trace-viewer/src/ui/watchMode.tsx b/packages/trace-viewer/src/ui/watchMode.tsx index e0504d00e5..0c716c5ceb 100644 --- a/packages/trace-viewer/src/ui/watchMode.tsx +++ b/packages/trace-viewer/src/ui/watchMode.tsx @@ -20,7 +20,7 @@ import '@web/common.css'; import React from 'react'; import { TreeView } from '@web/components/treeView'; import type { TreeState } from '@web/components/treeView'; -import { TeleReporterReceiver, TeleSuite } from '@testIsomorphic/teleReceiver'; +import { baseFullConfig, TeleReporterReceiver, TeleSuite } from '@testIsomorphic/teleReceiver'; import type { TeleTestCase } from '@testIsomorphic/teleReceiver'; import type { FullConfig, Suite, TestCase, TestResult, Location } from '../../../playwright-test/types/testReporter'; import { SplitView } from '@web/components/splitView'; @@ -34,8 +34,9 @@ import { XtermWrapper } from '@web/components/xtermWrapper'; import { Expandable } from '@web/components/expandable'; import { toggleTheme } from '@web/theme'; import { artifactsFolderName } from '@testIsomorphic/folders'; +import { settings } from '@web/uiUtils'; -let updateRootSuite: (rootSuite: Suite, progress: Progress) => void = () => {}; +let updateRootSuite: (config: FullConfig, rootSuite: Suite, progress: Progress) => void = () => {}; let runWatchedTests = (fileName: string) => {}; let xtermSize = { cols: 80, rows: 24 }; @@ -49,6 +50,11 @@ const xtermDataSource: XtermDataSource = { }, }; +type TestModel = { + config: FullConfig | undefined; + rootSuite: Suite | undefined; +}; + export const WatchModeView: React.FC<{}> = ({ }) => { const [filterText, setFilterText] = React.useState(''); @@ -60,7 +66,7 @@ export const WatchModeView: React.FC<{}> = ({ ['skipped', false], ])); const [projectFilters, setProjectFilters] = React.useState>(new Map()); - const [rootSuite, setRootSuite] = React.useState<{ value: Suite | undefined }>({ value: undefined }); + const [testModel, setTestModel] = React.useState({ config: undefined, rootSuite: undefined }); const [progress, setProgress] = React.useState({ total: 0, passed: 0, failed: 0, skipped: 0 }); const [selectedTest, setSelectedTest] = React.useState(undefined); const [visibleTestIds, setVisibleTestIds] = React.useState([]); @@ -71,7 +77,7 @@ export const WatchModeView: React.FC<{}> = ({ const reloadTests = () => { setIsLoading(true); - updateRootSuite(new TeleSuite('', 'root'), { total: 0, passed: 0, failed: 0, skipped: 0 }); + updateRootSuite(baseFullConfig, new TeleSuite('', 'root'), { total: 0, passed: 0, failed: 0, skipped: 0 }); refreshRootSuite(true).then(() => { setIsLoading(false); }); @@ -82,19 +88,20 @@ export const WatchModeView: React.FC<{}> = ({ reloadTests(); }, []); - updateRootSuite = (rootSuite: Suite, newProgress: Progress) => { + updateRootSuite = (config: FullConfig, rootSuite: Suite, newProgress: Progress) => { + const selectedProjects = config.configFile ? settings.getObject(config.configFile + ':projects', undefined) : undefined; for (const projectName of projectFilters.keys()) { if (!rootSuite.suites.find(s => s.title === projectName)) projectFilters.delete(projectName); } for (const projectSuite of rootSuite.suites) { if (!projectFilters.has(projectSuite.title)) - projectFilters.set(projectSuite.title, false); + projectFilters.set(projectSuite.title, !!selectedProjects?.includes(projectSuite.title)); } - if (projectFilters.size && ![...projectFilters.values()].includes(true)) + if (!selectedProjects && projectFilters.size && ![...projectFilters.values()].includes(true)) projectFilters.set(projectFilters.entries().next().value[0], true); - setRootSuite({ value: rootSuite }); + setTestModel({ config, rootSuite }); setProjectFilters(new Map(projectFilters)); setProgress(newProgress); }; @@ -103,11 +110,11 @@ export const WatchModeView: React.FC<{}> = ({ // Clear test results. { const testIdSet = new Set(testIds); - for (const test of rootSuite.value?.allTests() || []) { + for (const test of testModel.rootSuite?.allTests() || []) { if (testIdSet.has(test.id)) (test as TeleTestCase)._createTestResult('pending'); } - setRootSuite({ ...rootSuite }); + setTestModel({ ...testModel }); } const time = ' [' + new Date().toLocaleTimeString() + ']'; @@ -154,6 +161,7 @@ export const WatchModeView: React.FC<{}> = ({ setStatusFilters={setStatusFilters} projectFilters={projectFilters} setProjectFilters={setProjectFilters} + testModel={testModel} runTests={() => runTests(visibleTestIds)} />
Tests
@@ -165,7 +173,7 @@ export const WatchModeView: React.FC<{}> = ({ statusFilters={statusFilters} projectFilters={projectFilters} filterText={filterText} - rootSuite={rootSuite} + testModel={testModel} runningState={runningState} runTests={runTests} onTestSelected={setSelectedTest} @@ -191,8 +199,9 @@ const FiltersView: React.FC<{ setStatusFilters: (filters: Map) => void; projectFilters: Map; setProjectFilters: (filters: Map) => void; + testModel: TestModel | undefined, runTests: () => void; -}> = ({ filterText, setFilterText, statusFilters, setStatusFilters, projectFilters, setProjectFilters, runTests }) => { +}> = ({ filterText, setFilterText, statusFilters, setStatusFilters, projectFilters, setProjectFilters, testModel, runTests }) => { const [expanded, setExpanded] = React.useState(false); const inputRef = React.useRef(null); React.useEffect(() => { @@ -235,6 +244,9 @@ const FiltersView: React.FC<{ const copy = new Map(projectFilters); copy.set(projectName, !copy.get(projectName)); setProjectFilters(copy); + const configFile = testModel?.config?.configFile; + if (configFile) + settings.setObject(configFile + ':projects', [...copy.entries()].filter(([_, v]) => v).map(([k]) => k)); }}/>
{projectName}
@@ -254,18 +266,18 @@ const TestList: React.FC<{ statusFilters: Map, projectFilters: Map, filterText: string, - rootSuite: { value: Suite | undefined }, + testModel: { rootSuite: Suite | undefined, config: FullConfig | undefined }, runTests: (testIds: string[]) => void, runningState?: { testIds: Set, itemSelectedByUser?: boolean }, setVisibleTestIds: (testIds: string[]) => void, onTestSelected: (test: TestCase | undefined) => void, -}> = ({ statusFilters, projectFilters, filterText, rootSuite, runTests, runningState, onTestSelected, setVisibleTestIds }) => { +}> = ({ statusFilters, projectFilters, filterText, testModel, runTests, runningState, onTestSelected, setVisibleTestIds }) => { const [treeState, setTreeState] = React.useState({ expandedItems: new Map() }); const [selectedTreeItemId, setSelectedTreeItemId] = React.useState(); const [watchedTreeIds] = React.useState>(new Set()); const { rootItem, treeItemMap } = React.useMemo(() => { - const rootItem = createTree(rootSuite.value, projectFilters); + const rootItem = createTree(testModel.rootSuite, projectFilters); filterTree(rootItem, filterText, statusFilters); hideOnlyTests(rootItem); const treeItemMap = new Map(); @@ -279,7 +291,7 @@ const TestList: React.FC<{ visit(rootItem); setVisibleTestIds([...visibleTestIds]); return { rootItem, treeItemMap }; - }, [filterText, rootSuite, statusFilters, projectFilters, setVisibleTestIds]); + }, [filterText, testModel, statusFilters, projectFilters, setVisibleTestIds]); React.useEffect(() => { // Look for a first failure within the run batch to select it. @@ -439,15 +451,15 @@ declare global { let receiver: TeleReporterReceiver | undefined; let throttleTimer: NodeJS.Timeout | undefined; -let throttleData: { rootSuite: Suite, progress: Progress } | undefined; +let throttleData: { config: FullConfig, rootSuite: Suite, progress: Progress } | undefined; const throttledAction = () => { clearTimeout(throttleTimer); throttleTimer = undefined; - updateRootSuite(throttleData!.rootSuite, throttleData!.progress); + updateRootSuite(throttleData!.config, throttleData!.rootSuite, throttleData!.progress); }; -const throttleUpdateRootSuite = (rootSuite: Suite, progress: Progress, immediate = false) => { - throttleData = { rootSuite, progress }; +const throttleUpdateRootSuite = (config: FullConfig, rootSuite: Suite, progress: Progress, immediate = false) => { + throttleData = { config, rootSuite, progress }; if (immediate) throttledAction(); else if (!throttleTimer) @@ -465,23 +477,25 @@ const refreshRootSuite = (eraseResults: boolean): Promise => { failed: 0, skipped: 0, }; + let config: FullConfig; receiver = new TeleReporterReceiver({ - onBegin: (config: FullConfig, suite: Suite) => { + onBegin: (c: FullConfig, suite: Suite) => { if (!rootSuite) rootSuite = suite; + config = c; progress.total = suite.allTests().length; progress.passed = 0; progress.failed = 0; progress.skipped = 0; - throttleUpdateRootSuite(rootSuite, progress, true); + throttleUpdateRootSuite(config, rootSuite, progress, true); }, onEnd: () => { - throttleUpdateRootSuite(rootSuite, progress, true); + throttleUpdateRootSuite(config, rootSuite, progress, true); }, onTestBegin: () => { - throttleUpdateRootSuite(rootSuite, progress); + throttleUpdateRootSuite(config, rootSuite, progress); }, onTestEnd: (test: TestCase) => { @@ -491,7 +505,7 @@ const refreshRootSuite = (eraseResults: boolean): Promise => { ++progress.failed; else ++progress.passed; - throttleUpdateRootSuite(rootSuite, progress); + throttleUpdateRootSuite(config, rootSuite, progress); }, }); return sendMessage('list', {}); diff --git a/packages/web/src/theme.ts b/packages/web/src/theme.ts index cd07541484..cdafcb7bc6 100644 --- a/packages/web/src/theme.ts +++ b/packages/web/src/theme.ts @@ -14,6 +14,8 @@ * limitations under the License. */ +import { settings } from './uiUtils'; + export function applyTheme() { if ((document as any).playwrightThemeInitialized) return; @@ -26,14 +28,14 @@ export function applyTheme() { document.body.classList.add('inactive'); }, false); - const currentTheme = localStorage.getItem('theme'); + const currentTheme = settings.getString('theme', 'light-mode'); const prefersDarkScheme = window.matchMedia('(prefers-color-scheme: dark)'); if (currentTheme === 'dark-mode' || prefersDarkScheme.matches) document.body.classList.add('dark-mode'); } export function toggleTheme() { - const oldTheme = localStorage.getItem('theme'); + const oldTheme = settings.getString('theme', 'light-mode'); let newTheme: string; if (oldTheme === 'dark-mode') newTheme = 'light-mode'; @@ -43,7 +45,7 @@ export function toggleTheme() { if (oldTheme) document.body.classList.remove(oldTheme); document.body.classList.add(newTheme); - localStorage.setItem('theme', newTheme); + settings.setString('theme', newTheme); } export function isDarkTheme() { diff --git a/packages/web/src/uiUtils.ts b/packages/web/src/uiUtils.ts index 848593c71b..c978f4161d 100644 --- a/packages/web/src/uiUtils.ts +++ b/packages/web/src/uiUtils.ts @@ -80,15 +80,41 @@ export function copy(text: string) { } export function useSetting(name: string, defaultValue: S): [S, React.Dispatch>] { - const string = localStorage.getItem(name); - let value = defaultValue; - if (string !== null) - value = JSON.parse(string); - + const value = settings.getObject(name, defaultValue); const [state, setState] = React.useState(value); const setStateWrapper = (value: React.SetStateAction) => { - localStorage.setItem(name, JSON.stringify(value)); + settings.setObject(name, value); setState(value); }; return [state, setStateWrapper]; } + +export class Settings { + getString(name: string, defaultValue: string): string { + return localStorage[name] || defaultValue; + } + + setString(name: string, value: string) { + localStorage[name] = value; + if ((window as any).saveSettings) + (window as any).saveSettings(); + } + + getObject(name: string, defaultValue: T): T { + if (!localStorage[name]) + return defaultValue; + try { + return JSON.parse(localStorage[name]); + } catch { + return defaultValue; + } + } + + setObject(name: string, value: T) { + localStorage[name] = JSON.stringify(value); + if ((window as any).saveSettings) + (window as any).saveSettings(); + } +} + +export const settings = new Settings();