311 lines
11 KiB
TypeScript
311 lines
11 KiB
TypeScript
// server.ts - Local server that receives click data from the Chrome extension
|
|
// Run with: ts-node --transpile-only server.ts
|
|
|
|
import * as http from "http";
|
|
import * as fs from "fs";
|
|
import * as path from "path";
|
|
|
|
const PORT = 3000;
|
|
const OUTPUT_FILE = path.join(__dirname, "recorded_clicks.ts");
|
|
const SUGGESTIONS_FILE = path.join(__dirname, "suggestions.txt");
|
|
|
|
// ─── Parse .po.ts file into structured data ───────────────────────────────────
|
|
|
|
interface ParsedPageObject {
|
|
filePath: string;
|
|
statics: Record<string, string>; // name -> selector value
|
|
getFunctions: ParsedGetFn[];
|
|
}
|
|
|
|
interface ParsedGetFn {
|
|
name: string;
|
|
body: string; // full body text
|
|
referencedStatics: string[]; // static variable names used in body
|
|
nccontainsText: string | null; // text in nccontains() if present
|
|
}
|
|
|
|
function parsePageObject(): ParsedPageObject {
|
|
const poFiles = fs.readdirSync(__dirname).filter(f => f.endsWith(".po.ts"));
|
|
if (poFiles.length === 0) throw new Error("No .po.ts file found!");
|
|
|
|
const filePath = path.join(__dirname, poFiles[0]);
|
|
const content = fs.readFileSync(filePath, "utf-8");
|
|
const lines = content.split("\n");
|
|
|
|
const statics: Record<string, string> = {};
|
|
const getFunctions: ParsedGetFn[] = [];
|
|
|
|
let i = 0;
|
|
while (i < lines.length) {
|
|
const line = lines[i].trimStart();
|
|
|
|
// Parse static declarations: static foo = ".some-class"
|
|
const staticMatch = line.match(/^static\s+(\w+)\s*=\s*["'](.+?)["']/);
|
|
if (staticMatch) {
|
|
statics[staticMatch[1]] = staticMatch[2];
|
|
i++;
|
|
continue;
|
|
}
|
|
|
|
// Parse get functions: get FooBar() {
|
|
const getMatch = line.match(/^get\s+(\w+)\s*\(/);
|
|
if (getMatch) {
|
|
const fnName = getMatch[1];
|
|
const bodyLines: string[] = [];
|
|
let depth = 0;
|
|
while (i < lines.length) {
|
|
const l = lines[i];
|
|
bodyLines.push(l);
|
|
for (const ch of l) {
|
|
if (ch === "{") depth++;
|
|
if (ch === "}") depth--;
|
|
}
|
|
i++;
|
|
if (depth === 0 && bodyLines.length > 1) break;
|
|
}
|
|
const body = bodyLines.join("\n");
|
|
|
|
// Find which static variables are referenced
|
|
const referencedStatics = Object.keys(statics).filter(s =>
|
|
body.includes(s)
|
|
);
|
|
|
|
// Extract nccontains text if present: nccontains(Mainpage.foo, 'some text')
|
|
const ncMatch = body.match(/nccontains\s*\([^,]+,\s*['"](.+?)['"]\s*\)/);
|
|
const nccontainsText = ncMatch ? ncMatch[1] : null;
|
|
|
|
getFunctions.push({ name: fnName, body, referencedStatics, nccontainsText });
|
|
continue;
|
|
}
|
|
|
|
i++;
|
|
}
|
|
|
|
console.log(`Parsed ${Object.keys(statics).length} statics, ${getFunctions.length} get functions.`);
|
|
return { filePath, statics, getFunctions };
|
|
}
|
|
|
|
// ─── Matching logic ────────────────────────────────────────────────────────────
|
|
|
|
interface MatchResult {
|
|
type: "full_match" | "static_only" | "no_match";
|
|
fnName?: string; // for full_match
|
|
staticName?: string; // for static_only and no_match (if static exists)
|
|
selectorVar?: string; // for static_only (to build the new get fn)
|
|
}
|
|
|
|
function findMatch(data: any, po: ParsedPageObject): MatchResult {
|
|
const rawText = data.text ? data.text.split("\n")[0].trim() : null;
|
|
|
|
// Step 1: find matching static names by class or aria-label
|
|
const matchingStatics: string[] = [];
|
|
for (const [name, value] of Object.entries(po.statics)) {
|
|
const classMatch = value.startsWith(".") && data.classes.includes(value.slice(1));
|
|
const ariaMatch = data.ariaLabel && value === data.ariaLabel;
|
|
if (classMatch || ariaMatch) {
|
|
matchingStatics.push(name);
|
|
}
|
|
}
|
|
|
|
if (matchingStatics.length === 0) {
|
|
return { type: "no_match" };
|
|
}
|
|
|
|
// Step 2: find a get function that references one of those statics AND has matching text
|
|
for (const fn of po.getFunctions) {
|
|
const referencesMatch = fn.referencedStatics.some(s => matchingStatics.includes(s));
|
|
if (!referencesMatch) continue;
|
|
|
|
// If we have text, require nccontains to match it
|
|
if (rawText) {
|
|
if (fn.nccontainsText && fn.nccontainsText.toLowerCase() === rawText.toLowerCase()) {
|
|
return { type: "full_match", fnName: fn.name };
|
|
}
|
|
} else {
|
|
// No text — any get function referencing the static is good enough
|
|
return { type: "full_match", fnName: fn.name };
|
|
}
|
|
}
|
|
|
|
// Static matched but no get function with matching text found
|
|
return {
|
|
type: "static_only",
|
|
staticName: matchingStatics[0],
|
|
selectorVar: `Mainpage.${matchingStatics[0]}`,
|
|
};
|
|
}
|
|
|
|
// ─── File insertion ────────────────────────────────────────────────────────────
|
|
|
|
function insertGetFunction(filePath: string, getBlock: string) {
|
|
const content = fs.readFileSync(filePath, "utf-8");
|
|
const fileLines = content.split("\n");
|
|
|
|
// Find the last static line
|
|
let lastStaticIndex = -1;
|
|
for (let i = 0; i < fileLines.length; i++) {
|
|
if (fileLines[i].trimStart().startsWith("static ")) lastStaticIndex = i;
|
|
}
|
|
|
|
// Find first get after static block
|
|
let firstGetIndex = -1;
|
|
for (let i = lastStaticIndex + 1; i < fileLines.length; i++) {
|
|
if (fileLines[i].trimStart().startsWith("get ")) { firstGetIndex = i; break; }
|
|
}
|
|
|
|
if (firstGetIndex === -1) {
|
|
console.warn("Could not find first get function, aborting insert.");
|
|
return;
|
|
}
|
|
|
|
const getLines = getBlock.split("\n");
|
|
fileLines.splice(firstGetIndex, 0, "", ...getLines);
|
|
fs.writeFileSync(filePath, fileLines.join("\n"), "utf-8");
|
|
}
|
|
|
|
function insertStaticAndGet(filePath: string, staticLine: string, getBlock: string) {
|
|
const content = fs.readFileSync(filePath, "utf-8");
|
|
const fileLines = content.split("\n");
|
|
|
|
// Find last static line
|
|
let lastStaticIndex = -1;
|
|
for (let i = 0; i < fileLines.length; i++) {
|
|
if (fileLines[i].trimStart().startsWith("static ")) lastStaticIndex = i;
|
|
}
|
|
|
|
if (lastStaticIndex === -1) {
|
|
console.warn("Could not find static block, aborting insert.");
|
|
return;
|
|
}
|
|
|
|
// Find first get after static block
|
|
let firstGetIndex = -1;
|
|
for (let i = lastStaticIndex + 1; i < fileLines.length; i++) {
|
|
if (fileLines[i].trimStart().startsWith("get ")) { firstGetIndex = i; break; }
|
|
}
|
|
|
|
if (firstGetIndex === -1) {
|
|
console.warn("Could not find first get function, aborting insert.");
|
|
return;
|
|
}
|
|
|
|
// Insert static
|
|
fileLines.splice(lastStaticIndex + 1, 0, ` ${staticLine}`);
|
|
|
|
// Insert get (shifted by 1)
|
|
const getLines = getBlock.split("\n");
|
|
fileLines.splice(firstGetIndex + 1, 0, "", ...getLines);
|
|
|
|
fs.writeFileSync(filePath, fileLines.join("\n"), "utf-8");
|
|
}
|
|
|
|
// ─── Main handler ──────────────────────────────────────────────────────────────
|
|
|
|
function buildGetFnName(data: any): string {
|
|
const rawText = data.text ? data.text.split("\n")[0].trim() : null;
|
|
const firstClass = data.classes.length > 0 ? data.classes[0] : null;
|
|
const baseName = data.ariaLabel
|
|
? data.ariaLabel.replace(/\s+/g, "").toLowerCase()
|
|
: firstClass
|
|
? firstClass.replace(/[-_.]/g, "").toLowerCase()
|
|
: "unknown";
|
|
return rawText ? baseName + rawText.replace(/\s+/g, "").toLowerCase() : baseName;
|
|
}
|
|
|
|
function handleClick(data: any) {
|
|
const po = parsePageObject();
|
|
const match = findMatch(data, po);
|
|
const rawText = data.text ? data.text.split("\n")[0].trim() : null;
|
|
const hasText = rawText && rawText.length > 0;
|
|
const firstClass = data.classes.length > 0 ? data.classes[0] : null;
|
|
|
|
if (match.type === "full_match") {
|
|
// Perfect — use existing function
|
|
console.log(` >> Full match: mainpage.${match.fnName}.click();`);
|
|
fs.appendFileSync(OUTPUT_FILE, `mainpage.${match.fnName}.click();\n`);
|
|
|
|
} else if (match.type === "static_only") {
|
|
// Static exists, just create the get function
|
|
const getFnName = buildGetFnName(data);
|
|
const getBody = hasText
|
|
? ` return cy.nccontains(${match.selectorVar}, '${rawText}');`
|
|
: ` return cy.get(${match.selectorVar});`;
|
|
const getBlock = ` get ${getFnName}() {\n${getBody}\n }`;
|
|
|
|
console.log(` >> Static match, inserting get function: ${getFnName}`);
|
|
insertGetFunction(po.filePath, getBlock);
|
|
fs.appendFileSync(OUTPUT_FILE, `mainpage.${getFnName}.click();\n`);
|
|
fs.appendFileSync(SUGGESTIONS_FILE, `// Inserted get function (static already existed)\n${getBlock}\n// ----\n\n`);
|
|
|
|
} else {
|
|
// No match at all — create both static and get function
|
|
const baseName = data.ariaLabel
|
|
? data.ariaLabel.replace(/\s+/g, "").toLowerCase()
|
|
: firstClass
|
|
? firstClass.replace(/[-_.]/g, "").toLowerCase()
|
|
: "unknown";
|
|
const getFnName = buildGetFnName(data);
|
|
const selectorVar = data.ariaLabel ? `Mainpage.${baseName}aria` : `Mainpage.${baseName}class`;
|
|
const staticValue = data.ariaLabel ? `"[aria-label='${data.ariaLabel}']"` : `".${firstClass}"`;
|
|
const staticName = data.ariaLabel ? `${baseName}aria` : `${baseName}class`;
|
|
const staticLine = `static ${staticName} = ${staticValue}`;
|
|
const getBody = hasText
|
|
? ` return cy.nccontains(${selectorVar}, '${rawText}');`
|
|
: ` return cy.get(${selectorVar});`;
|
|
const getBlock = ` get ${getFnName}() {\n${getBody}\n }`;
|
|
|
|
console.log(` >> No match, inserting static + get function: ${getFnName}`);
|
|
insertStaticAndGet(po.filePath, staticLine, getBlock);
|
|
fs.appendFileSync(OUTPUT_FILE, `mainpage.${getFnName}.click();\n`);
|
|
fs.appendFileSync(SUGGESTIONS_FILE, `// Inserted static + get\n ${staticLine}\n${getBlock}\n// ----\n\n`);
|
|
}
|
|
}
|
|
|
|
// ─── Server ────────────────────────────────────────────────────────────────────
|
|
|
|
const server = http.createServer((req, res) => {
|
|
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
|
|
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
|
|
|
if (req.method === "OPTIONS") {
|
|
res.writeHead(204);
|
|
res.end();
|
|
return;
|
|
}
|
|
|
|
if (req.method === "POST" && req.url === "/record") {
|
|
let body = "";
|
|
req.on("data", (chunk) => (body += chunk));
|
|
req.on("end", () => {
|
|
try {
|
|
const data = JSON.parse(body);
|
|
|
|
console.log("\n--- Click Recorded ---");
|
|
console.log("Tag: ", data.tag);
|
|
console.log("ID: ", data.id);
|
|
console.log("Aria-label: ", data.ariaLabel);
|
|
console.log("Classes: ", data.classes.join(", "));
|
|
console.log("Text: ", data.text);
|
|
|
|
handleClick(data);
|
|
|
|
console.log("----------------------");
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({ status: "ok" }));
|
|
} catch (err: any) {
|
|
console.error("Failed to parse body:", err.message);
|
|
res.writeHead(400);
|
|
res.end("Bad request");
|
|
}
|
|
});
|
|
} else {
|
|
res.writeHead(404);
|
|
res.end("Not found");
|
|
}
|
|
});
|
|
|
|
server.listen(PORT, () => {
|
|
console.log(`Cypress Recorder server running on http://localhost:${PORT}`);
|
|
console.log(`Watching for clicks...`);
|
|
}); |