Skip to content
Open
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
2 changes: 1 addition & 1 deletion .github/workflows/CD.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ jobs:
steps:
- uses: actions/setup-node@v3
with:
node-version: ^18.17
node-version: ^24.1
- name: import secrets
id: import-secrets
uses: LanceMcCarthy/akeyless-action@v3
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/CI.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-node@v3
with:
node-version: ^18.17
node-version: ^24.1
- name: install virtual display
run: sudo apt-get install xvfb
- name: npm install
Expand Down
2 changes: 1 addition & 1 deletion examples/selenium-interop/w3schools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ describe("w3schools", () => {
await driver?.quit();
});

test("navigate to js statements page", async () => {
test.skip("navigate to js statements page", async () => {
await driver.navigate().to('http://www.w3schools.com');
await sleep(3000);
await session.navigateTo('http://www.w3schools.com/js')
Expand Down
255 changes: 255 additions & 0 deletions examples/test-website/index.semantic.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
// index.semantic.test.ts
import { describe, test, expect, beforeAll, afterAll, afterEach, beforeEach } from "@jest/globals";
import { mkdir, writeFile } from "fs/promises";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { Express } from "@progress/roadkill/express.js";
import { getState, step } from "@progress/roadkill/utils.js";
import { ChromeDriver } from "@progress/roadkill/chromedriver.js";
import { Session, WebDriverClient, type Element as WebDriverElement } from "@progress/roadkill/webdriver.js";
import { semantic, discover, SemanticObject, Root, findElementsByCss, FindByCSSFields } from "@progress/roadkill/semantic.js";
import { SemanticJSX, expectSemanticMatch } from "@progress/roadkill/semantic-jsx.js";

const enableLogging = false;

@semantic()
class LoginPage extends SemanticObject {
titleText?: string | null;
userInput?: WebDriverElement | null;
passInput?: WebDriverElement | null;
submitBtn?: WebDriverElement | null;

static find() {
return findElementsByCss<FindByCSSFields<typeof LoginPage>>(
"main.page-login",
element => ({
titleText: (element.querySelector("h1")?.textContent || "").trim(),
userInput: element.querySelector("#username"),
passInput: element.querySelector("#password"),
submitBtn: element.querySelector("button[type=submit]"),
}));
}

async login({ username, password }: { username: string; password: string }) {
await this.userInput!.clear();
await this.userInput!.sendKeys(username);
await this.passInput!.clear();
await this.passInput!.sendKeys(password);
await this.submitBtn!.click();
}
}

@semantic()
class GdprFrame extends SemanticObject {
static find() {
return findElementsByCss<FindByCSSFields<typeof GdprFrame>>("iframe.overlay-frame", () => ({}));
}

async switchToFrame() {
await this.element!.switchToFrame();
}
}

@semantic()
class GdprPanel extends SemanticObject {
headerText?: string;
acceptBtn?: WebDriverElement | null;

static find() {
return findElementsByCss<FindByCSSFields<typeof GdprPanel>>(
"main.page-gdpr",
element => ({
headerText: (element.querySelector("h2")?.textContent || "").trim(),
acceptBtn: element.querySelector("#accept"),
})
);
}

async accept() {
await this.acceptBtn!.click();
}
}

@semantic()
class TocPage extends SemanticObject {
titleText?: string;
subtitleText?: string;
cardCount?: number;

static find() {
return findElementsByCss<FindByCSSFields<typeof TocPage>>(
"main.page-topics",
element => ({
titleText: (element.querySelector("h1")?.textContent || "").trim(),
subtitleText: (element.querySelector(".muted")?.textContent || "").trim(),
cardCount: element.querySelectorAll("#topics-grid .card").length,
})
);
}

cards(): TopicCard[] { return this.childrenOfType(TopicCard); }
}

@semantic()
class TopicCard extends SemanticObject {
title?: string;
description?: string;
href?: string;

static find() {
return findElementsByCss<FindByCSSFields<typeof TopicCard>>(
"#topics-grid .card",
element => ({
title: (element.querySelector("h3")?.textContent || "").trim(),
description: (element.querySelector("p.muted")?.textContent || "").trim(),
href: (element.querySelector("a[href]") as HTMLAnchorElement | null)?.href || "",
})
);
}
}

describe("test-website (semantic objects)", () => {
let chromedriver: ChromeDriver;
let express: Express;
let webdriver: WebDriverClient;
let session: Session;

beforeAll(async () => {
const { signal } = getState();

express = new Express(
Express.npmStart({ cwd: dirname(fileURLToPath(import.meta.url)), enableLogging }),
);
await express.start(signal);

chromedriver = new ChromeDriver({ args: ["--port=5033"], enableLogging });
await chromedriver.start(signal);

webdriver = new WebDriverClient({ address: chromedriver.address!, enableLogging });
session = await webdriver.newSession({
capabilities: { timeouts: { implicit: 2000 } },
});
}, 60000);

beforeEach(async () => {
await session.setTimeouts({ implicit: 2000 });
});

afterEach(async () => {
const { test } = getState();
if (test && test.status === "fail") {
const screenshot = await session.takeScreenshot();
const dir = `dist/test/${expect.getState().currentTestName}-semantic`;
await mkdir(dir, { recursive: true });
await writeFile(join(dir, "screenshot.png"), screenshot, { encoding: "base64" });
}
});

afterAll(async () => {
await session?.dispose();
await chromedriver?.dispose();
await express?.dispose();
}, 20000);

test.only("login + topics using semantic objects", async () => {

await step("navigate to local test site", () =>
session.navigateTo(express.address!)
);

let root: Root = await step("discover login page + iframe", async () => {
const r = await discover(session);

expectSemanticMatch(r,
<Root>
<LoginPage titleText="Roadkill – Test Login">
<GdprFrame />
</LoginPage>
</Root>);

expect(r.toXML(" ")).toBe(
`<Root>
<LoginPage titleText="Roadkill – Test Login">
<GdprFrame/>
</LoginPage>
</Root>`
);
return r;
});

await step("switch to GDPR iframe", async () => {
const login = root.childrenOfType(LoginPage)[0];
const frame = login.childrenOfType(GdprFrame)[0];
expect(frame).toBeDefined();
await frame.switchToFrame();
});

await step("discover & accept GDPR", async () => {
const r = await discover(session);

expectSemanticMatch(r,
<Root>
<GdprPanel headerText="GDPR Consent" />
</Root>);

expect(r.toXML(" ")).toBe(
`<Root>
<GdprPanel headerText="GDPR Consent"/>
</Root>`
);
const panel = r.childrenOfType(GdprPanel)[0];
await panel.accept();
});

await step("switch back & perform login", async () => {
await session.switchToFrame(null);
const r = await discover(session);
const login = r.childrenOfType(LoginPage)[0];
await login.login({ username: "admin", password: "1234" });
});

await step("wait for redirect to /toc", async () => {
const deadline = Date.now() + 12_000;
while (Date.now() < deadline) {
if ((await session.getCurrentUrl()).includes("/toc")) return;
await new Promise(r => setTimeout(r, 50));
}
throw new Error("Timed out waiting for /toc");
});

await step("discover topics & verify snapshot", async () => {
const r = await discover(session);

expectSemanticMatch(r,
<Root>
<TocPage cardCount={5} subtitleText="Targetable summary cards for QA flows." titleText="Roadkill – Topics">
<TopicCard description="Standalone server implementing the WebDriver protocol for Chromium browsers. Roadkill manages lifecycle, logs, and startup detection." href="https://chromedriver.chromium.org/" title="ChromeDriver" />
<TopicCard description="The W3C-standard browser automation protocol. Roadkill stays close to spec with typed commands and helpful errors." href="https://www.w3.org/TR/webdriver2/" title="WebDriver" />
<TopicCard description="Higher-level DOM discovery helpers that make selectors readable, robust, and LLM-friendly." href="http://localhost:3000/toc#semantic-objects" title="Semantic Objects" />
<TopicCard description="Checks Chrome/Node/ChromeDriver versions, manages drivers, and streamlines CI/dev workflows." href="http://localhost:3000/toc#roadkill-cli" title="Roadkill CLI" />
<TopicCard description="Expose Roadkill via the Model Context Protocol so LLMs can inspect pages and iteratively author tests." href="https://modelcontextprotocol.io/" title="MCP Integration" />
</TocPage>
</Root>);

const xml = r.toXML(" ");
expect(xml).toBe(
`<Root>
<TocPage cardCount="5" subtitleText="Targetable summary cards for QA flows." titleText="Roadkill – Topics">
<TopicCard description="Standalone server implementing the WebDriver protocol for Chromium browsers. Roadkill manages lifecycle, logs, and startup detection." href="https://chromedriver.chromium.org/" title="ChromeDriver"/>
<TopicCard description="The W3C-standard browser automation protocol. Roadkill stays close to spec with typed commands and helpful errors." href="https://www.w3.org/TR/webdriver2/" title="WebDriver"/>
<TopicCard description="Higher-level DOM discovery helpers that make selectors readable, robust, and LLM-friendly." href="http://localhost:3000/toc#semantic-objects" title="Semantic Objects"/>
<TopicCard description="Checks Chrome/Node/ChromeDriver versions, manages drivers, and streamlines CI/dev workflows." href="http://localhost:3000/toc#roadkill-cli" title="Roadkill CLI"/>
<TopicCard description="Expose Roadkill via the Model Context Protocol so LLMs can inspect pages and iteratively author tests." href="https://modelcontextprotocol.io/" title="MCP Integration"/>
</TocPage>
</Root>`
);
});

await step("screenshot topics page", async () => {
const screenshot = await session.takeScreenshot();
const dir = `dist/test/${expect.getState().currentTestName}-semantic`;
await mkdir(dir, { recursive: true });
await writeFile(join(dir, "screenshot.png"), screenshot, { encoding: "base64" });
});
}, 30000);
});
114 changes: 114 additions & 0 deletions examples/test-website/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { ChromeDriver } from "@progress/roadkill/chromedriver.js";
import { Express } from "@progress/roadkill/express.js";
import { Session, WebDriverClient, by } from "@progress/roadkill/webdriver.js";
import { describe, test, expect, beforeAll, afterAll, afterEach, beforeEach } from "@jest/globals";
import { getState, step } from "@progress/roadkill/utils.js";
import { mkdir, writeFile } from "fs/promises";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";

const enableLogging = false;

describe("test-website", () => {
let chromedriver: ChromeDriver;
let express: Express;
let webdriver: WebDriverClient;
let session: Session;

beforeAll(async () => {
const { signal } = getState();

express = new Express(
Express.npmStart({ cwd: dirname(fileURLToPath(import.meta.url)), enableLogging }),
);
await express.start(signal);

chromedriver = new ChromeDriver({ args: ["--port=5032"], enableLogging });
await chromedriver.start(signal);

webdriver = new WebDriverClient({ address: chromedriver.address!, enableLogging });
session = await webdriver.newSession({
capabilities: { timeouts: { implicit: 2000 } },
});
}, 60000);

// Before each: set implicit: 2000
beforeEach(async () => {
await session.setTimeouts({ implicit: 2000 });
});

afterEach(async () => {
const { test } = getState();
if (test && test.status === "fail") {
console.log("collecting failure artifacts for:", test.names.join(" > "));
const screenshot = await session.takeScreenshot();
const dir = `dist/test/${expect.getState().currentTestName}`;
await mkdir(dir, { recursive: true });
await writeFile(join(dir, "screenshot.png"), screenshot, { encoding: "base64" });
}
});

afterAll(async () => {
await session?.dispose();
await chromedriver?.dispose();
await express?.dispose();
}, 20000);

test("login flow", async () => {
const { signal } = getState();

await step("navigate to local test site", () =>
session.navigateTo(express.address!)
);

await step("accept GDPR overlay", async () => {
const iframe = await session.findElement(by.css(".overlay-frame"));
await iframe.switchToFrame();

const accept = await session.findElement(by.css("button:nth-of-type(1)"));
await accept.click();

// Back to main/top-level browsing context
await session.switchToFrame(null);
});

await step("perform login", async () => {
const user = await session.findElement(by.css("#username"));
await user.clear();
await user.sendKeys("admin");

const pass = await session.findElement(by.css("#password"));
await pass.clear();
await pass.sendKeys("1234");

const btn = await session.findElement(by.css("button[type=submit]"));
await btn.click();
});

// Assert navigation and page-unique content
await step("wait for Topics page", async () => {
await session.setTimeouts({ implicit: 10000 });

const grid = await session.findElement(by.css("#topics-grid"));
expect(await grid.getTagName()).toBe("div");

const url = await session.getCurrentUrl();
expect(url).toContain("/toc");

await session.setTimeouts({ implicit: 2000 });
});

await step("verify cards exist on Topics page", async () => {
const links = await session.findElements(by.css("#topics-grid .card a[href]"));
expect(links.length).toBeGreaterThanOrEqual(3);
});

await step("capture and save screenshot", async () => {
const screenshot = await session.takeScreenshot();
const dir = `dist/test/${expect.getState().currentTestName}`;
await mkdir(dir, { recursive: true });
await writeFile(join(dir, "screenshot.png"), screenshot, { encoding: "base64", signal });
});

}, 30000);
});
Loading