Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/app/src/components/sidebar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export class DevtoolsSidebar extends Element {
border-right: 1px solid var(--vscode-panel-border) !important;
display: flex;
flex-direction: column;
height: 100%;
}
`
]
Expand Down
17 changes: 15 additions & 2 deletions packages/app/src/components/sidebar/explorer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry {
display: flex;
flex-direction: column;
min-height: 0;
flex: 1 1 auto;
}

header {
Expand Down Expand Up @@ -120,7 +121,9 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry {
runAll: detail.uid === '*',
framework: this.#getFramework(),
specFile: detail.specFile || this.#deriveSpecFile(detail),
configFile: this.#getConfigPath()
configFile: this.#getConfigPath(),
rerunCommand: this.#getRerunCommand(),
launchCommand: this.#getLaunchCommand()
}
await this.#postToBackend('/api/tests/run', payload)
}
Expand Down Expand Up @@ -199,7 +202,9 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry {
entryType: 'suite',
runAll: true,
framework: this.#getFramework(),
configFile: this.#getConfigPath()
configFile: this.#getConfigPath(),
rerunCommand: this.#getRerunCommand(),
launchCommand: this.#getLaunchCommand()
})
}

Expand Down Expand Up @@ -277,6 +282,14 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry {
return options?.configFilePath || options?.configFile
}

#getRerunCommand(): string | undefined {
return this.#getRunnerOptions()?.rerunCommand
}

#getLaunchCommand(): string | undefined {
return this.#getRunnerOptions()?.launchCommand
}

#renderEntry(entry: TestEntry): TemplateResult {
return html`
<wdio-test-entry
Expand Down
2 changes: 2 additions & 0 deletions packages/app/src/components/sidebar/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ export interface RunnerOptions {
configFile?: string
configFilePath?: string
runCapabilities?: Partial<RunCapabilities>
rerunCommand?: string
launchCommand?: string
}

export interface TestRunDetail {
Expand Down
48 changes: 40 additions & 8 deletions packages/app/src/controller/DataManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,9 +317,15 @@ export class DataManagerController implements ReactiveController {
}

if (scope === 'clearExecutionData') {
const { uid, entryType } =
const { uid, entryType, clearSuiteTree } =
data as SocketMessage<'clearExecutionData'>['data']
this.clearExecutionData(uid, entryType)
if (clearSuiteTree) {
this.suitesContextProvider.setValue([])
this.#activeRerunTestUid = undefined
rerunState.activeRerunSuiteUid = undefined
this.#lastSeenRunTimestamp = 0
}
this.#host.requestUpdate()
return
}
Expand Down Expand Up @@ -542,14 +548,22 @@ export class DataManagerController implements ReactiveController {

#handleReplaceCommand(oldTimestamp: number, newCommand: CommandLog) {
const current = this.commandsContextProvider.value || []
// Find the last entry with the matching timestamp (most recent retry)
const idx = current.map((c) => c.timestamp).lastIndexOf(oldTimestamp)
// Prefer stable `id` — chained selenium calls share a millisecond.
let idx = -1
const newId = (newCommand as CommandLog & { id?: number }).id
if (typeof newId === 'number') {
idx = current.findIndex(
(c) => (c as CommandLog & { id?: number }).id === newId
)
}
if (idx === -1) {
idx = current.map((c) => c.timestamp).lastIndexOf(oldTimestamp)
}
if (idx !== -1) {
const updated = [...current]
updated[idx] = newCommand
this.commandsContextProvider.setValue(updated)
} else {
// No matching entry found — just append
this.commandsContextProvider.setValue([...current, newCommand])
}
}
Expand All @@ -562,10 +576,28 @@ export class DataManagerController implements ReactiveController {
}

#handleNetworkRequestsUpdate(data: NetworkRequest[]) {
this.networkRequestsContextProvider.setValue([
...(this.networkRequestsContextProvider.value || []),
...data
])
const current = this.networkRequestsContextProvider.value || []
const byId = new Map<string, number>()
current.forEach((r, i) => {
if (r?.id) {
byId.set(r.id, i)
}
})
const next = [...current]
for (const incoming of data) {
if (!incoming?.id) {
next.push(incoming)
continue
}
const existingIdx = byId.get(incoming.id)
if (existingIdx !== undefined) {
next[existingIdx] = incoming
} else {
byId.set(incoming.id, next.length)
next.push(incoming)
}
}
this.networkRequestsContextProvider.setValue(next)
}

