Merge branch 'develop' into integration_airtable
This commit is contained in:
@@ -28,29 +28,47 @@ router.post("/register", async (req, res) => {
|
||||
try {
|
||||
const { email, password } = req.body;
|
||||
|
||||
if (!email) return res.status(400).send("Email is required");
|
||||
if (!password || password.length < 6)
|
||||
return res
|
||||
.status(400)
|
||||
.send("Password is required and must be at least 6 characters");
|
||||
if (!email) {
|
||||
return res.status(400).json({
|
||||
error: "VALIDATION_ERROR",
|
||||
code: "register.validation.email_required"
|
||||
});
|
||||
}
|
||||
|
||||
if (!password || password.length < 6) {
|
||||
return res.status(400).json({
|
||||
error: "VALIDATION_ERROR",
|
||||
code: "register.validation.password_requirements"
|
||||
});
|
||||
}
|
||||
|
||||
let userExist = await User.findOne({ raw: true, where: { email } });
|
||||
if (userExist) return res.status(400).send("User already exists");
|
||||
if (userExist) {
|
||||
return res.status(400).json({
|
||||
error: "USER_EXISTS",
|
||||
code: "register.error.user_exists"
|
||||
});
|
||||
}
|
||||
|
||||
const hashedPassword = await hashPassword(password);
|
||||
|
||||
let user: any;
|
||||
|
||||
try {
|
||||
user = await User.create({ email, password: hashedPassword });
|
||||
} catch (error: any) {
|
||||
console.log(`Could not create user - ${error}`);
|
||||
return res.status(500).send(`Could not create user - ${error.message}`);
|
||||
return res.status(500).json({
|
||||
error: "DATABASE_ERROR",
|
||||
code: "register.error.creation_failed"
|
||||
});
|
||||
}
|
||||
|
||||
if (!process.env.JWT_SECRET) {
|
||||
console.log("JWT_SECRET is not defined in the environment");
|
||||
return res.status(500).send("Internal Server Error");
|
||||
return res.status(500).json({
|
||||
error: "SERVER_ERROR",
|
||||
code: "register.error.server_error"
|
||||
});
|
||||
}
|
||||
|
||||
const token = jwt.sign({ id: user.id }, process.env.JWT_SECRET as string);
|
||||
@@ -58,36 +76,60 @@ router.post("/register", async (req, res) => {
|
||||
res.cookie("token", token, {
|
||||
httpOnly: true,
|
||||
});
|
||||
|
||||
capture("maxun-oss-user-registered", {
|
||||
email: user.email,
|
||||
userId: user.id,
|
||||
registeredAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
console.log(`User registered`);
|
||||
res.json(user);
|
||||
|
||||
} catch (error: any) {
|
||||
console.log(`Could not register user - ${error}`);
|
||||
res.status(500).send(`Could not register user - ${error.message}`);
|
||||
return res.status(500).json({
|
||||
error: "SERVER_ERROR",
|
||||
code: "register.error.generic"
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/login", async (req, res) => {
|
||||
try {
|
||||
const { email, password } = req.body;
|
||||
if (!email || !password)
|
||||
return res.status(400).send("Email and password are required");
|
||||
if (password.length < 6)
|
||||
return res.status(400).send("Password must be at least 6 characters");
|
||||
if (!email || !password) {
|
||||
return res.status(400).json({
|
||||
error: "VALIDATION_ERROR",
|
||||
code: "login.validation.required_fields"
|
||||
});
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
return res.status(400).json({
|
||||
error: "VALIDATION_ERROR",
|
||||
code: "login.validation.password_length"
|
||||
});
|
||||
}
|
||||
|
||||
let user = await User.findOne({ raw: true, where: { email } });
|
||||
if (!user) return res.status(400).send("User does not exist");
|
||||
if (!user) {
|
||||
return res.status(404).json({
|
||||
error: "USER_NOT_FOUND",
|
||||
code: "login.error.user_not_found"
|
||||
});
|
||||
}
|
||||
|
||||
const match = await comparePassword(password, user.password);
|
||||
if (!match) return res.status(400).send("Invalid email or password");
|
||||
if (!match) {
|
||||
return res.status(401).json({
|
||||
error: "INVALID_CREDENTIALS",
|
||||
code: "login.error.invalid_credentials"
|
||||
});
|
||||
}
|
||||
|
||||
const token = jwt.sign({ id: user?.id }, process.env.JWT_SECRET as string);
|
||||
|
||||
// return user and token to client, exclude hashed password
|
||||
if (user) {
|
||||
user.password = undefined as unknown as string;
|
||||
}
|
||||
@@ -101,30 +143,43 @@ router.post("/login", async (req, res) => {
|
||||
});
|
||||
res.json(user);
|
||||
} catch (error: any) {
|
||||
res.status(400).send(`Could not login user - ${error.message}`);
|
||||
console.log(`Could not login user - ${error}`);
|
||||
console.error(`Login error: ${error.message}`);
|
||||
res.status(500).json({
|
||||
error: "SERVER_ERROR",
|
||||
code: "login.error.server_error"
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/logout", async (req, res) => {
|
||||
try {
|
||||
res.clearCookie("token");
|
||||
return res.json({ message: "Logout successful" });
|
||||
} catch (error: any) {
|
||||
res.status(500).send(`Could not logout user - ${error.message}`);
|
||||
try {
|
||||
res.clearCookie("token");
|
||||
return res.status(200).json({
|
||||
ok: true,
|
||||
message: "Logged out successfully",
|
||||
code: "success"
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
return res.status(500).json({
|
||||
ok: false,
|
||||
message: "Error during logout",
|
||||
code: "server",
|
||||
error: process.env.NODE_ENV === 'development' ? error : undefined
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/current-user",
|
||||
requireSignIn,
|
||||
async (req: Request, res) => {
|
||||
const authenticatedReq = req as AuthenticatedRequest;
|
||||
async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
if (!authenticatedReq.user) {
|
||||
if (!req.user) {
|
||||
return res.status(401).json({ ok: false, error: "Unauthorized" });
|
||||
}
|
||||
const user = await User.findByPk(authenticatedReq.user.id, {
|
||||
const user = await User.findByPk(req.user.id, {
|
||||
attributes: { exclude: ["password"] },
|
||||
});
|
||||
if (!user) {
|
||||
@@ -147,7 +202,7 @@ router.get(
|
||||
router.get(
|
||||
"/user/:id",
|
||||
requireSignIn,
|
||||
async (req: Request, res) => {
|
||||
async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
if (!id) {
|
||||
@@ -176,13 +231,12 @@ router.get(
|
||||
router.post(
|
||||
"/generate-api-key",
|
||||
requireSignIn,
|
||||
async (req: Request, res) => {
|
||||
const authenticatedReq = req as AuthenticatedRequest;
|
||||
async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
if (!authenticatedReq.user) {
|
||||
if (!req.user) {
|
||||
return res.status(401).json({ ok: false, error: "Unauthorized" });
|
||||
}
|
||||
const user = await User.findByPk(authenticatedReq.user.id, {
|
||||
const user = await User.findByPk(req.user.id, {
|
||||
attributes: { exclude: ["password"] },
|
||||
});
|
||||
|
||||
@@ -217,28 +271,41 @@ router.post(
|
||||
router.get(
|
||||
"/api-key",
|
||||
requireSignIn,
|
||||
async (req: Request, res) => {
|
||||
const authenticatedReq = req as AuthenticatedRequest;
|
||||
async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
if (!authenticatedReq.user) {
|
||||
return res.status(401).json({ ok: false, error: "Unauthorized" });
|
||||
if (!req.user) {
|
||||
return res.status(401).json({
|
||||
ok: false,
|
||||
error: "Unauthorized",
|
||||
code: "unauthorized"
|
||||
});
|
||||
}
|
||||
|
||||
const user = await User.findByPk(authenticatedReq.user.id, {
|
||||
const user = await User.findByPk(req.user.id, {
|
||||
raw: true,
|
||||
attributes: ["api_key"],
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ message: "User not found" });
|
||||
return res.status(404).json({
|
||||
ok: false,
|
||||
error: "User not found",
|
||||
code: "not_found"
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
ok: true,
|
||||
message: "API key fetched successfully",
|
||||
api_key: user.api_key || null,
|
||||
});
|
||||
} catch (error) {
|
||||
return res.status(500).json({ message: "Error fetching API key", error });
|
||||
console.error('API Key fetch error:', error);
|
||||
return res.status(500).json({
|
||||
ok: false,
|
||||
error: "Error fetching API key",
|
||||
code: "server",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -246,14 +313,13 @@ router.get(
|
||||
router.delete(
|
||||
"/delete-api-key",
|
||||
requireSignIn,
|
||||
async (req: Request, res) => {
|
||||
const authenticatedReq = req as AuthenticatedRequest;
|
||||
if (!authenticatedReq.user) {
|
||||
async (req: AuthenticatedRequest, res) => {
|
||||
if (!req.user) {
|
||||
return res.status(401).send({ error: "Unauthorized" });
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await User.findByPk(authenticatedReq.user.id, { raw: true });
|
||||
const user = await User.findByPk(req.user.id, { raw: true });
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ message: "User not found" });
|
||||
@@ -263,7 +329,7 @@ router.delete(
|
||||
return res.status(404).json({ message: "API Key not found" });
|
||||
}
|
||||
|
||||
await User.update({ api_key: null }, { where: { id: authenticatedReq.user.id } });
|
||||
await User.update({ api_key: null }, { where: { id: req.user.id } });
|
||||
|
||||
capture("maxun-oss-api-key-deleted", {
|
||||
user_id: user.id,
|
||||
@@ -309,8 +375,7 @@ router.get("/google", (req, res) => {
|
||||
router.get(
|
||||
"/google/callback",
|
||||
requireSignIn,
|
||||
async (req: Request, res) => {
|
||||
const authenticatedReq = req as AuthenticatedRequest;
|
||||
async (req: AuthenticatedRequest, res) => {
|
||||
const { code, state } = req.query;
|
||||
try {
|
||||
if (!state) {
|
||||
@@ -336,12 +401,12 @@ router.get(
|
||||
return res.status(400).json({ message: "Email not found" });
|
||||
}
|
||||
|
||||
if (!authenticatedReq.user) {
|
||||
if (!req.user) {
|
||||
return res.status(401).send({ error: "Unauthorized" });
|
||||
}
|
||||
|
||||
// Get the currently authenticated user (from `requireSignIn`)
|
||||
let user = await User.findOne({ where: { id: authenticatedReq.user.id } });
|
||||
let user = await User.findOne({ where: { id: req.user.id } });
|
||||
|
||||
if (!user) {
|
||||
return res.status(400).json({ message: "User not found" });
|
||||
@@ -419,13 +484,12 @@ router.get(
|
||||
router.post(
|
||||
"/gsheets/data",
|
||||
requireSignIn,
|
||||
async (req: Request, res) => {
|
||||
const authenticatedReq = req as AuthenticatedRequest;
|
||||
async (req: AuthenticatedRequest, res) => {
|
||||
const { spreadsheetId, robotId } = req.body;
|
||||
if (!authenticatedReq.user) {
|
||||
if (!req.user) {
|
||||
return res.status(401).send({ error: "Unauthorized" });
|
||||
}
|
||||
const user = await User.findByPk(authenticatedReq.user.id, { raw: true });
|
||||
const user = await User.findByPk(req.user.id, { raw: true });
|
||||
|
||||
if (!user) {
|
||||
return res.status(400).json({ message: "User not found" });
|
||||
@@ -537,14 +601,13 @@ router.post("/gsheets/update", requireSignIn, async (req, res) => {
|
||||
router.post(
|
||||
"/gsheets/remove",
|
||||
requireSignIn,
|
||||
async (req: Request, res) => {
|
||||
const authenticatedReq = req as AuthenticatedRequest;
|
||||
async (req: AuthenticatedRequest, res) => {
|
||||
const { robotId } = req.body;
|
||||
if (!robotId) {
|
||||
return res.status(400).json({ message: "Robot ID is required" });
|
||||
}
|
||||
|
||||
if (!authenticatedReq.user) {
|
||||
if (!req.user) {
|
||||
return res.status(401).send({ error: "Unauthorized" });
|
||||
}
|
||||
|
||||
@@ -566,7 +629,7 @@ router.post(
|
||||
});
|
||||
|
||||
capture("maxun-oss-google-sheet-integration-removed", {
|
||||
user_id: authenticatedReq.user.id,
|
||||
user_id: req.user.id,
|
||||
robot_id: robotId,
|
||||
deleted_at: new Date().toISOString(),
|
||||
});
|
||||
@@ -866,4 +929,6 @@ router.get("/airtable/tables", async (req: Request, res) => {
|
||||
} catch (error: any) {
|
||||
res.status(500).json({ message: error.message });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -26,35 +26,43 @@ chromium.use(stealthPlugin());
|
||||
|
||||
export const router = Router();
|
||||
|
||||
export const decryptWorkflowActions = async (workflow: any[],): Promise<any[]> => {
|
||||
// Create a deep copy to avoid mutating the original workflow
|
||||
const processedWorkflow = JSON.parse(JSON.stringify(workflow));
|
||||
export const processWorkflowActions = async (workflow: any[], checkLimit: boolean = false): Promise<any[]> => {
|
||||
const processedWorkflow = JSON.parse(JSON.stringify(workflow));
|
||||
|
||||
// Process each step in the workflow
|
||||
for (const step of processedWorkflow) {
|
||||
if (!step.what) continue;
|
||||
|
||||
// Process each action in the step
|
||||
for (const action of step.what) {
|
||||
// Only process type and press actions
|
||||
if ((action.action === 'type' || action.action === 'press') && Array.isArray(action.args) && action.args.length > 1) {
|
||||
// The second argument contains the encrypted value
|
||||
const encryptedValue = action.args[1];
|
||||
if (typeof encryptedValue === 'string') {
|
||||
try {
|
||||
// Decrypt the value and update the args array
|
||||
action.args[1] = await decrypt(encryptedValue);
|
||||
} catch (error) {
|
||||
console.error('Failed to decrypt value:', error);
|
||||
// Keep the encrypted value if decryption fails
|
||||
processedWorkflow.forEach((pair: any) => {
|
||||
pair.what.forEach((action: any) => {
|
||||
// Handle limit validation for scrapeList action
|
||||
if (action.action === 'scrapeList' && checkLimit && Array.isArray(action.args) && action.args.length > 0) {
|
||||
const scrapeConfig = action.args[0];
|
||||
if (scrapeConfig && typeof scrapeConfig === 'object' && 'limit' in scrapeConfig) {
|
||||
if (typeof scrapeConfig.limit === 'number' && scrapeConfig.limit > 5) {
|
||||
scrapeConfig.limit = 5;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle decryption for type and press actions
|
||||
if ((action.action === 'type' || action.action === 'press') && Array.isArray(action.args) && action.args.length > 1) {
|
||||
try {
|
||||
const encryptedValue = action.args[1];
|
||||
if (typeof encryptedValue === 'string') {
|
||||
const decryptedValue = decrypt(encryptedValue);
|
||||
action.args[1] = decryptedValue;
|
||||
} else {
|
||||
logger.log('error', 'Encrypted value is not a string');
|
||||
action.args[1] = '';
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
logger.log('error', `Failed to decrypt input value: ${errorMessage}`);
|
||||
action.args[1] = '';
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return processedWorkflow;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs information about recordings API.
|
||||
@@ -89,7 +97,7 @@ router.get('/recordings/:id', requireSignIn, async (req, res) => {
|
||||
);
|
||||
|
||||
if (data?.recording?.workflow) {
|
||||
data.recording.workflow = await decryptWorkflowActions(
|
||||
data.recording.workflow = await processWorkflowActions(
|
||||
data.recording.workflow,
|
||||
);
|
||||
}
|
||||
@@ -164,54 +172,82 @@ interface Credentials {
|
||||
[key: string]: CredentialInfo;
|
||||
}
|
||||
|
||||
function updateTypeActionsInWorkflow(workflow: any[], credentials: Credentials) {
|
||||
function handleWorkflowActions(workflow: any[], credentials: Credentials) {
|
||||
return workflow.map(step => {
|
||||
if (!step.what) return step;
|
||||
|
||||
const indicesToRemove = new Set<number>();
|
||||
step.what.forEach((action: any, index: number) => {
|
||||
if (!action.action || !action.args?.[0]) return;
|
||||
const newWhat: any[] = [];
|
||||
const processedSelectors = new Set<string>();
|
||||
|
||||
for (let i = 0; i < step.what.length; i++) {
|
||||
const action = step.what[i];
|
||||
|
||||
if ((action.action === 'type' || action.action === 'press') && credentials[action.args[0]]) {
|
||||
indicesToRemove.add(index);
|
||||
|
||||
if (step.what[index + 1]?.action === 'waitForLoadState') {
|
||||
indicesToRemove.add(index + 1);
|
||||
}
|
||||
if (!action?.action || !action?.args?.[0]) {
|
||||
newWhat.push(action);
|
||||
continue;
|
||||
}
|
||||
});
|
||||
|
||||
const filteredWhat = step.what.filter((_: any, index: number) => !indicesToRemove.has(index));
|
||||
const selector = action.args[0];
|
||||
const credential = credentials[selector];
|
||||
|
||||
Object.entries(credentials).forEach(([selector, credentialInfo]) => {
|
||||
const clickIndex = filteredWhat.findIndex((action: any) =>
|
||||
action.action === 'click' && action.args?.[0] === selector
|
||||
);
|
||||
if (!credential) {
|
||||
newWhat.push(action);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (clickIndex !== -1) {
|
||||
const chars = credentialInfo.value.split('');
|
||||
if (action.action === 'click') {
|
||||
newWhat.push(action);
|
||||
|
||||
chars.forEach((char, i) => {
|
||||
filteredWhat.splice(clickIndex + 1 + (i * 2), 0, {
|
||||
if (!processedSelectors.has(selector) &&
|
||||
i + 1 < step.what.length &&
|
||||
(step.what[i + 1].action === 'type' || step.what[i + 1].action === 'press')) {
|
||||
|
||||
newWhat.push({
|
||||
action: 'type',
|
||||
args: [
|
||||
selector,
|
||||
encrypt(char),
|
||||
credentialInfo.type
|
||||
]
|
||||
args: [selector, encrypt(credential.value), credential.type]
|
||||
});
|
||||
|
||||
filteredWhat.splice(clickIndex + 2 + (i * 2), 0, {
|
||||
newWhat.push({
|
||||
action: 'waitForLoadState',
|
||||
args: ['networkidle']
|
||||
});
|
||||
|
||||
processedSelectors.add(selector);
|
||||
|
||||
while (i + 1 < step.what.length &&
|
||||
(step.what[i + 1].action === 'type' ||
|
||||
step.what[i + 1].action === 'press' ||
|
||||
step.what[i + 1].action === 'waitForLoadState')) {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
} else if ((action.action === 'type' || action.action === 'press') &&
|
||||
!processedSelectors.has(selector)) {
|
||||
newWhat.push({
|
||||
action: 'type',
|
||||
args: [selector, encrypt(credential.value), credential.type]
|
||||
});
|
||||
|
||||
newWhat.push({
|
||||
action: 'waitForLoadState',
|
||||
args: ['networkidle']
|
||||
});
|
||||
|
||||
processedSelectors.add(selector);
|
||||
|
||||
// Skip subsequent type/press/waitForLoadState actions for this selector
|
||||
while (i + 1 < step.what.length &&
|
||||
(step.what[i + 1].action === 'type' ||
|
||||
step.what[i + 1].action === 'press' ||
|
||||
step.what[i + 1].action === 'waitForLoadState')) {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...step,
|
||||
what: filteredWhat
|
||||
what: newWhat
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -244,7 +280,7 @@ router.put('/recordings/:id', requireSignIn, async (req: AuthenticatedRequest, r
|
||||
let workflow = [...robot.recording.workflow]; // Create a copy of the workflow
|
||||
|
||||
if (credentials) {
|
||||
workflow = updateTypeActionsInWorkflow(workflow, credentials);
|
||||
workflow = handleWorkflowActions(workflow, credentials);
|
||||
}
|
||||
|
||||
// Update the limit
|
||||
@@ -282,9 +318,23 @@ router.put('/recordings/:id', requireSignIn, async (req: AuthenticatedRequest, r
|
||||
}
|
||||
}
|
||||
|
||||
robot.set('recording', { ...robot.recording, workflow });
|
||||
const updates: any = {
|
||||
recording: {
|
||||
...robot.recording,
|
||||
workflow
|
||||
}
|
||||
};
|
||||
|
||||
await robot.save();
|
||||
if (name) {
|
||||
updates.recording_meta = {
|
||||
...robot.recording_meta,
|
||||
name
|
||||
};
|
||||
}
|
||||
|
||||
await Robot.update(updates, {
|
||||
where: { 'recording_meta.id': id }
|
||||
});
|
||||
|
||||
const updatedRobot = await Robot.findOne({ where: { 'recording_meta.id': id } });
|
||||
|
||||
@@ -502,6 +552,7 @@ router.put('/runs/:id', requireSignIn, async (req: AuthenticatedRequest, res) =>
|
||||
return res.send({
|
||||
browserId: id,
|
||||
runId: plainRun.runId,
|
||||
robotMetaId: recording.recording_meta.id,
|
||||
});
|
||||
} catch (e) {
|
||||
const { message } = e as Error;
|
||||
|
||||
@@ -22,7 +22,7 @@ import { getBestSelectorForAction } from "../utils";
|
||||
import { browserPool } from "../../server";
|
||||
import { uuid } from "uuidv4";
|
||||
import { capture } from "../../utils/analytics"
|
||||
import { encrypt } from "../../utils/auth";
|
||||
import { decrypt, encrypt } from "../../utils/auth";
|
||||
|
||||
interface PersistedGeneratedData {
|
||||
lastUsedSelector: string;
|
||||
@@ -42,6 +42,13 @@ interface MetaData {
|
||||
isLogin?: boolean;
|
||||
}
|
||||
|
||||
interface InputState {
|
||||
selector: string;
|
||||
value: string;
|
||||
type: string;
|
||||
cursorPosition: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Workflow generator is used to transform the user's interactions into an automatically
|
||||
* generated correct workflows, using the ability of internal state persistence and
|
||||
@@ -428,26 +435,86 @@ export class WorkflowGenerator {
|
||||
}
|
||||
|
||||
if ((elementInfo?.tagName === 'INPUT' || elementInfo?.tagName === 'TEXTAREA') && selector) {
|
||||
// Calculate the exact position within the element
|
||||
const elementPos = await page.evaluate((selector) => {
|
||||
const element = document.querySelector(selector);
|
||||
if (!element) return null;
|
||||
const rect = element.getBoundingClientRect();
|
||||
return {
|
||||
x: rect.left,
|
||||
y: rect.top
|
||||
};
|
||||
}, selector);
|
||||
const positionAndCursor = await page.evaluate(
|
||||
({ selector, coords }) => {
|
||||
const getCursorPosition = (element: any, clickX: any) => {
|
||||
const text = element.value;
|
||||
|
||||
const mirror = document.createElement('div');
|
||||
|
||||
const style = window.getComputedStyle(element);
|
||||
mirror.style.cssText = `
|
||||
font: ${style.font};
|
||||
line-height: ${style.lineHeight};
|
||||
padding: ${style.padding};
|
||||
border: ${style.border};
|
||||
box-sizing: ${style.boxSizing};
|
||||
white-space: ${style.whiteSpace};
|
||||
overflow-wrap: ${style.overflowWrap};
|
||||
position: absolute;
|
||||
top: -9999px;
|
||||
left: -9999px;
|
||||
width: ${element.offsetWidth}px;
|
||||
`;
|
||||
|
||||
document.body.appendChild(mirror);
|
||||
|
||||
const paddingLeft = parseFloat(style.paddingLeft);
|
||||
const borderLeft = parseFloat(style.borderLeftWidth);
|
||||
|
||||
const adjustedClickX = clickX - (paddingLeft + borderLeft);
|
||||
|
||||
let bestIndex = 0;
|
||||
let bestDiff = Infinity;
|
||||
|
||||
for (let i = 0; i <= text.length; i++) {
|
||||
const textBeforeCursor = text.substring(0, i);
|
||||
const span = document.createElement('span');
|
||||
span.textContent = textBeforeCursor;
|
||||
mirror.innerHTML = '';
|
||||
mirror.appendChild(span);
|
||||
|
||||
const textWidth = span.getBoundingClientRect().width;
|
||||
|
||||
const diff = Math.abs(adjustedClickX - textWidth);
|
||||
|
||||
if (diff < bestDiff) {
|
||||
bestIndex = i;
|
||||
bestDiff = diff;
|
||||
}
|
||||
}
|
||||
|
||||
document.body.removeChild(mirror);
|
||||
|
||||
return bestIndex;
|
||||
};
|
||||
|
||||
if (elementPos) {
|
||||
const relativeX = coordinates.x - elementPos.x;
|
||||
const relativeY = coordinates.y - elementPos.y;
|
||||
const element = document.querySelector(selector) as HTMLInputElement | HTMLTextAreaElement;
|
||||
if (!element) return null;
|
||||
|
||||
const rect = element.getBoundingClientRect();
|
||||
const relativeX = coords.x - rect.left;
|
||||
|
||||
return {
|
||||
rect: {
|
||||
x: rect.left,
|
||||
y: rect.top
|
||||
},
|
||||
cursorIndex: getCursorPosition(element, relativeX)
|
||||
};
|
||||
},
|
||||
{ selector, coords: coordinates }
|
||||
);
|
||||
|
||||
if (positionAndCursor) {
|
||||
const relativeX = coordinates.x - positionAndCursor.rect.x;
|
||||
const relativeY = coordinates.y - positionAndCursor.rect.y;
|
||||
|
||||
const pair: WhereWhatPair = {
|
||||
where,
|
||||
what: [{
|
||||
action: 'click',
|
||||
args: [selector, { position: { x: relativeX, y: relativeY } }]
|
||||
args: [selector, { position: { x: relativeX, y: relativeY } }, { cursorIndex: positionAndCursor.cursorIndex }],
|
||||
}]
|
||||
};
|
||||
|
||||
@@ -759,8 +826,7 @@ export class WorkflowGenerator {
|
||||
selectors?.id,
|
||||
selectors?.hrefSelector,
|
||||
selectors?.accessibilitySelector,
|
||||
selectors?.attrSelector,
|
||||
selectors?.generalSelector
|
||||
selectors?.attrSelector
|
||||
]
|
||||
.filter(selector => selector !== null && selector !== undefined)
|
||||
.join(',');
|
||||
@@ -1027,77 +1093,107 @@ export class WorkflowGenerator {
|
||||
* @param workflow The workflow to be optimized.
|
||||
*/
|
||||
private optimizeWorkflow = (workflow: WorkflowFile) => {
|
||||
|
||||
// replace a sequence of press actions by a single fill action
|
||||
let input = {
|
||||
selector: '',
|
||||
value: '',
|
||||
type: '',
|
||||
actionCounter: 0,
|
||||
};
|
||||
|
||||
const pushTheOptimizedAction = (pair: WhereWhatPair, index: number) => {
|
||||
if (input.value.length === 1) {
|
||||
// when only one press action is present, keep it and add a waitForLoadState action
|
||||
pair.what.splice(index + 1, 0, {
|
||||
action: 'waitForLoadState',
|
||||
args: ['networkidle'],
|
||||
})
|
||||
} else {
|
||||
// when more than one press action is present, add a type action
|
||||
pair.what.splice(index - input.actionCounter, input.actionCounter, {
|
||||
action: 'type',
|
||||
args: [input.selector, encrypt(input.value), input.type],
|
||||
}, {
|
||||
action: 'waitForLoadState',
|
||||
args: ['networkidle'],
|
||||
});
|
||||
const inputStates = new Map<string, InputState>();
|
||||
|
||||
for (const pair of workflow.workflow) {
|
||||
let currentIndex = 0;
|
||||
|
||||
while (currentIndex < pair.what.length) {
|
||||
const condition = pair.what[currentIndex];
|
||||
|
||||
if (condition.action === 'click' && condition.args?.[2]?.cursorIndex !== undefined) {
|
||||
const selector = condition.args[0];
|
||||
const cursorIndex = condition.args[2].cursorIndex;
|
||||
|
||||
let state = inputStates.get(selector) || {
|
||||
selector,
|
||||
value: '',
|
||||
type: 'text',
|
||||
cursorPosition: -1
|
||||
};
|
||||
|
||||
state.cursorPosition = cursorIndex;
|
||||
inputStates.set(selector, state);
|
||||
|
||||
pair.what.splice(currentIndex, 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (condition.action === 'press' && condition.args?.[1]) {
|
||||
const [selector, encryptedKey, type] = condition.args;
|
||||
const key = decrypt(encryptedKey);
|
||||
|
||||
let state = inputStates.get(selector);
|
||||
if (!state) {
|
||||
state = {
|
||||
selector,
|
||||
value: '',
|
||||
type: type || 'text',
|
||||
cursorPosition: -1
|
||||
};
|
||||
} else {
|
||||
state.type = type || state.type;
|
||||
}
|
||||
|
||||
if (key.length === 1) {
|
||||
if (state.cursorPosition === -1) {
|
||||
state.value += key;
|
||||
} else {
|
||||
state.value =
|
||||
state.value.slice(0, state.cursorPosition) +
|
||||
key +
|
||||
state.value.slice(state.cursorPosition);
|
||||
state.cursorPosition++;
|
||||
}
|
||||
} else if (key === 'Backspace') {
|
||||
if (state.cursorPosition > 0) {
|
||||
state.value =
|
||||
state.value.slice(0, state.cursorPosition - 1) +
|
||||
state.value.slice(state.cursorPosition);
|
||||
state.cursorPosition--;
|
||||
} else if (state.cursorPosition === -1 && state.value.length > 0) {
|
||||
state.value = state.value.slice(0, -1);
|
||||
}
|
||||
} else if (key === 'Delete') {
|
||||
if (state.cursorPosition >= 0 && state.cursorPosition < state.value.length) {
|
||||
state.value =
|
||||
state.value.slice(0, state.cursorPosition) +
|
||||
state.value.slice(state.cursorPosition + 1);
|
||||
} else if (state.cursorPosition === -1 && state.value.length > 0) {
|
||||
state.value = state.value.slice(0, -1);
|
||||
}
|
||||
}
|
||||
|
||||
inputStates.set(selector, state);
|
||||
|
||||
pair.what.splice(currentIndex, 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
currentIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
for (const pair of workflow.workflow) {
|
||||
pair.what.forEach((condition, index) => {
|
||||
if (condition.action === 'press') {
|
||||
if (condition.args && condition.args[1]) {
|
||||
if (!input.selector) {
|
||||
input.selector = condition.args[0];
|
||||
}
|
||||
if (input.selector === condition.args[0]) {
|
||||
input.actionCounter++;
|
||||
if (condition.args[1].length === 1) {
|
||||
input.value = input.value + condition.args[1];
|
||||
} else if (condition.args[1] === 'Backspace') {
|
||||
input.value = input.value.slice(0, -1);
|
||||
} else if (condition.args[1] !== 'Shift') {
|
||||
pushTheOptimizedAction(pair, index);
|
||||
pair.what.splice(index + 1, 0, {
|
||||
action: 'waitForLoadState',
|
||||
args: ['networkidle'],
|
||||
})
|
||||
input = { selector: '', value: '', type: '', actionCounter: 0 };
|
||||
}
|
||||
} else {
|
||||
pushTheOptimizedAction(pair, index);
|
||||
input = {
|
||||
selector: condition.args[0],
|
||||
value: condition.args[1],
|
||||
type: condition.args[2],
|
||||
actionCounter: 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (input.value.length !== 0) {
|
||||
pushTheOptimizedAction(pair, index);
|
||||
// clear the input
|
||||
input = { selector: '', value: '', type: '', actionCounter: 0 };
|
||||
}
|
||||
|
||||
for (const [selector, state] of inputStates.entries()) {
|
||||
if (state.value) {
|
||||
for (let i = workflow.workflow.length - 1; i >= 0; i--) {
|
||||
const pair = workflow.workflow[i];
|
||||
|
||||
pair.what.push({
|
||||
action: 'type',
|
||||
args: [selector, encrypt(state.value), state.type]
|
||||
}, {
|
||||
action: 'waitForLoadState',
|
||||
args: ['networkidle']
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return workflow;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns workflow params from the stored metadata.
|
||||
|
||||
@@ -1092,12 +1092,16 @@ export const getSelectors = async (page: Page, coordinates: Coordinates) => {
|
||||
newPath.splice(i, 1);
|
||||
const newPathKey = selector(newPath);
|
||||
if (scope.visited.has(newPathKey)) {
|
||||
return;
|
||||
continue;
|
||||
}
|
||||
if (unique(newPath) && same(newPath, input)) {
|
||||
yield newPath;
|
||||
scope.visited.set(newPathKey, true);
|
||||
yield* optimize(newPath, input, scope);
|
||||
try {
|
||||
if (unique(newPath) && same(newPath, input)) {
|
||||
yield newPath;
|
||||
scope.visited.set(newPathKey, true);
|
||||
yield* optimize(newPath, input, scope);
|
||||
}
|
||||
} catch (e: any) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1654,6 +1658,31 @@ export const getNonUniqueSelectors = async (page: Page, coordinates: Coordinates
|
||||
}
|
||||
}
|
||||
|
||||
if (element.parentElement) {
|
||||
// Look for identical siblings
|
||||
const siblings = Array.from(element.parentElement.children);
|
||||
const identicalSiblings = siblings.filter(sibling => {
|
||||
if (sibling === element) return false;
|
||||
|
||||
let siblingSelector = sibling.tagName.toLowerCase();
|
||||
const siblingClassName = typeof sibling.className === 'string' ? sibling.className : '';
|
||||
if (siblingClassName) {
|
||||
const siblingClasses = siblingClassName.split(/\s+/).filter(Boolean);
|
||||
const validSiblingClasses = siblingClasses.filter(cls => !cls.startsWith('!') && !cls.includes(':'));
|
||||
if (validSiblingClasses.length > 0) {
|
||||
siblingSelector += '.' + validSiblingClasses.map(cls => CSS.escape(cls)).join('.');
|
||||
}
|
||||
}
|
||||
|
||||
return siblingSelector === selector;
|
||||
});
|
||||
|
||||
if (identicalSiblings.length > 0) {
|
||||
const position = siblings.indexOf(element) + 1;
|
||||
selector += `:nth-child(${position})`;
|
||||
}
|
||||
}
|
||||
|
||||
return selector;
|
||||
}
|
||||
|
||||
@@ -1894,6 +1923,31 @@ export const getNonUniqueSelectors = async (page: Page, coordinates: Coordinates
|
||||
}
|
||||
}
|
||||
|
||||
if (element.parentElement) {
|
||||
// Look for identical siblings
|
||||
const siblings = Array.from(element.parentElement.children);
|
||||
const identicalSiblings = siblings.filter(sibling => {
|
||||
if (sibling === element) return false;
|
||||
|
||||
let siblingSelector = sibling.tagName.toLowerCase();
|
||||
const siblingClassName = typeof sibling.className === 'string' ? sibling.className : '';
|
||||
if (siblingClassName) {
|
||||
const siblingClasses = siblingClassName.split(/\s+/).filter(Boolean);
|
||||
const validSiblingClasses = siblingClasses.filter(cls => !cls.startsWith('!') && !cls.includes(':'));
|
||||
if (validSiblingClasses.length > 0) {
|
||||
siblingSelector += '.' + validSiblingClasses.map(cls => CSS.escape(cls)).join('.');
|
||||
}
|
||||
}
|
||||
|
||||
return siblingSelector === selector;
|
||||
});
|
||||
|
||||
if (identicalSiblings.length > 0) {
|
||||
const position = siblings.indexOf(element) + 1;
|
||||
selector += `:nth-child(${position})`;
|
||||
}
|
||||
}
|
||||
|
||||
return selector;
|
||||
}
|
||||
|
||||
@@ -2025,6 +2079,31 @@ export const getChildSelectors = async (page: Page, parentSelector: string): Pro
|
||||
}
|
||||
}
|
||||
|
||||
if (element.parentElement) {
|
||||
// Look for identical siblings
|
||||
const siblings = Array.from(element.parentElement.children);
|
||||
const identicalSiblings = siblings.filter(sibling => {
|
||||
if (sibling === element) return false;
|
||||
|
||||
let siblingSelector = sibling.tagName.toLowerCase();
|
||||
const siblingClassName = typeof sibling.className === 'string' ? sibling.className : '';
|
||||
if (siblingClassName) {
|
||||
const siblingClasses = siblingClassName.split(/\s+/).filter(Boolean);
|
||||
const validSiblingClasses = siblingClasses.filter(cls => !cls.startsWith('!') && !cls.includes(':'));
|
||||
if (validSiblingClasses.length > 0) {
|
||||
siblingSelector += '.' + validSiblingClasses.map(cls => CSS.escape(cls)).join('.');
|
||||
}
|
||||
}
|
||||
|
||||
return siblingSelector === selector;
|
||||
});
|
||||
|
||||
if (identicalSiblings.length > 0) {
|
||||
const position = siblings.indexOf(element) + 1;
|
||||
selector += `:nth-child(${position})`;
|
||||
}
|
||||
}
|
||||
|
||||
return selector;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user