@stubber/virtual-worker 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.env_dev ADDED
@@ -0,0 +1,14 @@
1
+ # when running inside docker and connecting to tag running on host machine
2
+ # WORKER_CONNECT_URL=http://172.17.0.1:7450/orgs/7cf3b2dc-5a49-51dd-8b4d-812a8ba6f233
3
+ # when running using npm run dev and connecting to tag running on host machine
4
+ # WORKER_CONNECT_URL=http://localhost:7450/orgs/7cf3b2dc-5a49-51dd-8b4d-812a8ba6f233
5
+ # when used in docker or with npm run, connect to the devmaster tag
6
+ WORKER_CONNECT_URL="https://virtual-workers.dev.stubber.com/orgs/7cf3b2dc-5a49-51dd-8b4d-812a8ba6f233"
7
+
8
+ WORKER_SIGNATURE=069c293dc0d71dd42b32b122da62b2deae08c12c67a85224d14a7dd8c4907f21
9
+
10
+ WORKER_WORKERUUID=b0bf29a8-5ebb-4b79-b464-9d35686ebc7d
11
+
12
+ WORKER_GROUPS=insurance-worker,b0bf29a8-5ebb-4b79-b464-9d35686ebc7d
13
+
14
+ NODE_ENV=development
package/.env_devmaster ADDED
@@ -0,0 +1,7 @@
1
+ WORKER_CONNECT_URL="https://virtual-workers.dev.stubber.com/orgs/7cf3b2dc-5a49-51dd-8b4d-812a8ba6f233"
2
+
3
+ WORKER_SIGNATURE=069c293dc0d71dd42b32b122da62b2deae08c12c67a85224d14a7dd8c4907f21
4
+
5
+ WORKER_WORKERUUID=b0bf29a8-5ebb-4b79-b464-9d35686ebc7d
6
+
7
+ WORKER_GROUPS=insurance-worker,b0bf29a8-5ebb-4b79-b464-9d35686ebc7d
package/.eslintrc.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "env": {
3
+ "node": true,
4
+ "es2021": true
5
+ },
6
+ "extends": "eslint:recommended",
7
+ "parserOptions": {
8
+ "ecmaVersion": "latest",
9
+ "sourceType": "module"
10
+ },
11
+ "plugins": ["filenames"],
12
+ "rules": {
13
+ "no-unused-vars": "off",
14
+ "id-match": ["warn", "^_*[a-z0-9]+(_[a-z0-9]+)*$|^_*[A-Z0-9]+(_[A-Z0-9]+)*$|_", { "properties": true }],
15
+ "filenames/match-regex": ["warn", "^[a-z]+(_[a-z]+)*$", true]
16
+ },
17
+ "globals": {
18
+ "__baseFileNameData": "readonly"
19
+ }
20
+ }
package/.prettierrc ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "useTabs": false,
3
+ "tabWidth": 2,
4
+ "semi": true,
5
+ "printWidth": 120
6
+ }
@@ -0,0 +1,143 @@
1
+ import { create_error_conceptual } from "#app/functions/create_error_conceptual.js";
2
+ import { create_error_technical } from "#app/functions/create_error_technical.js";
3
+ import { create_success } from "#app/functions/create_success.js";
4
+ import { get_chromium_page } from "../../helpers/get_chromium_page.js";
5
+ import fs from "fs";
6
+ import { v4 } from "uuid";
7
+
8
+ /**
9
+ *
10
+ * @param {Object} params
11
+ * @param {Array<{ locator: string, frame?:string, options?: Object, download?: boolean }> } params.entries - Array of entries to click
12
+ * @returns
13
+ */
14
+ export const browser_click = async (params, stubber_context) => {
15
+ // Extract coordinates from options
16
+ const { entries } = params || {};
17
+
18
+ // use playwright to move the mouse and perform a left click
19
+ const page_result = await get_chromium_page(stubber_context.stubref);
20
+
21
+ if (!page_result.success) {
22
+ return page_result;
23
+ }
24
+
25
+ const page = page_result.payload;
26
+
27
+ const downloads = [];
28
+
29
+ for (let { locator, frame, options, download } of entries) {
30
+ if (!locator) {
31
+ return create_error_conceptual({ message: "Locator is required", details: { locator, frame } });
32
+ }
33
+
34
+ /** @type {import('playwright').Locator} */
35
+ let target;
36
+ if (frame) {
37
+ const frame_handle = page.frameLocator(frame);
38
+ target = frame_handle.locator(locator);
39
+ } else {
40
+ target = page.locator(locator);
41
+ }
42
+
43
+ // const target = page.locator(locator);
44
+
45
+ try {
46
+ await target.waitFor({ state: "attached", timeout: 5000 });
47
+ } catch {
48
+ return create_error_conceptual({ message: "No element found for locator", details: { locator, frame } });
49
+ }
50
+
51
+ if (download) {
52
+ // not all downloads are made equal. Some of them actually trigger the "download" event, in which case playwright handles it
53
+ // however, some files, like PDFs, are opened inline in the browser, so no download event is triggered
54
+ // for those, if the user specifies download: true, we will try to fetch the file ourselves
55
+ const [download_evt, popup_evt] = await Promise.all([
56
+ page.waitForEvent("download", { timeout: 3000 }).catch(() => null), // don’t throw if no event
57
+ page.waitForEvent("popup").catch(() => null),
58
+ target.click(),
59
+ ]);
60
+
61
+ if (download_evt) {
62
+ const path = await download_evt.path();
63
+ console.log("Downloaded file path:", path);
64
+
65
+ downloads.push({
66
+ file_name: download_evt.suggestedFilename(),
67
+ file_path: path,
68
+ url: download_evt.url(),
69
+ });
70
+ } else if (popup_evt) {
71
+ await popup_evt.waitForLoadState();
72
+
73
+ // Fallback: maybe inline PDF
74
+ const { file_name, file_path, url } = await download_inline_file(popup_evt.url());
75
+ downloads.push({ url, file_path, file_name });
76
+ // close the popup to avoid too many open pages
77
+ await popup_evt.close();
78
+ } else {
79
+ throw create_error_conceptual({ message: "No download or popup event detected.", details: { locator, frame } });
80
+ }
81
+ } else {
82
+ // if target is an <option> element, we need to use selectOption instead of click
83
+ const tag_name = await target.evaluate((el) => el.tagName.toLowerCase());
84
+ if (tag_name === "option") {
85
+ const value = await target.evaluate((el) => el.value);
86
+ const select = target.locator("xpath=..");
87
+ console.log("Selecting option with value:", value, select);
88
+ await select.selectOption(value);
89
+ } else {
90
+ await target.click(options);
91
+ }
92
+ }
93
+ }
94
+
95
+ const payload = {};
96
+
97
+ if (downloads.length) {
98
+ payload.downloads = downloads;
99
+ }
100
+
101
+ return create_success({ message: "Click(s) executed successfully", payload });
102
+ };
103
+
104
+ /**
105
+ *
106
+ * @param {string} url
107
+ */
108
+ const download_inline_file = async (url) => {
109
+ // Use Node to fetch directly
110
+ const response = await fetch(url);
111
+ if (!response.ok) {
112
+ throw new Error(`Failed to download file: ${response.statusText}`);
113
+ }
114
+
115
+ // try to look at Content-Disposition header first
116
+ const cd_header = response.headers.get("Content-Disposition");
117
+ let suggested_filename = null;
118
+
119
+ if (cd_header) {
120
+ const matches = cd_header.match(/filename="?(.+?)"?;?$/);
121
+ if (matches) {
122
+ suggested_filename = matches[1];
123
+ }
124
+ }
125
+
126
+ if (!suggested_filename) {
127
+ const uri = new URL(url);
128
+ const pathname = uri.pathname;
129
+ const last_part = pathname.split("/").pop();
130
+
131
+ suggested_filename = last_part || v4();
132
+ }
133
+
134
+ const buffer = Buffer.from(await response.arrayBuffer());
135
+
136
+ const file_path = `/tmp/${suggested_filename}`; // or configurable download dir
137
+
138
+ console.log("Downloaded file path:", file_path);
139
+
140
+ fs.writeFileSync(file_path, buffer);
141
+
142
+ return { file_name: suggested_filename, file_path, url };
143
+ };
@@ -0,0 +1,89 @@
1
+ import { create_success } from "#app/functions/create_success.js";
2
+ import { create_error_technical } from "#app/functions/create_error_technical.js";
3
+ import { get_chromium_page } from "../../helpers/get_chromium_page.js";
4
+ import { create_error_conceptual } from "#app/functions/create_error_conceptual.js";
5
+
6
+ export const browser_extract_data = async (params, stubber_context) => {
7
+ if (!params?.locators) {
8
+ return create_error_conceptual({ message: "No locators provided", details: { params } });
9
+ }
10
+
11
+ // Get the Chromium page
12
+ const page_result = await get_chromium_page(stubber_context.stubref);
13
+ if (!page_result.success) {
14
+ return page_result;
15
+ }
16
+
17
+ const page = page_result.payload;
18
+ // object with deeply nested properties, each string property is a locator to a dom element/s
19
+ const payload_locators = params?.locators;
20
+
21
+ const payload = await get_payload_from_dom(page, payload_locators);
22
+
23
+ return create_success(`DOM content retrieved successfully`, payload);
24
+ };
25
+
26
+ /** * Recursively retrieves data from the DOM based on the provided locators.
27
+ * @param {import("playwright").Page} page - The Playwright page object.
28
+ * @param {object|string} locators - The locators to use for extracting data.
29
+ * @param {import("playwright").Locator} [root=page] - The root locator to start from.
30
+ */
31
+ const get_payload_from_dom = async (page, locators, root = page) => {
32
+ // Handle string case directly
33
+ if (typeof locators === "string") {
34
+ const target = root.locator(locators);
35
+
36
+ return (await target.count()) ? await target.innerText() : null;
37
+ } else if (typeof locators === "object" && locators !== null && !Array.isArray(locators)) {
38
+ const is_list = "_list" in locators && "_list_item_properties" in locators;
39
+ if (is_list) {
40
+ // Handle list case
41
+ const elements = root.locator(locators._list);
42
+ const count = await elements.count();
43
+ const items = [];
44
+
45
+ for (let i = 0; i < count; i++) {
46
+ const element_handle = elements.nth(i);
47
+ const item = await get_payload_from_dom(page, locators._list_item_properties, element_handle);
48
+ items.push(item);
49
+ }
50
+
51
+ return items; // Return the array of items
52
+ }
53
+
54
+ const is_locator = "_locator" in locators;
55
+ if (is_locator) {
56
+ return await get_payload_from_locator(root, locators);
57
+ }
58
+
59
+ const result = {};
60
+ for (const [key, locator] of Object.entries(locators)) {
61
+ //recursively handle nested locators
62
+ result[key] = await get_payload_from_dom(page, locator, root);
63
+ }
64
+ return result; // Return the object with nested locators resolved
65
+ } else {
66
+ return null; // Return null if locators is neither a string nor an object
67
+ }
68
+ };
69
+
70
+ /**
71
+ * @typedef {"innertText"|"innerHTML"|"href"|"id"} TargetProperty
72
+ */
73
+
74
+ /**
75
+ * @param {import("playwright").Locator} root
76
+ * @param {{_locator: string, _target: TargetProperty}} locator - The locator object containing the properties to extract.
77
+ */
78
+ const get_payload_from_locator = async (root, locator) => {
79
+ const target = root.locator(locator._locator);
80
+ switch (locator._target) {
81
+ case "innerText":
82
+ return (await target.count()) ? await target.innerText() : null;
83
+ case "innerHTML":
84
+ return (await target.count()) ? await target.innerHTML() : null;
85
+ default:
86
+ // return (await target.count()) ? await target.getAttribute("id") : null;
87
+ return (await target.count()) ? await target.getAttribute(locator._target) : null;
88
+ }
89
+ };
@@ -0,0 +1,27 @@
1
+ import { create_error_conceptual } from "#app/functions/create_error_conceptual.js";
2
+ import { create_error_technical } from "#app/functions/create_error_technical.js";
3
+ import { create_success } from "#app/functions/create_success.js";
4
+ import { get_chromium_page } from "../../helpers/get_chromium_page.js";
5
+
6
+ export const browser_get_localstorage = async (param, stubber_context) => {
7
+ const page_result = await get_chromium_page(stubber_context.stubref);
8
+
9
+ if (!page_result.success) {
10
+ return page_result;
11
+ }
12
+
13
+ const page = page_result.payload;
14
+
15
+ const page_url = page.url();
16
+
17
+ if (!page_url || page_url === "about:blank") {
18
+ return create_error_conceptual({ message: "No page loaded", details: { url: page_url } });
19
+ }
20
+
21
+ const localstorage = await page.evaluate(() => {
22
+ /* global localStorage */
23
+ return localStorage;
24
+ });
25
+
26
+ return create_success({ message: "Local storage retrieved successfully", payload: localstorage });
27
+ };
@@ -0,0 +1,18 @@
1
+ // eslint-disable-next-line id-match
2
+ import { create_success } from "#app/functions/create_success.js";
3
+ import { get_chromium_page } from "../../helpers/get_chromium_page.js";
4
+
5
+ export const browser_navigate = async (params, stubber_context) => {
6
+ const result = await get_chromium_page(stubber_context.stubref);
7
+ if (!result.success) {
8
+ return result;
9
+ }
10
+
11
+ const { url } = params || {};
12
+
13
+ const page = result.payload;
14
+
15
+ if (url) await page.goto(url);
16
+
17
+ return create_success({ message: "Navigated to new page", payload: {} });
18
+ };
@@ -0,0 +1,58 @@
1
+ import { create_success } from "#app/functions/create_success.js";
2
+ import { create_error_conceptual } from "#app/functions/create_error_conceptual.js";
3
+ import { create_error_technical } from "#app/functions/create_error_technical.js";
4
+ import { get_chromium_page } from "#app/helpers/get_chromium_page.js";
5
+
6
+ /**
7
+ *
8
+ * @param {Object} params
9
+ * @param {Array<{locator?: string, frame?:string, key?: string, keys?: string}>} params.entries - Array of locator and key pairs.
10
+ * @returns
11
+ */
12
+ export const browser_press_key = async ({ entries }, stubber_context) => {
13
+ const page_result = await get_chromium_page(stubber_context.stubref);
14
+ if (!page_result.success) {
15
+ return page_result;
16
+ }
17
+ const page = page_result.payload;
18
+
19
+ for (let { locator, frame, key, keys } of entries) {
20
+ if (!key && !keys) {
21
+ return create_error_conceptual({ message: "'key' or 'keys' is required", details: { locator, key, keys } });
22
+ }
23
+
24
+ if (key) {
25
+ key = key.toString();
26
+ }
27
+
28
+ if (!locator) {
29
+ if (key) {
30
+ await page.keyboard.press(key);
31
+ } else if (keys) {
32
+ await page.keyboard.type(keys);
33
+ }
34
+ continue; // If no locator is provided, just press the key on the page
35
+ }
36
+
37
+ /** @type {import('playwright').Locator} */
38
+ let target;
39
+ if (frame) {
40
+ const frame_handle = page.frameLocator(frame);
41
+ target = frame_handle.locator(locator);
42
+ } else {
43
+ target = page.locator(locator);
44
+ }
45
+
46
+ if (!(await target.count())) {
47
+ return create_error_conceptual({ message: "No element found for locator", details: { locator, key, keys } });
48
+ }
49
+
50
+ if (key) {
51
+ await target.press(key);
52
+ } else if (keys) {
53
+ await target.pressSequentially(keys);
54
+ }
55
+ }
56
+
57
+ return create_success({ message: "Key(s) pressed successfully", payload: {} });
58
+ };
@@ -0,0 +1,51 @@
1
+ import { create_success } from "#app/functions/create_success.js";
2
+ import { create_error_conceptual } from "#app/functions/create_error_conceptual.js";
3
+ import { create_error_technical } from "#app/functions/create_error_technical.js";
4
+ import { get_chromium_page } from "../../helpers/get_chromium_page.js";
5
+
6
+ /**
7
+ *
8
+ * @param {Object} params
9
+ * @param {Array<{locator: string, frame?:string, text: string}>} params.entries - Array of locator and text pairs.
10
+ * @returns
11
+ */
12
+ export const browser_write_text = async ({ entries }, stubber_context) => {
13
+ const page_result = await get_chromium_page(stubber_context.stubref);
14
+ if (!page_result.success) {
15
+ return page_result;
16
+ }
17
+ const page = page_result.payload;
18
+
19
+ for (let { locator, frame, text } of entries) {
20
+ if (!locator) {
21
+ return create_error_conceptual({ message: "Locator is required", details: { locator, text } });
22
+ }
23
+
24
+ if (!text) {
25
+ return create_error_conceptual({ message: "Text is required", details: { locator, text } });
26
+ }
27
+
28
+ text = text.toString();
29
+
30
+ if (typeof text !== "string" || text.trim() === "") {
31
+ return create_error_conceptual({ message: "Text must be a non-empty string", details: { locator, text } });
32
+ }
33
+
34
+ /** @type {import('playwright').Locator} */
35
+ let target;
36
+ if (frame) {
37
+ const frame_handle = page.frameLocator(frame);
38
+ target = frame_handle.locator(locator);
39
+ } else {
40
+ target = page.locator(locator);
41
+ }
42
+
43
+ if (!(await target.count())) {
44
+ return create_error_conceptual({ message: "No element found for locator", details: { locator, text } });
45
+ }
46
+
47
+ await target.fill(text);
48
+ }
49
+
50
+ return create_success({ message: "Text written successfully", payload: {} });
51
+ };
@@ -0,0 +1,56 @@
1
+ import { create_error_conceptual } from "#app/functions/create_error_conceptual.js";
2
+ import { create_error_technical } from "#app/functions/create_error_technical.js";
3
+ import { create_success } from "#app/functions/create_success.js";
4
+ import { exec as exec_cb } from "node:child_process";
5
+ import { promisify } from "node:util";
6
+
7
+ const exec = promisify(exec_cb);
8
+
9
+ /**
10
+ * Run an arbitrary CLI command and return stdout/stderr.
11
+ * @param {{command: string, timeout?: number, cwd?: string, env?: Object, max_buffer?: number}} params
12
+ */
13
+ export const cli_run = async (params, _stubber) => {
14
+ const { command, timeout, cwd, env, max_buffer } = params || {};
15
+
16
+ if (!command || typeof command !== "string") {
17
+ return create_error_conceptual({
18
+ message: "Command parameter is required and must be a string",
19
+ details: { params },
20
+ });
21
+ }
22
+
23
+ try {
24
+ /** @type {import("child_process").ExecOptionsWithStringEncoding} */
25
+ const options = {
26
+ // eslint-disable-next-line id-match
27
+ maxBuffer: 10 * 1024 * 1024,
28
+ };
29
+
30
+ if (typeof timeout === "number") options.timeout = timeout;
31
+
32
+ if (cwd) options.cwd = cwd;
33
+
34
+ if (env && typeof env === "object") options.env = { ...process.env, ...env };
35
+
36
+ // eslint-disable-next-line id-match
37
+ if (typeof max_buffer === "number") options.maxBuffer = max_buffer;
38
+
39
+ const { stdout, stderr } = await exec(command, options);
40
+
41
+ return create_success({
42
+ message: "Command executed successfully",
43
+ payload: { stdout: String(stdout), stderr: String(stderr) },
44
+ });
45
+ } catch (error) {
46
+ // if exec failed (non-zero exit code or other), include stdout/stderr when available
47
+ try {
48
+ if (error && error.stdout) error.stdout = String(error.stdout);
49
+ if (error && error.stderr) error.stderr = String(error.stderr);
50
+ } catch (e) {
51
+ // ignore
52
+ }
53
+
54
+ return create_error_technical(error);
55
+ }
56
+ };
@@ -0,0 +1,80 @@
1
+ import { create_error_conceptual } from "#app/functions/create_error_conceptual.js";
2
+ import { create_error_technical } from "#app/functions/create_error_technical.js";
3
+ import { create_success } from "#app/functions/create_success.js";
4
+ // eslint-disable-next-line id-match
5
+ import { writeFile } from "node:fs/promises";
6
+ import { tmpdir } from "node:os";
7
+ import { join } from "node:path";
8
+
9
+ /**
10
+ * @typedef DownloadFilesParams
11
+ * @property {Array<string|{fileuuid:string}>} files - The files to download
12
+ */
13
+
14
+ /**
15
+ * @typedef ResponseFile
16
+ * @property {string} fileuuid - The UUID of the file
17
+ * @property {boolean} status - The status of the file download
18
+ * @property {Buffer<ArrayBuffer>} data - The file data
19
+ */
20
+
21
+ /**
22
+ *
23
+ * @param {DownloadFilesParams} params
24
+ */
25
+ export const download_files = async (params, _stubber) => {
26
+ // todo: how will fileserver work now that we dont have sockets
27
+ const socket = _stubber?.stubber_context.socket;
28
+ if (!socket) {
29
+ return create_error_conceptual({ message: "Socket not available", details: {} });
30
+ }
31
+
32
+ const { files } = params || {};
33
+ if (!files) {
34
+ return create_error_conceptual({ message: "Files parameter is required", details: { params } });
35
+ }
36
+ if (!Array.isArray(files) || files.length === 0) {
37
+ return create_error_conceptual({ message: "Files must be a non-empty array", details: { files } });
38
+ }
39
+
40
+ const uuids = [];
41
+ for (const file of files) {
42
+ if (typeof file === "string") {
43
+ uuids.push(file);
44
+ } else if (file && typeof file === "object" && typeof file.fileuuid === "string") {
45
+ uuids.push(file.fileuuid);
46
+ } else {
47
+ return create_error_conceptual({ message: "Invalid file entry in files array", details: { entry: file } });
48
+ }
49
+ }
50
+
51
+ return new Promise((resolve) => {
52
+ const downloaded_files = [];
53
+
54
+ socket.emit("download_files", uuids, async (response) => {
55
+ if (response.success) {
56
+ /** @type {ResponseFile[]} */
57
+ const files = response.payload;
58
+
59
+ for (const file of files) {
60
+ if (file.status == 200) {
61
+ // todo: support passing download location in params
62
+ const tmp_dir = tmpdir();
63
+ const file_path = join(tmp_dir, file.fileuuid);
64
+
65
+ await writeFile(file_path, file.data);
66
+
67
+ downloaded_files.push(file_path);
68
+ } else {
69
+ console.log("Error downloading file:", file);
70
+ }
71
+ }
72
+
73
+ resolve(create_success({ message: "Files downloaded successfully", payload: { downloaded_files } }));
74
+ } else {
75
+ console.log("Error downloading files:", response.error);
76
+ resolve(create_error_technical(response.error));
77
+ }
78
+ });
79
+ });
80
+ };
@@ -0,0 +1,57 @@
1
+ import { create_error_conceptual } from "#app/functions/create_error_conceptual.js";
2
+ import { create_error_technical } from "#app/functions/create_error_technical.js";
3
+ import { create_success } from "#app/functions/create_success.js";
4
+ // eslint-disable-next-line id-match
5
+ import { readFile } from "node:fs/promises";
6
+
7
+ /**
8
+ * Upload files to the server
9
+ * @param {Object} params - The parameters for the upload
10
+ * @param {Array<string>} params.files - The path to files to upload
11
+ */
12
+ export const upload_files = async (params, _stubber) => {
13
+ /** @type {import("socket.io-client").Socket} */
14
+ const socket = _stubber?.stubber_context.socket;
15
+ if (!socket) {
16
+ return create_error_conceptual({ message: "Socket not available", details: {} });
17
+ }
18
+
19
+ const { files } = params || {};
20
+ if (!files || !Array.isArray(files)) {
21
+ return create_error_conceptual({
22
+ message: "Files parameter is required and must be an array",
23
+ details: { params },
24
+ });
25
+ }
26
+
27
+ const uploaded_files = [];
28
+
29
+ for (const filepath of files) {
30
+ const filename = filepath.split("/").pop();
31
+ const file = await readFile(filepath); // Buffer
32
+
33
+ // Wrap socket.emit in a promise so we can await it
34
+ const file_info = await emit_upload(socket, { filepath, filename }, file);
35
+
36
+ if (file_info) {
37
+ uploaded_files.push(file_info);
38
+ }
39
+ }
40
+
41
+ return create_success({ message: "Files uploaded successfully", payload: { uploaded_files } });
42
+ };
43
+
44
+ /**
45
+ * Wraps a socket.emit call in a Promise
46
+ */
47
+ function emit_upload(socket, metadata, buffer) {
48
+ return new Promise((resolve, reject) => {
49
+ socket.emit("upload_file", metadata, buffer, (response) => {
50
+ if (!response.success) {
51
+ reject(new Error(response));
52
+ } else {
53
+ resolve(response.payload);
54
+ }
55
+ });
56
+ });
57
+ }
@@ -0,0 +1,26 @@
1
+ import { browser_click } from "./browser/browser_click.js";
2
+ import { browser_extract_data } from "./browser/browser_extract_data.js";
3
+ import { browser_get_localstorage } from "./browser/browser_get_localstorage.js";
4
+ import { browser_navigate } from "./browser/browser_navigate.js";
5
+ import { browser_write_text } from "./browser/browser_write_text.js";
6
+ import { browser_press_key } from "./browser/browser_press_key.js";
7
+ import { cli_run } from "./cli/cli_run.js";
8
+
9
+ import { upload_files } from "./file-server/upload_files.js";
10
+ import { download_files } from "./file-server/download_files.js";
11
+
12
+ const all_commands = {
13
+ browser_click,
14
+ browser_extract_data,
15
+ browser_get_localstorage,
16
+ browser_navigate,
17
+ browser_write_text,
18
+ browser_press_key,
19
+
20
+ cli_run,
21
+
22
+ upload_files,
23
+ download_files,
24
+ };
25
+
26
+ export { all_commands };
@@ -0,0 +1,114 @@
1
+ import jsonata from "jsonata";
2
+ // eslint-disable-next-line id-match
3
+ import { cloneDeep } from "lodash-es";
4
+ import { all_commands } from "./index.js";
5
+ import { create_success } from "#app/functions/create_success.js";
6
+ import { create_error_technical } from "#app/functions/create_error_technical.js";
7
+ import { create_error_conceptual } from "#app/functions/create_error_conceptual.js";
8
+
9
+ /**
10
+ * @typedef {object} Command
11
+ * @property {string} commandtype - The type of command to run.
12
+ * @property {object} [params] - The parameters for the command.
13
+ * @property {bool} [continue_on_error] - Whether to continue on error.
14
+ * @property {number} [__order] - The order in which to run the command.
15
+ * @property {string[]} [conditions] - Array of jsonata conditions to evaluate.
16
+ */
17
+
18
+ /**
19
+ * @param {{[key: string]: Command}} commands
20
+ * @param {object} _stubber - The context this task is being ran in.
21
+ */
22
+ export const run_commands = async (task, _stubber) => {
23
+ const commands = task.params?.commands;
24
+
25
+ const command_entries = Object.entries(commands).sort((a, b) => {
26
+ // Sort commands by their __order key, if it exists
27
+ return (a[1].__order || 0) - (b[1].__order || 0);
28
+ });
29
+
30
+ const payload = {
31
+ commands: {},
32
+ };
33
+
34
+ const action_context = _stubber.stubber_context || { stubpost: { tasks: {} } };
35
+ const cloned_context = cloneDeep(action_context);
36
+ // update the stubber_context on the fly as commands are executed
37
+ cloned_context.stubpost.tasks[task.task_name] = { payload };
38
+ cloned_context.commands = payload.commands;
39
+
40
+ let important_command_failed = false;
41
+ for (const [command_name, command] of command_entries) {
42
+ console.log(`Starting command: ${command_name}`, command);
43
+
44
+ // Check if the command has conditions and evaluate them
45
+ const conditions_met = await evaluate_command_conditions(command, cloned_context);
46
+ if (!conditions_met) {
47
+ console.log(`Skipping command ${command_name} due to unmet conditions.`);
48
+ payload.commands[command_name] = create_success({
49
+ message: "Command skipped due to conditions not met",
50
+ details: {
51
+ conditions: command.conditions,
52
+ },
53
+ });
54
+ continue; // Skip this command if conditions are not met
55
+ }
56
+
57
+ const commandtype = command.commandtype;
58
+
59
+ const command_function = all_commands[commandtype];
60
+ if (!command_function) {
61
+ payload.commands[command_name] = create_error_conceptual({
62
+ message: `Command function not found for command type: ${commandtype}`,
63
+ details: { command },
64
+ });
65
+ } else {
66
+ try {
67
+ payload.commands[command_name] = await command_function(command.params, _stubber);
68
+ } catch (error) {
69
+ console.error(`Error running command ${command_name}:`, error);
70
+ payload.commands[command_name] = create_error_technical(error);
71
+ }
72
+ }
73
+
74
+ if (!payload.commands[command_name].success && !command.continue_on_error) {
75
+ important_command_failed = true;
76
+ break; // Stop execution if continue_on_error is falsy
77
+ }
78
+ }
79
+
80
+ if (important_command_failed) {
81
+ return create_error_conceptual({
82
+ message: "Some commands failed",
83
+ details: { ...payload },
84
+ });
85
+ }
86
+
87
+ return create_success({ message: "All commands executed successfully", payload });
88
+ };
89
+
90
+ /**
91
+ * @param {Command} command
92
+ * @param {object} context
93
+ */
94
+ const evaluate_command_conditions = async (command, context) => {
95
+ if (!command.conditions || Array.isArray(command.conditions) === false) {
96
+ return true; // No conditions to evaluate, so we consider it true
97
+ }
98
+
99
+ for (let condition of command.conditions) {
100
+ if (typeof condition !== "string") {
101
+ condition = JSON.stringify(condition);
102
+ }
103
+
104
+ const expression = jsonata(condition);
105
+
106
+ const result = await expression.evaluate(context);
107
+
108
+ if (!result) {
109
+ return false;
110
+ }
111
+ }
112
+
113
+ return true;
114
+ };
@@ -0,0 +1,20 @@
1
+ import { create_success } from "#app/functions/create_success.js";
2
+ import { task_schema } from "#lib/interfaces/http/routers/stubber_task_schema.js";
3
+
4
+ /** @type {{ last_task_received: Date|null }} */
5
+ export let worker_status = {
6
+ last_task_received: null,
7
+ };
8
+
9
+ /**
10
+ *
11
+ * @param {z.infer<typeof task_schema>} task
12
+ */
13
+ export const virtual_worker_status = async (task) => {
14
+ return create_success({
15
+ message: "Virtual worker is operational",
16
+ payload: {
17
+ last_task_received: worker_status.last_task_received?.toISOString(),
18
+ },
19
+ });
20
+ };
@@ -0,0 +1,34 @@
1
+ import { run_commands } from "#app/commands/run_commands.js";
2
+ import { task_schema } from "#lib/interfaces/http/routers/stubber_task_schema.js";
3
+ import * as z from "zod";
4
+ import { worker_status } from "./virtual_worker_status.js";
5
+
6
+ const command_schema = z.object({
7
+ commandtype: z.string().min(1),
8
+ params: z.record(z.string(), z.any()).optional(),
9
+ continue_on_error: z.boolean().optional(),
10
+ __order: z.number().optional(),
11
+ conditions: z.array(z.string()).optional(),
12
+ });
13
+
14
+ const params_schema = z.object({
15
+ commands: z.record(z.string(), command_schema),
16
+ worker: z.object({
17
+ group: z.string().min(1),
18
+ }),
19
+ });
20
+
21
+ /**
22
+ *
23
+ * @param {z.infer<typeof task_schema>} task
24
+ */
25
+ export const virtual_worker_task = async (task) => {
26
+ worker_status.last_task_received = new Date();
27
+
28
+ const stubber = task._stubber;
29
+ task.params = params_schema.parse(task.params);
30
+
31
+ const task_result = await run_commands(task, stubber);
32
+
33
+ return task_result;
34
+ };
@@ -0,0 +1,12 @@
1
+ export const create_error_conceptual = ({ message, details = {} }) => {
2
+ console.warn("Conceptual Error:", message, details);
3
+
4
+ return {
5
+ success: false,
6
+ message: message || "An error occurred",
7
+ error: {
8
+ type: "conceptual",
9
+ details: details,
10
+ },
11
+ };
12
+ };
@@ -0,0 +1,18 @@
1
+ /**
2
+ * @param {Error} error
3
+ */
4
+ export const create_error_technical = (error) => {
5
+ console.error("Technical error:", error);
6
+ return {
7
+ success: false,
8
+ message: error.message || "An error occurred",
9
+ error: {
10
+ type: "technical",
11
+ details: {
12
+ name: error.name,
13
+ // stack up to the second \n newline
14
+ stack: error.stack.split("\n").slice(0, 2).join("\n"),
15
+ },
16
+ },
17
+ };
18
+ };
@@ -0,0 +1,7 @@
1
+ export const create_success = ({ message, payload = {} }) => {
2
+ return {
3
+ success: true,
4
+ message: message || "Operation completed successfully",
5
+ payload: payload,
6
+ };
7
+ };
@@ -0,0 +1,72 @@
1
+ import { create_error_conceptual } from "#app/functions/create_error_conceptual.js";
2
+ import { create_error_technical } from "#app/functions/create_error_technical.js";
3
+ import { create_success } from "#app/functions/create_success.js";
4
+ import { chromium } from "playwright";
5
+
6
+ /** @type {import("playwright").Browser} */
7
+ let browser;
8
+
9
+ /** @type {Record<string, {
10
+ * context: import("playwright").BrowserContext,
11
+ * last_used: number
12
+ }>} */
13
+ const contexts = {};
14
+ const CONTEXT_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
15
+
16
+ /**
17
+ * @param {string} stubref
18
+ * @returns {Promise<{ success: boolean, payload?: import("playwright").Page }>}
19
+ */
20
+ export const get_chromium_page = async (stubref) => {
21
+ if (!stubref) {
22
+ return create_error_conceptual({ message: "stubref is required to get a Chromium page." });
23
+ }
24
+
25
+ // Cleanup unused contexts
26
+ const now = Date.now();
27
+ for (const [key, value] of Object.entries(contexts)) {
28
+ if (value.last_used && now - value.last_used > CONTEXT_TIMEOUT_MS) {
29
+ try {
30
+ await value.context.close();
31
+ } catch (e) {
32
+ // ignore errors during cleanup
33
+ }
34
+ delete contexts[key];
35
+ }
36
+ }
37
+
38
+ // Launch the browser once and reuse it
39
+ if (!browser) {
40
+ browser = await chromium.launch({
41
+ headless: false,
42
+ // eslint-disable-next-line id-match
43
+ slowMo: 1500,
44
+ });
45
+ }
46
+
47
+ // Create a new context per stubref if not already created
48
+ if (!contexts[stubref]) {
49
+ const context = await browser.newContext({
50
+ // eslint-disable-next-line id-match
51
+ ignoreHTTPSErrors: true,
52
+ });
53
+ contexts[stubref] = {
54
+ context,
55
+ last_used: now,
56
+ };
57
+ } else {
58
+ contexts[stubref].last_used = now;
59
+ }
60
+
61
+ const context = contexts[stubref].context;
62
+
63
+ // Try to find an open page in the context
64
+ let page = context.pages().find((p) => !p.isClosed());
65
+
66
+ // If no open page, create a new one
67
+ if (!page) {
68
+ page = await context.newPage();
69
+ }
70
+
71
+ return create_success({ message: "Successfully retrieved Chromium page.", payload: page });
72
+ };
package/app.js ADDED
@@ -0,0 +1,4 @@
1
+ import "dotenv/config"; // see https://github.com/motdotla/dotenv#how-do-i-use-dotenv-with-import
2
+ import { start_http_server } from "#lib/interfaces/http/index.js";
3
+
4
+ start_http_server();
package/app_config.js ADDED
@@ -0,0 +1,6 @@
1
+ export const config = {
2
+ WORKER_CONNECT_URL: process.env.WORKER_CONNECT_URL,
3
+ WORKER_SIGNATURE: process.env.WORKER_SIGNATURE,
4
+ WORKER_WORKERUUID: process.env.WORKER_WORKERUUID,
5
+ WORKER_GROUPS: process.env.WORKER_GROUPS?.split(",") || [],
6
+ };
package/cli.js ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+ import "dotenv/config";
3
+ import { start_http_server } from "#lib/interfaces/http/index.js";
4
+
5
+ start_http_server();
@@ -0,0 +1,29 @@
1
+ // client-vnc-agent.js
2
+ import io from "socket.io-client";
3
+ import net from "net";
4
+
5
+ const socket = io("ws://localhost:7450", {
6
+ path: "/tunnel",
7
+ transports: ["websocket"],
8
+ });
9
+
10
+ socket.on("connect", () => {
11
+ console.log("Connected to tunnel server");
12
+ const vncSocket = net.createConnection({ host: "localhost", port: 5900 });
13
+
14
+ vncSocket.on("data", (data) => {
15
+ socket.emit("vnc-data", data);
16
+ });
17
+
18
+ vncSocket.on("close", () => {
19
+ socket.disconnect();
20
+ });
21
+
22
+ socket.on("vnc-data", (b64) => {
23
+ vncSocket.write(b64);
24
+ });
25
+
26
+ socket.on("disconnect", () => {
27
+ vncSocket.end();
28
+ });
29
+ });
@@ -0,0 +1,23 @@
1
+ import body_parser from "body-parser";
2
+ import express from "express";
3
+ import task_gateway_router from "./routers/task_gateway.js";
4
+ import { virtual_worker_status } from "#root/app/controllers/virtual_worker_status.js";
5
+
6
+ export const start_http_server = () => {
7
+ const app = express();
8
+ app.use(body_parser.json());
9
+
10
+ // ROUTERS
11
+ app.use("/api/v1/task-gateway", task_gateway_router);
12
+
13
+ // health check endpoint
14
+ app.get("/api/v1/health", async (req, res) => {
15
+ const worker_status = await virtual_worker_status({});
16
+ res.status(200).json(worker_status);
17
+ });
18
+
19
+ const PORT = process.env.HTTP_PORT || 3000;
20
+ app.listen(PORT, () => {
21
+ console.log(`HTTP server is running on port ${PORT}`);
22
+ });
23
+ };
@@ -0,0 +1,16 @@
1
+ import * as z from "zod";
2
+
3
+ export const stubber_schema = z.object({
4
+ orguuid: z.uuid(),
5
+ });
6
+
7
+ export const task_schema = z.object({
8
+ tasktype: z.string().min(1),
9
+ task_name: z.string().min(1),
10
+ params: z.any(),
11
+ _stubber: stubber_schema,
12
+ });
13
+
14
+ export const task_payload_schema = z.object({
15
+ task: task_schema,
16
+ });
@@ -0,0 +1,49 @@
1
+ import { virtual_worker_task } from "#app/controllers/virtual_worker_task.js";
2
+ import { create_error_conceptual } from "#app/functions/create_error_conceptual.js";
3
+ import { create_error_technical } from "#app/functions/create_error_technical.js";
4
+ // eslint-disable-next-line id-match
5
+ import { Router } from "express";
6
+ import * as z from "zod";
7
+ import { task_payload_schema } from "./stubber_task_schema.js";
8
+ import { virtual_worker_status } from "#root/app/controllers/virtual_worker_status.js";
9
+
10
+ const router = Router();
11
+
12
+ router.post("/virtual_worker_task", async (req, res) => {
13
+ try {
14
+ const payload = task_payload_schema.parse(req.body);
15
+ const task = payload.task;
16
+
17
+ console.log("Received task:", task.task_name, "of type:", task.tasktype, "with params:", task.params);
18
+
19
+ const result = await virtual_worker_task(task);
20
+
21
+ if (result.success) {
22
+ res.status(200).json(result);
23
+ } else {
24
+ res.status(400).json(result);
25
+ }
26
+ } catch (error) {
27
+ // handle zod validation errors
28
+ if (error instanceof z.ZodError) {
29
+ // console.log("Zod validation error:", error);
30
+ res.status(400).json(
31
+ create_error_conceptual({
32
+ message: "Validation error",
33
+ details: {
34
+ errors: JSON.parse(error.message),
35
+ },
36
+ })
37
+ );
38
+ } else {
39
+ res.status(500).json(create_error_technical(error));
40
+ }
41
+ }
42
+ });
43
+
44
+ router.post("/virtual_worker_status", async (req, res) => {
45
+ const result = await virtual_worker_status({});
46
+ res.status(200).json(result);
47
+ });
48
+
49
+ export default router;
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@stubber/virtual-worker",
3
+ "version": "1.0.1",
4
+ "description": "Template to easily create a node app and keep development standards",
5
+ "main": "app.js",
6
+ "directories": {
7
+ "lib": "lib",
8
+ "test": "test"
9
+ },
10
+ "bin": {
11
+ "stubber-virtual-worker": "./cli.js"
12
+ },
13
+ "type": "module",
14
+ "imports": {
15
+ "#lib/*.js": "./lib/*.js",
16
+ "#root/*.js": "./*.js",
17
+ "#app/*.js": "./app/*.js"
18
+ },
19
+ "scripts": {
20
+ "dev": "nodemon app.js"
21
+ },
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/stubber/stubber-virtual-worker.git"
25
+ },
26
+ "author": "Stubber",
27
+ "license": "ISC",
28
+ "bugs": {
29
+ "url": "https://github.com/stubber/stubber-virtual-worker/issues"
30
+ },
31
+ "homepage": "https://github.com/stubber/stubber-virtual-worker#readme",
32
+ "dependencies": {
33
+ "body-parser": "^1.20.3",
34
+ "dotenv": "^16.0.3",
35
+ "express": "^4.18.2",
36
+ "jsonata": "^2.1.0",
37
+ "lodash-es": "^4.17.21",
38
+ "net": "^1.0.2",
39
+ "playwright": "^1.53.0",
40
+ "socket.io-client": "^4.8.1",
41
+ "uuid": "^9.0.0",
42
+ "zod": "^4.1.12"
43
+ },
44
+ "devDependencies": {
45
+ "eslint": "^8.56.0",
46
+ "eslint-plugin-filenames": "^1.3.2",
47
+ "nodemon": "^3.1.10"
48
+ }
49
+ }
@@ -0,0 +1,5 @@
1
+ pandas
2
+ openpyxl
3
+ odfpy
4
+ numpy
5
+ xlsxwriter