#handleMetadataUpdate(data: Metadata) {
Expand Down
6 changes: 5 additions & 1 deletion packages/app/src/controller/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,11 @@ export interface SocketMessage<
data: T extends keyof TraceLog
? TraceLog[T]
: T extends 'clearExecutionData'
? { uid?: string; entryType?: 'suite' | 'test' }
? {
uid?: string
entryType?: 'suite' | 'test'
clearSuiteTree?: boolean
}
: T extends 'replaceCommand'
? { oldTimestamp: number; command: CommandLog }
: unknown
Expand Down
12 changes: 12 additions & 0 deletions packages/selenium-devtools/example/cucumber-test/cucumber.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"default": {
"import": [
"example/cucumber-test/features/support/setup.js",
"example/cucumber-test/features/support/world.js",
"example/cucumber-test/features/support/steps.js"
],
"paths": ["example/cucumber-test/features/*.feature"],
"publishQuiet": true,
"format": ["progress"]
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
Feature: the-internet login flow

Scenario: logs in with valid credentials and lands on /secure
Given I am on the login page
When I enter username "tomsmith" and password "SuperSecretPassword!"
And I submit the login form
Then I should be on the secure page
And I should see a flash message matching "You logged into a secure area"

Scenario: rejects invalid username with an error flash
Given I am on the login page
When I enter username "foobar" and password "barfoo"
And I submit the login form
Then I should see a flash message matching "Your username is invalid"
And I should still be on the login page
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/**
* Loads the @wdio/selenium-devtools plugin and configures it.
*
* Run from the package root: pnpm example:cucumber
*/

import { DevTools } from '@wdio/selenium-devtools'

DevTools.configure({
screencast: { enabled: true, quality: 70, maxWidth: 1280, maxHeight: 720 },
headless: true
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { strict as assert } from 'node:assert'
import { Given, When, Then } from '@cucumber/cucumber'
import { By, until } from 'selenium-webdriver'

Given('I am on the login page', async function () {
await this.driver.get('https://the-internet.herokuapp.com/login')
})

When(
'I enter username {string} and password {string}',
async function (username, password) {
await this.driver.findElement(By.id('username')).sendKeys(username)
await this.driver.findElement(By.id('password')).sendKeys(password)
}
)

When('I submit the login form', async function () {
await this.driver.findElement(By.css('button[type="submit"]')).click()
})

Then('I should be on the secure page', async function () {
await this.driver.wait(until.urlContains('/secure'), 10_000)
})

Then(
'I should see a flash message matching {string}',
async function (pattern) {
const flash = await this.driver.wait(
until.elementLocated(By.id('flash')),
10_000
)
const text = await flash.getText()
assert.match(text, new RegExp(pattern, 'i'))
await this.driver.sleep(1500)
}
)

Then('I should still be on the login page', async function () {
const url = await this.driver.getCurrentUrl()
assert.match(url, /\/login$/)
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { setWorldConstructor, World, Before, After, setDefaultTimeout } from '@cucumber/cucumber'
import { Builder } from 'selenium-webdriver'

setDefaultTimeout(60_000)

class CustomWorld extends World {
constructor(options) {
super(options)
this.driver = null
}
}

setWorldConstructor(CustomWorld)

Before(async function () {
this.driver = await new Builder().forBrowser('chrome').build()
})

After(async function () {
if (this.driver) {
await this.driver.quit()
this.driver = null
}
})
6 changes: 6 additions & 0 deletions packages/selenium-devtools/example/jest-test/jest.config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"testEnvironment": "node",
"testMatch": ["<rootDir>/test/*.test.js"],
"testTimeout": 60000,
"transform": {}
}
60 changes: 60 additions & 0 deletions packages/selenium-devtools/example/jest-test/test/example.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* Login flow against the-internet.herokuapp.com under Jest.
*
* Run from the package root: pnpm example:jest
*/

import { DevTools } from '@wdio/selenium-devtools'

Check failure on line 7 in packages/selenium-devtools/example/jest-test/test/example.test.js

View workflow job for this annotation

GitHub Actions / build

packages/selenium-devtools/example/jest-test/test/example.test.js

Error: Failed to resolve entry for package "@wdio/selenium-devtools". The package may have incorrect main/module/exports specified in its package.json. ❯ packages/selenium-devtools/example/jest-test/test/example.test.js:7:1
import { Builder, By, until } from 'selenium-webdriver'

const LOGIN_URL = 'https://the-internet.herokuapp.com/login'
const VALID_USERNAME = 'tomsmith'
const VALID_PASSWORD = 'SuperSecretPassword!'

DevTools.configure({
screencast: { enabled: true, quality: 70, maxWidth: 1280, maxHeight: 720 },
headless: true
})

describe('the-internet login flow', () => {
let driver

beforeEach(async () => {
driver = await new Builder().forBrowser('chrome').build()
}, 60000)

afterEach(async () => {
if (driver) {
await driver.quit()
}
})

test('logs in with valid credentials and lands on /secure', async () => {
await driver.get(LOGIN_URL)
await driver.findElement(By.id('username')).sendKeys(VALID_USERNAME)
await driver.findElement(By.id('password')).sendKeys(VALID_PASSWORD)
await driver.findElement(By.css('button[type="submit"]')).click()

await driver.wait(until.urlContains('/secure'), 10000)
const flash = await driver.wait(until.elementLocated(By.id('flash')), 10000)
const flashText = await flash.getText()
expect(flashText).toMatch(/You logged into a secure area/i)

await driver.sleep(1500)
}, 60000)

test('rejects invalid username with an error flash', async () => {
await driver.get(LOGIN_URL)
await driver.findElement(By.id('username')).sendKeys('foobar')
await driver.findElement(By.id('password')).sendKeys('barfoo')
await driver.findElement(By.css('button[type="submit"]')).click()

const flash = await driver.wait(until.elementLocated(By.id('flash')), 10000)
const flashText = await flash.getText()
expect(flashText).toMatch(/Your username is invalid/i)
const url = await driver.getCurrentUrl()
expect(url).toMatch(/\/login$/)

await driver.sleep(1500)
}, 60000)
})
43 changes: 43 additions & 0 deletions packages/selenium-devtools/example/mocha-test/test/example.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* Smoke test for @wdio/selenium-devtools.
*
* Run from the package root: pnpm example:mocha
*/

import { strict as assert } from 'node:assert'
import { Builder, By, until } from 'selenium-webdriver'

Check failure on line 8 in packages/selenium-devtools/example/mocha-test/test/example.test.js

View workflow job for this annotation

GitHub Actions / build

packages/selenium-devtools/example/mocha-test/test/example.test.js

Error: Cannot find package 'selenium-webdriver' imported from /home/runner/work/devtools/devtools/packages/selenium-devtools/example/mocha-test/test/example.test.js ❯ packages/selenium-devtools/example/mocha-test/test/example.test.js:8:1 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { code: 'ERR_MODULE_NOT_FOUND' }
import { DevTools } from '@wdio/selenium-devtools'

DevTools.configure({
screencast: { enabled: true, quality: 70, maxWidth: 1280, maxHeight: 720 },
headless: true
})

describe('selenium-devtools smoke test', function () {
let driver

before(async function () {
driver = await new Builder().forBrowser('chrome').build()
})

after(async function () {
if (driver) {
await driver.quit()
}
})

it('loads example.com and reads the heading', async function () {
await driver.get('https://example.com')
await driver.sleep(1500)
const heading = await driver.wait(until.elementLocated(By.css('h1')), 10000)
const text = await heading.getText()
assert.equal(text, 'Example Domain')
})

it('navigates and reads the page title', async function () {
await driver.get('https://example.org')
await driver.sleep(1500)
const title = await driver.getTitle()
assert.match(title, /Example/i)
})
})
7 changes: 7 additions & 0 deletions packages/selenium-devtools/example/vitest-test/setup.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { DevTools } from '@wdio/selenium-devtools'

DevTools.configure({
rerunCommand: 'npx vitest --testNamePattern "{{testName}}"',
screencast: { enabled: true, quality: 70, maxWidth: 1280, maxHeight: 720 },
headless: true
})
Loading
Loading