Created
July 27, 2025 08:49
-
-
Save aabccd021/aefa0e513ee20dc6c4f4bd9ab5b643f1 to your computer and use it in GitHub Desktop.
Netero playwright
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import * as fs from "node:fs"; | |
import type { Locator, Page } from "playwright"; | |
export type FormInput = | |
| { | |
type: "text"; | |
value: string; | |
} | |
| { | |
type: "radio"; | |
value: string; | |
} | |
| { | |
type: "checkbox"; | |
checked: boolean; | |
}; | |
export type Action = | |
| { | |
action: "goto-url"; | |
value: string; | |
} | |
| { | |
action: "goto"; | |
xpath: string; | |
} | |
| { | |
action: "submit"; | |
button?: string; | |
data?: Record<string, { type: "text"; value: string }>; | |
} | |
| { | |
action: "time-advance"; | |
value: number; | |
} | |
| { | |
action: "assert-url"; | |
expected: string; | |
} | |
| { | |
action: "assert-attribute"; | |
xpath: string; | |
attribute: string; | |
expected: string; | |
} | |
| { | |
action: "assert-text"; | |
xpath: string; | |
expected: string; | |
}; | |
export type Scenario = { | |
prev: string; | |
steps: string[]; | |
}; | |
export type Config = { | |
steps: Record<string, Action>; | |
scenarios: Record<string, Scenario>; | |
}; | |
async function handleInput( | |
form: Locator, | |
name: string, | |
input: FormInput, | |
): Promise<void> { | |
if (input.type === "text") { | |
const element = form.locator(`//input[@name='${name}']`); | |
await element.fill(input.value); | |
return; | |
} | |
if (input.type === "checkbox") { | |
const element = form.locator(`//input[@name='${name}']`); | |
if (input.checked) { | |
await element.check(); | |
} else { | |
await element.uncheck(); | |
} | |
return; | |
} | |
if (input.type === "radio") { | |
const element = form.locator( | |
`//input[@name='${name}' and @value='${input.value}']`, | |
); | |
await element.check(); | |
return; | |
} | |
input satisfies never; | |
throw new Error(`Unknown input type: ${JSON.stringify(input)}`); | |
} | |
export async function handleAction( | |
neteroState: string, | |
page: Page, | |
action: Action, | |
): Promise<void> { | |
if (action.action === "goto-url") { | |
await page.goto(action.value); | |
return; | |
} | |
if (action.action === "goto") { | |
await page.locator(action.xpath).click(); | |
return; | |
} | |
if (action.action === "submit") { | |
const form = page.locator("//form"); | |
for (const [name, input] of Object.entries(action.data ?? {})) { | |
await handleInput(form, name, input); | |
} | |
const submitButton = | |
action.button !== undefined | |
? page.locator(action.button) | |
: form.locator("//button[@type='submit' or @action='submit']"); | |
await submitButton.click(); | |
return; | |
} | |
if (action.action === "time-advance") { | |
const oldTimeStr = await fs.promises.readFile( | |
`${neteroState}/now.txt`, | |
"utf-8", | |
); | |
const oldTime = parseInt(oldTimeStr, 10); | |
const newTime = oldTime + action.value; | |
await fs.promises.writeFile(`${neteroState}/now.txt`, newTime.toString()); | |
return; | |
} | |
if (action.action === "assert-url") { | |
const url = page.url(); | |
if (!new RegExp(action.expected).test(url)) { | |
throw new Error(`Expected URL ${url} to match ${action.expected}`); | |
} | |
return; | |
} | |
if (action.action === "assert-attribute") { | |
const attributeValue = await page | |
.locator(action.xpath) | |
.getAttribute(action.attribute); | |
if (attributeValue === null) { | |
throw new Error( | |
`Attribute "${action.attribute}" not found for element at "${action.xpath}"`, | |
); | |
} | |
if (!new RegExp(action.expected).test(attributeValue)) { | |
throw new Error( | |
`Expected attribute "${action.attribute}" to match "${action.expected}", but got "${attributeValue}"`, | |
); | |
} | |
return; | |
} | |
if (action.action === "assert-text") { | |
const textContent = await page.locator(action.xpath).textContent(); | |
if (textContent === null) { | |
throw new Error( | |
`Text content not found for element at "${action.xpath}"`, | |
); | |
} | |
if (!new RegExp(action.expected).test(textContent)) { | |
throw new Error( | |
`Expected text content "${textContent}" to match "${action.expected}"`, | |
); | |
} | |
return; | |
} | |
action satisfies never; | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import * as fs from "node:fs"; | |
import * as os from "node:os"; | |
import * as util from "node:util"; | |
import { chromium } from "playwright"; | |
import { type Config, handleAction } from "./action"; | |
let theme: "light" | "dark" | undefined; | |
const { values: args } = util.parseArgs({ | |
args: process.argv.slice(2), | |
options: { | |
theme: { | |
type: "string", | |
}, | |
config: { | |
type: "string", | |
}, | |
scenario: { | |
type: "string", | |
}, | |
}, | |
strict: true, | |
allowPositionals: true, | |
}); | |
if ( | |
args.theme === "light" || | |
args.theme === "dark" || | |
args.theme === undefined | |
) { | |
theme = args.theme; | |
} else { | |
throw new Error( | |
`Invalid theme: ${args.theme}. Must be "light", "dark", or undefined.`, | |
); | |
} | |
if (args.config === undefined) { | |
throw new Error("No actions provided. Use --config to specify them."); | |
} | |
if (args.scenario === undefined) { | |
throw new Error("No scenario provided. Use --scenario to specify it."); | |
} | |
const neteroState = process.env["NETERO_STATE"]; | |
if (neteroState === undefined) { | |
throw new Error("NETERO_STATE environment variable is not set."); | |
} | |
const configStr = fs.readFileSync(args.config, "utf-8"); | |
const config: Config = JSON.parse(configStr); | |
const scenario = config.scenarios[args.scenario]; | |
if (scenario === undefined) { | |
throw new Error(`Scenario "${args.scenario}" not found in config.`); | |
} | |
const activeBrowser = fs.readFileSync( | |
`${neteroState}/active-browser.txt`, | |
"utf-8", | |
); | |
const activeTab = fs.readFileSync(`${neteroState}/active-tab.txt`, "utf-8"); | |
const dataDir = os.tmpdir(); | |
const browser = await chromium.launchPersistentContext(dataDir, { | |
headless: true, | |
colorScheme: theme, | |
}); | |
browser.on("close", () => { | |
process.exit(0); | |
}); | |
const cookieFile = `${neteroState}/browser/${activeBrowser}/cookie.json`; | |
const urlFile = `${neteroState}/browser/${activeBrowser}/tab/${activeTab}/url.txt`; | |
if (fs.existsSync(cookieFile)) { | |
const cookiesStr = fs.readFileSync(cookieFile, "utf-8"); | |
const cookies = JSON.parse(cookiesStr); | |
await browser.addCookies(cookies); | |
} | |
const page = await browser.newPage(); | |
if (fs.existsSync(urlFile)) { | |
const url = fs.readFileSync(urlFile, "utf-8"); | |
await page.goto(url); | |
} | |
for (const step of scenario.steps) { | |
const action = config.steps[step]; | |
if (action === undefined) { | |
throw new Error(`Action "${step}" not found in config.`); | |
} | |
await handleAction(neteroState, page, action); | |
} | |
const url = page.url(); | |
const cookies = await browser.cookies(); | |
await Promise.all([ | |
fs.promises.writeFile( | |
`${neteroState}/browser/${activeBrowser}/tab/${activeTab}/url.txt`, | |
url, | |
), | |
fs.promises.writeFile( | |
`${neteroState}/browser/${activeBrowser}/cookie.json`, | |
JSON.stringify(cookies, null, 2), | |
), | |
]); | |
process.exit(0); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment