diff --git a/qkf/driverFactory.js b/qkf/driverFactory.js new file mode 100644 index 0000000..66455a8 --- /dev/null +++ b/qkf/driverFactory.js @@ -0,0 +1,64 @@ +// qkf/selenium/driverFactory.js +const { Builder } = require("selenium-webdriver"); + +function envBool(v, def = false) { + if (v == null) return def; + return String(v).toLowerCase() === "true" || String(v) === "1"; +} + +/** + * Driver factory: + * - Local: uses selenium-webdriver Builder().forBrowser() + * - Remote: if SELENIUM_REMOTE_URL is set, uses .usingServer(url) + * + * Env: + * - QKF_BROWSER=chrome|firefox|MicrosoftEdge + * - QKF_HEADLESS=true|false + * - SELENIUM_REMOTE_URL=http://localhost:4444/wd/hub (optional) + */ +async function createDriver() { + const browser = process.env.QKF_BROWSER || "chrome"; + const headless = envBool(process.env.QKF_HEADLESS, false); + const remoteUrl = process.env.SELENIUM_REMOTE_URL || ""; + + const builder = new Builder().forBrowser(browser); + + if (remoteUrl) builder.usingServer(remoteUrl); + + // Headless options (best-effort; safe if missing) + try { + if (browser.toLowerCase() === "chrome") { + const chrome = require("selenium-webdriver/chrome"); + const opts = new chrome.Options(); + if (headless) opts.addArguments("--headless=new"); + opts.addArguments("--window-size=1440,900"); + builder.setChromeOptions(opts); + } else if (browser.toLowerCase() === "firefox") { + const firefox = require("selenium-webdriver/firefox"); + const opts = new firefox.Options(); + if (headless) opts.addArguments("-headless"); + builder.setFirefoxOptions(opts); + } else if (browser.toLowerCase().includes("edge")) { + const edge = require("selenium-webdriver/edge"); + const opts = new edge.Options(); + if (headless) opts.addArguments("--headless=new"); + opts.addArguments("--window-size=1440,900"); + builder.setEdgeOptions(opts); + } + } catch { + // If browser-specific options modules aren't available, continue without them. + } + + const driver = await builder.build(); + + // Default timeouts (adjust as needed) + await driver.manage().setTimeouts({ + implicit: 0, + pageLoad: 60000, + script: 30000 + }); + + return driver; +} + +module.exports = { createDriver }; diff --git a/qkf/loaders.js b/qkf/loaders.js new file mode 100644 index 0000000..41a079f --- /dev/null +++ b/qkf/loaders.js @@ -0,0 +1,135 @@ +/** + * qkf/loaders.js + * Loads your existing (non-standard) JS files without modifying them: + * - pages/*.js may contain "export const X = ..." and use "By" + * - step-definitons/*.js may call global WHEN/AND/etc and use qkf/pages globals + */ + +const fs = require("fs"); +const path = require("path"); +const vm = require("vm"); + +function readText(p) { + return fs.readFileSync(p, "utf-8"); +} + +function listJsFiles(dir) { + if (!fs.existsSync(dir)) return []; + return fs + .readdirSync(dir) + .filter((f) => f.toLowerCase().endsWith(".js")) + .map((f) => path.join(dir, f)) + .sort(); +} + +function createBy() { + // Try selenium-webdriver's By. If not present, return a safe fallback. + try { + const { By } = require("selenium-webdriver"); + return By; + } catch { + return { + id: (v) => ({ using: "id", value: v }), + xpath: (v) => ({ using: "xpath", value: v }), + css: (v) => ({ using: "css", value: v }), + name: (v) => ({ using: "name", value: v }), + }; + } +} + +/** + * Transforms tiny ESM snippets into CommonJS exports: + * - "export const Login = ..." -> "exports.Login = ..." + * Only handles the patterns present in your zip (simple export const). + */ +function transformEsmExportsToCjs(src) { + return String(src) + .replace(/^\s*export\s+const\s+([A-Za-z_$][\w$]*)\s*=\s*/gm, "exports.$1 = "); +} + +/** + * Runs a JS file in a vm context and returns module.exports/exports. + */ +function runFileInVm(filePath, { injectedGlobals = {}, filenameLabel } = {}) { + const codeRaw = readText(filePath); + const code = transformEsmExportsToCjs(codeRaw); + + const module = { exports: {} }; + const exports = module.exports; + + const sandbox = { + module, + exports, + require, + __filename: filePath, + __dirname: path.dirname(filePath), + console, + + // ✅ Node globals your step files may rely on + process, + Buffer, + setTimeout, + clearTimeout, + setInterval, + clearInterval, + + ...injectedGlobals, + }; + + vm.createContext(sandbox); + + const wrapped = `(function(){\n${code}\n})();`; + vm.runInContext(wrapped, sandbox, { + filename: filenameLabel || filePath, + displayErrors: true, + }); + + return sandbox.module.exports; +} + +/** + * Loads all page selector modules from pagesDir. + * Returns a flat object like { Login: {...}, OtherPage: {...} } + */ +function loadPages(pagesDir) { + const By = createBy(); + const files = listJsFiles(pagesDir); + + const pages = {}; + for (const f of files) { + const exp = runFileInVm(f, { injectedGlobals: { By }, filenameLabel: `pages:${path.basename(f)}` }); + Object.assign(pages, exp); + } + return pages; +} + +/** + * Loads all step-definition modules from stepDefsDir and executes them. + * Your current step file just calls WHEN/AND directly at top-level, so executing it is enough. + */ +function loadStepDefinitions(stepDefsDir, registry, { qkf, pages } = {}) { + const files = listJsFiles(stepDefsDir); + + for (const f of files) { + runFileInVm(f, { + filenameLabel: `steps:${path.basename(f)}`, + injectedGlobals: { + // bind DSL to registry + WHEN: registry.WHEN, + AND: registry.AND, + THEN: registry.THEN, + GIVEN: registry.GIVEN, + BUT: registry.BUT, + + // inject the things your step files expect + qkf, + ...pages, // makes "Login" available directly if they do "Login.username" + }, + }); + } +} + +module.exports = { + loadPages, + loadStepDefinitions, +}; diff --git a/qkf/qkf.js b/qkf/qkf.js new file mode 100644 index 0000000..a4d91f2 --- /dev/null +++ b/qkf/qkf.js @@ -0,0 +1,76 @@ +// qkf/qkf.js +let _impl = null; + +// Tracks pending async actions triggered inside a step definition +let _pending = null; + +function requireImpl() { + if (!_impl) { + throw new Error("QKF Selenium is not initialized. Did you call runtime.beforeEachTest()?"); + } + return _impl; +} + +function setImpl(impl) { + _impl = impl; +} + +function clearImpl() { + _impl = null; + _pending = null; +} + +/** + * Called by the registry before executing a step-definition. + * Resets the pending promise list. + */ +function __beginStep() { + _pending = []; +} + +/** + * Called by the registry after the step-definition returns. + * Waits for all async actions triggered during the step. + */ +async function __endStep() { + const pending = _pending || []; + _pending = null; + + if (!pending.length) return; + + // Ensure we surface failures to Mocha + const results = await Promise.allSettled(pending); + const rejected = results.filter((r) => r.status === "rejected"); + if (rejected.length) { + // Throw the first error (keeps stack readable) + throw rejected[0].reason; + } +} + +/** + * Track a promise so it can be awaited even if caller forgets `await`. + */ +function track(p) { + if (_pending && p && typeof p.then === "function") _pending.push(p); + return p; +} + +const qkf = { + setImpl, + clearImpl, + + // internal hooks used by registry + __beginStep, + __endStep, + + // public api used by step-definitions + goto(url) { return track(requireImpl().goto(url)); }, + enterValue(locator, value) { return track(requireImpl().enterValue(locator, value)); }, + click(locator) { return track(requireImpl().click(locator)); }, + waitVisible(locator, timeoutMs) { return track(requireImpl().waitVisible(locator, timeoutMs)); }, + getText(locator, timeoutMs) { return track(requireImpl().getText(locator, timeoutMs)); }, + screenshotBase64() { return track(requireImpl().screenshotBase64()); }, + quit() { return track(requireImpl().quit()); } +}; + +module.exports = qkf; diff --git a/qkf/runtime.js b/qkf/runtime.js new file mode 100644 index 0000000..5f29382 --- /dev/null +++ b/qkf/runtime.js @@ -0,0 +1,54 @@ +// qkf/runtime.js +const path = require("path"); +const qkf = require("./qkf"); +const { createStepRegistry } = require("./stepRegistry"); +const { loadPages, loadStepDefinitions } = require("./loaders"); + +const { createDriver } = require("./driverFactory"); +const { createQkfSelenium } = require("./selenium/qkfSelenium"); + +function createRuntime({ rootDir } = {}) { + const base = rootDir || process.cwd(); + + const pagesDir = path.resolve(base, "pages"); + const stepDefsDir = path.resolve(base, "step-definitons"); + + const pages = loadPages(pagesDir); + + // registry is global (step defs registered once) + const registry = createStepRegistry({ qkf, pages }); + + // registers steps by executing the user's files in vm + loadStepDefinitions(stepDefsDir, registry, { qkf, pages }); + + async function beforeEachTest() { + const driver = await createDriver(); + const impl = createQkfSelenium(driver); + qkf.setImpl(impl); + return { driver }; + } + + async function afterEachTest({ test, allureAttachment } = {}) { + try { + if (test && test.state === "failed") { + // screenshot as base64 png + const pngBase64 = await qkf.screenshotBase64(); + if (typeof allureAttachment === "function") { + // attach raw PNG buffer to Allure + const buf = Buffer.from(pngBase64, "base64"); + await allureAttachment("Screenshot (failure)", buf, "image/png"); + } + } + } catch (e) { + // don't crash teardown for reporting failures + // console.warn("[qkf] failed to attach screenshot:", e?.message || e); + } finally { + try { await qkf.quit(); } catch {} + qkf.clearImpl(); + } + } + + return { qkf, pages, registry, beforeEachTest, afterEachTest }; +} + +module.exports = { createRuntime }; diff --git a/qkf/stepRegistry.js b/qkf/stepRegistry.js new file mode 100644 index 0000000..e7734a9 --- /dev/null +++ b/qkf/stepRegistry.js @@ -0,0 +1,87 @@ +// qkf/stepRegistry.js + +function escapeRegex(s) { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function normalizeText(t) { + return String(t || "").trim().replace(/\s+/g, " "); +} + +function patternToRegex(pattern) { + const parts = String(pattern).split("{string}"); + const escaped = parts.map((p) => escapeRegex(normalizeText(p))); + const joined = escaped.join("\\s+(.+?)\\s+"); + const final = "^" + joined.replace(/\s+/g, "\\s+") + "$"; + return new RegExp(final, "i"); +} + +function createStepRegistry(initialCtx = {}) { + const defs = []; + + function register(pattern, fn, meta = {}) { + defs.push({ + pattern: String(pattern), + regex: patternToRegex(pattern), + fn, + meta, + }); + } + + function WHEN(pattern, fn) { register(pattern, fn, { keyword: "WHEN" }); } + function AND(pattern, fn) { register(pattern, fn, { keyword: "AND" }); } + function THEN(pattern, fn) { register(pattern, fn, { keyword: "THEN" }); } + function GIVEN(pattern, fn){ register(pattern, fn, { keyword: "GIVEN" }); } + function BUT(pattern, fn) { register(pattern, fn, { keyword: "BUT" }); } + + async function run(stepText, extraCtx = {}) { + const text = normalizeText(stepText); + + for (const d of defs) { + const m = d.regex.exec(text); + if (!m) continue; + + const captures = m.slice(1).map((x) => normalizeText(x)); + const ctx = { ...initialCtx, ...extraCtx }; + + // ✅ Begin step tracking so QKF calls are awaited even if step defs don't `await` + if (ctx.qkf && typeof ctx.qkf.__beginStep === "function") ctx.qkf.__beginStep(); + + try { + // Step defs get (...captures, ctx) + const res = d.fn.apply(null, [...captures, ctx]); + // Await the step function itself (if it's async) + await res; + // ✅ Then await all QKF actions triggered during the step + if (ctx.qkf && typeof ctx.qkf.__endStep === "function") await ctx.qkf.__endStep(); + return; + } catch (err) { + // try to flush pending actions so failures surface cleanly + try { + if (ctx.qkf && typeof ctx.qkf.__endStep === "function") await ctx.qkf.__endStep(); + } catch (_) { + // ignore secondary errors + } + throw err; + } + } + + const available = defs.map((d) => `- ${d.pattern}`).join("\n"); + throw new Error( + `No step definition matched:\n "${text}"\n\nAvailable definitions:\n${available}` + ); + } + + return { + defs, + register, + run, + WHEN, + AND, + THEN, + GIVEN, + BUT, + }; +} + +module.exports = { createStepRegistry };