@pipelab/plugin-construct 1.0.0-beta.0

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.
@@ -0,0 +1,286 @@
1
+ import {
2
+ Action,
3
+ ActionRunnerData,
4
+ createNumberParam,
5
+ createPasswordParam,
6
+ createPathParam,
7
+ createStringParam,
8
+ InputsDefinition,
9
+ ParamsToInput,
10
+ runWithLiveLogs,
11
+ fetchPackage,
12
+ } from "@pipelab/plugin-core";
13
+ import { script } from "./assets/script.js";
14
+ import * as v from "valibot";
15
+ import { BrowserContext } from "playwright";
16
+ import { dirname, join, delimiter } from "node:path";
17
+ import { cp, mkdir } from "node:fs/promises";
18
+ import { homedir } from "node:os";
19
+ import { createRequire } from "node:module";
20
+
21
+ const platform = process.platform;
22
+ const { LOCALAPPDATA, XDG_CONFIG_HOME } = process.env;
23
+
24
+ const isCI = process.env.CI === "true";
25
+
26
+ let baseProfile;
27
+ if (platform === "win32") {
28
+ baseProfile = join(LOCALAPPDATA ?? "", "Google", "Chrome", "User Data");
29
+ } else if (platform === "linux") {
30
+ baseProfile = join(XDG_CONFIG_HOME ?? "", "google-chrome");
31
+ } else if (platform === "darwin") {
32
+ baseProfile = join(homedir(), "Library", "Application Support", "Google", "Chrome");
33
+ }
34
+
35
+ export const sharedParams = {
36
+ username: createStringParam("", {
37
+ label: "Username",
38
+ required: false,
39
+ description: "Your Construct username",
40
+ }),
41
+ password: createPasswordParam("", {
42
+ description:
43
+ "Your Construct password. Will only be used locally to automate the export on Construct website via a local browser. Will not be sent to any server.",
44
+ required: false,
45
+ label: "Password",
46
+ }),
47
+ version: createStringParam("", {
48
+ description: "The Construct version you want to use",
49
+ label: "Version",
50
+ required: false,
51
+ }),
52
+ headless: {
53
+ description: "Whether to show the browser while export",
54
+ required: false,
55
+ control: {
56
+ type: "boolean",
57
+ },
58
+ value: false,
59
+ label: "Start headless",
60
+ },
61
+ timeout: createNumberParam(120, {
62
+ description: "The timeout (in seconds) to close the browser if it's stuck",
63
+ required: false,
64
+ label: "Timeout",
65
+ }),
66
+ // customBrowser: {
67
+ // description: 'Start your own browser rather than the predefined one',
68
+ // control: {
69
+ // type: 'path',
70
+ // options: {
71
+ // properties: ['openFile']
72
+ // }
73
+ // },
74
+ // label: 'Custom browser',
75
+ // value: ''
76
+ // },
77
+ customProfile: createPathParam(undefined, {
78
+ required: false,
79
+ description:
80
+ "Use your own profile (X:\\Users\\XXX\\AppData\\Local\\Google\\Chrome\\User Data). Usefull if you want to reuse plugins installed in your current browser",
81
+ control: {
82
+ type: "path",
83
+ options: {
84
+ properties: ["openDirectory"],
85
+ defaultPath: baseProfile,
86
+ },
87
+ },
88
+ label: "Custom profile",
89
+ }),
90
+ // addonsFolder: {
91
+ // description: 'Folder containing addons to import in the editor',
92
+ // required: false,
93
+ // control: {
94
+ // type: 'path',
95
+ // options: {
96
+ // buttonLabel: 'Addons folder',
97
+ // properties: ['openDirectory']
98
+ // }
99
+ // },
100
+ // value: '',
101
+ // label: 'Addons folder'
102
+ // }
103
+ } satisfies InputsDefinition;
104
+
105
+ type Inputs = ParamsToInput<typeof sharedParams>;
106
+
107
+ export const exportc3p = async <ACTION extends Action>(
108
+ file: string,
109
+ { cwd, log, inputs, setOutput, paths, abortSignal, context: ctx }: ActionRunnerData<ACTION>,
110
+ ) => {
111
+ let browserContext: BrowserContext | undefined = undefined;
112
+ let browser: any | undefined = undefined;
113
+
114
+ abortSignal.addEventListener("abort", () => {
115
+ console.error("aborted");
116
+
117
+ browserContext?.close();
118
+ });
119
+ const newInputs = inputs as Inputs;
120
+
121
+ // const { addonsFolder } = newInputs
122
+
123
+ const { thirdparty, node, pnpm } = paths;
124
+
125
+ const browserName: "chromium" | "firefox" | "webkit" = "chromium";
126
+
127
+ const { packageDir: playwrightPkgPath } = await fetchPackage("playwright-core", "1.48.2", {
128
+ installDeps: true,
129
+ context: ctx,
130
+ });
131
+ const playwrightCli = join(playwrightPkgPath, "cli.js");
132
+ const browsersPath = join(thirdparty, "playwright-browsers");
133
+
134
+ process.env.PLAYWRIGHT_BROWSERS_PATH = browsersPath;
135
+
136
+ log("Downloading browser to", browsersPath);
137
+ await runWithLiveLogs(
138
+ node,
139
+ [playwrightCli, "install", browserName],
140
+ {
141
+ env: {
142
+ ...process.env,
143
+ PLAYWRIGHT_BROWSERS_PATH: browsersPath,
144
+ PATH: `${dirname(node)}${delimiter}${process.env.PATH}`,
145
+ },
146
+ cancelSignal: abortSignal,
147
+ },
148
+ log,
149
+ {
150
+ onStdout(data) {
151
+ log(data);
152
+ },
153
+ onStderr(data) {
154
+ log(data);
155
+ },
156
+ },
157
+ );
158
+
159
+ const require = createRequire(import.meta.url);
160
+ const playwrightModule = require(join(playwrightPkgPath, "index.js"));
161
+ const playwright = playwrightModule.default || playwrightModule;
162
+
163
+ const downloadDir = join(cwd, "playwright");
164
+
165
+ log("Browser downloaded to", downloadDir);
166
+
167
+ log("Exporting construct project");
168
+
169
+ console.log("newInputs", newInputs);
170
+
171
+ const browserInstance = playwright[browserName];
172
+
173
+ let version = newInputs.version;
174
+ // if version is full digit, prepend "r", otherwise, use as is
175
+ if (version && /^\d+$/.test(version as string)) {
176
+ version = `r${version}`;
177
+ }
178
+ const headless = newInputs.headless;
179
+
180
+ // if (newInputs.customBrowser && !newInputs.customProfile) {
181
+ // throw new Error('You must specify a custom profile when using a custom browser')
182
+ // }
183
+
184
+ // if (!newInputs.customBrowser && newInputs.customProfile) {
185
+ // throw new Error('You must specify a custom browser when using a custom profile')
186
+ // }
187
+
188
+ // if (newInputs.customBrowser && newInputs.customProfile) {
189
+ if (newInputs.customProfile) {
190
+ const customProfile = join(cwd, "playwright-profile");
191
+
192
+ await mkdir(customProfile, {
193
+ recursive: true,
194
+ });
195
+
196
+ const indexedDbPathSource = join(newInputs.customProfile as string, "Default", "IndexedDB");
197
+ const indexedDbPathDestination = join(customProfile, "Default", "IndexedDB");
198
+ const pathsToCopy = [
199
+ "https_editor.construct.net_0.indexeddb.blob",
200
+ "https_editor.construct.net_0.indexeddb.leveldb",
201
+ ];
202
+
203
+ for (const p of pathsToCopy) {
204
+ const from = join(indexedDbPathSource, p);
205
+ const to = join(indexedDbPathDestination, p);
206
+ await cp(from, to, { recursive: true });
207
+ }
208
+
209
+ browserContext = await browserInstance.launchPersistentContext(customProfile, {
210
+ headless: headless as boolean,
211
+ locale: "en-US",
212
+ recordVideo: isCI
213
+ ? {
214
+ dir: join(process.cwd(), "playwright"),
215
+ }
216
+ : undefined,
217
+ });
218
+ } else {
219
+ browser = await browserInstance.launch({
220
+ headless: headless as boolean,
221
+ });
222
+
223
+ browserContext = await browser.newContext({
224
+ locale: "en-US",
225
+ recordVideo: isCI
226
+ ? {
227
+ dir: join(process.cwd(), "playwright"),
228
+ }
229
+ : undefined,
230
+ });
231
+ await browserContext?.clearPermissions();
232
+ }
233
+
234
+ if (!browserContext) {
235
+ throw new Error("Failed to initialize browser context");
236
+ }
237
+
238
+ const page = await browserContext.newPage();
239
+
240
+ page.setDefaultTimeout((newInputs.timeout as number) * 1000);
241
+
242
+ // this exact sequn=ence make it work
243
+ await page.addInitScript(() => {
244
+ // @ts-expect-error dds
245
+ delete self.showOpenFilePicker;
246
+ });
247
+ page.on("filechooser", (worker) => {
248
+ console.log("filechooser created: " + worker.page.name);
249
+ });
250
+ // ---------------------------------
251
+
252
+ try {
253
+ const result = await script(
254
+ page,
255
+ log,
256
+ file,
257
+ newInputs.username as string,
258
+ newInputs.password as string,
259
+ version as string,
260
+ downloadDir,
261
+ // addonsFolder,
262
+ );
263
+
264
+ log("Setting output result to ", result);
265
+
266
+ setOutput("folder", result); // deprecated
267
+
268
+ setOutput("parentFolder", dirname(result));
269
+ setOutput("zipFile", result);
270
+ } catch (e: any) {
271
+ log("error, no result, crashed", e);
272
+ throw new Error("ConstructExport failed: " + e.message);
273
+ } finally {
274
+ if (browserContext) {
275
+ await browserContext.close();
276
+ }
277
+ if (browser) {
278
+ await browser.close();
279
+ }
280
+ }
281
+ };
282
+
283
+ export const constructVersionValidator = (options: any) => {
284
+ void options;
285
+ return v.pipe(v.string(), v.regex(/^\d+(-\d+)?$/, "Invalid version"));
286
+ };
@@ -0,0 +1,37 @@
1
+ import { expect, test } from "vitest";
2
+ import { ExportActionRunner } from "./export-c3p.js";
3
+ import { browserWindow } from "@pipelab/shared";
4
+
5
+ test("adds 1 + 2 to equal 3", async () => {
6
+ const outputs: Record<string, unknown> = {};
7
+ // await ExportActionRunner({
8
+ // inputs: {
9
+ // password: '123',
10
+ // headless: false,
11
+ // username: 'abc',
12
+ // version: '350',
13
+ // file: ''
14
+ // },
15
+ // log: (...args) => {
16
+ // console.log(...args)
17
+ // },
18
+ // setOutput: (key, value) => {
19
+ // outputs[key] = value
20
+ // },
21
+ // meta: {
22
+ // definition: ''
23
+ // },
24
+ // setMeta: () => {
25
+ // console.log('set meta defined here')
26
+ // },
27
+ // cwd: '',
28
+ // paths: {
29
+ // assets: '',
30
+ // unpack: ''
31
+ // },
32
+ // api: undefined,
33
+ // browserWindow
34
+ // })
35
+ console.log("outputs", outputs);
36
+ expect(true).toBe(true);
37
+ }, 120_000);
package/src/index.ts ADDED
@@ -0,0 +1,34 @@
1
+ import { createNodeDefinition } from "@pipelab/plugin-core";
2
+ import { exportAction, ExportActionRunner } from "./export-c3p";
3
+ import { exportProjectAction, ExportProjectActionRunner } from "./export-project";
4
+ const icon = new URL("./assets/construct.webp", import.meta.url).href;
5
+ import { constructVersionValidator } from "./export-shared";
6
+
7
+ export default createNodeDefinition({
8
+ description: "Construct",
9
+ name: "Construct",
10
+ id: "construct",
11
+ icon: {
12
+ type: "image",
13
+ image: icon,
14
+ },
15
+ nodes: [
16
+ {
17
+ node: exportAction,
18
+ runner: ExportActionRunner,
19
+ },
20
+ {
21
+ node: exportProjectAction,
22
+ runner: ExportProjectActionRunner,
23
+ },
24
+ ],
25
+ validators: [
26
+ // {
27
+ // id: 'construct-version',
28
+ // description: 'Version must be a valid semver',
29
+ // validator: constructVersionValidator
30
+ // }
31
+ ],
32
+ });
33
+
34
+ export type { Params as ExportParams } from "./export-c3p";
@@ -0,0 +1,60 @@
1
+ import { expect, test, describe, afterEach } from "vitest";
2
+ import { readFile, access } from "node:fs/promises";
3
+ import { join, resolve, dirname } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { createSandbox, runAction } from "@pipelab/test-utils";
6
+ import { ExportActionRunner } from "../../src/export-c3p";
7
+
8
+ const __filename = fileURLToPath(import.meta.url);
9
+ const __dirname = dirname(__filename);
10
+ const fixturesPath = join(__dirname, "fixtures");
11
+
12
+ describe("End-to-End: Construct 3 Export Pipeline", () => {
13
+ let sandbox: Awaited<ReturnType<typeof createSandbox>>;
14
+
15
+ afterEach(async () => {
16
+ if (sandbox) {
17
+ await sandbox.remove();
18
+ }
19
+ });
20
+
21
+ test(
22
+ "should run the full C3 export action",
23
+ async () => {
24
+ sandbox = await createSandbox("c3-pipeline-e2e");
25
+ const fixtures = fixturesPath;
26
+ const jsonProject = JSON.parse(await readFile(join(fixtures, "c3-export.json"), "utf-8"));
27
+
28
+ // 1. Prepare inputs
29
+ const testC3pPath = resolve(fixtures, "c3-export/test.c3p");
30
+ const blockParams = jsonProject.canvas?.blocks?.[0]?.params;
31
+
32
+ const inputs = {
33
+ file: testC3pPath,
34
+ version: JSON.parse(blockParams?.version?.value || '"stable"'),
35
+ type: JSON.parse(blockParams?.type?.value || '"web"'),
36
+ // Add other required inputs from sharedParams if needed
37
+ };
38
+
39
+ // 2. Run the action directly
40
+ const result = await runAction(ExportActionRunner, {
41
+ inputs,
42
+ sandboxPath: sandbox.path,
43
+ });
44
+
45
+ // 3. Verification
46
+ const outputs = result.outputs;
47
+ expect(outputs).toBeDefined();
48
+
49
+ expect(outputs.folder).toEqual(expect.any(String));
50
+ expect(outputs.parentFolder).toEqual(expect.any(String));
51
+ expect(outputs.zipFile).toEqual(expect.any(String));
52
+
53
+ // Verify that the output files/folders actually exist
54
+ await expect(access(outputs.folder as string)).resolves.not.toThrow();
55
+ await expect(access(outputs.parentFolder as string)).resolves.not.toThrow();
56
+ await expect(access(outputs.zipFile as string)).resolves.not.toThrow();
57
+ },
58
+ 10 * 60 * 1000,
59
+ );
60
+ });
@@ -0,0 +1,51 @@
1
+ {
2
+ "description": "Export from Construct, package with Electron, then upload to Steam",
3
+ "name": "From Construct to Steam",
4
+ "variables": [],
5
+ "canvas": {
6
+ "triggers": [
7
+ {
8
+ "type": "event",
9
+ "origin": {
10
+ "pluginId": "system",
11
+ "nodeId": "manual"
12
+ },
13
+ "uid": "manual-start",
14
+ "params": {}
15
+ }
16
+ ],
17
+ "blocks": [
18
+ {
19
+ "uid": "export-construct-project",
20
+ "type": "action",
21
+ "origin": {
22
+ "nodeId": "export-construct-project",
23
+ "pluginId": "construct"
24
+ },
25
+ "params": {
26
+ "file": {
27
+ "editor": "editor",
28
+ "value": "\"./tests/e2e/fixtures/c3-export/test.c3p\""
29
+ },
30
+ "username": {
31
+ "editor": "editor",
32
+ "value": ""
33
+ },
34
+ "password": {
35
+ "editor": "editor",
36
+ "value": ""
37
+ },
38
+ "version": {
39
+ "editor": "editor",
40
+ "value": ""
41
+ },
42
+ "headless": {
43
+ "editor": "editor",
44
+ "value": true
45
+ }
46
+ }
47
+ }
48
+ ]
49
+ },
50
+ "version": "3.0.0"
51
+ }
@@ -0,0 +1,29 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Test</title>
7
+ </head>
8
+ <body>
9
+ OK
10
+ <script>
11
+ // window.electronAPI.exit(0)
12
+
13
+ const ws = new WebSocket("ws://localhost:31753");
14
+
15
+ ws.onopen = () => {
16
+ console.log("WebSocket opened");
17
+
18
+ /** @type {import('@pipelab/core').MakeInputOutput<import('@pipelab/core').MessageExit, 'input'>} */
19
+ const orderPath = {
20
+ url: "/exit",
21
+ body: {
22
+ code: 0,
23
+ },
24
+ };
25
+ ws.send(JSON.stringify(orderPath));
26
+ };
27
+ </script>
28
+ </body>
29
+ </html>
@@ -0,0 +1,20 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ fileParallelism: false,
6
+ testTimeout: 60000,
7
+ hookTimeout: 60000,
8
+ maxWorkers: 1,
9
+ minWorkers: 1,
10
+ poolOptions: {
11
+ forks: {
12
+ singleFork: true,
13
+ },
14
+ },
15
+ include: ["**/*.spec.ts"],
16
+ root: "tests/e2e",
17
+ environment: "node",
18
+ env: { NODE_ENV: "development", PIPELAB_DISABLE_HISTORY: "true" },
19
+ },
20
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,10 @@
1
+ {
2
+ "extends": "@pipelab/tsconfig/vue.json",
3
+ "compilerOptions": {
4
+ "outDir": "dist",
5
+ "rootDir": "src",
6
+ "types": ["node"],
7
+ "skipLibCheck": true
8
+ },
9
+ "include": ["src"]
10
+ }
@@ -0,0 +1,15 @@
1
+ import { defineConfig } from "tsdown";
2
+
3
+ export default defineConfig({
4
+ entry: ["src/index.ts"],
5
+ format: ["esm", "cjs"],
6
+ dts: true,
7
+ clean: true,
8
+ loader: {
9
+ ".webp": "dataurl",
10
+ ".png": "dataurl",
11
+ ".jpg": "dataurl",
12
+ ".jpeg": "dataurl",
13
+ ".svg": "dataurl",
14
+ },
15
+ });