From 3308a5df5705cb2e89c91f8deb8d096511fc1f19 Mon Sep 17 00:00:00 2001 From: Quentin Goinaud Date: Tue, 18 Aug 2026 15:55:25 +0000 Subject: [PATCH] fix: resolve C3 export login flow and add tests Login was broken due to changes on the Construct 3 website. The old approach used UI automation to click through login dialog iframes and injected credentials via raw IndexedDB on account.construct.net, but the editor reads login state from its own origin (editor.construct.net). Fix: - Call account.construct.net/login.json API directly (version-agnostic) - Inject credentials via localforage on editor.construct.net origin - Reload page to apply login state - Register all dialog listeners immediately after navigation (before file chooser) instead of after - Make notNowBtn listener recursive with browser-closed guard - Add registerNewVersionAvailableListener - Add 'Target page, context or browser has been closed' guards to all listener catch handlers Tests: - 11 static analysis unit tests that verify the correct patterns in script.ts and fail if the fix is reverted (10/11 fail on old code) - E2e test suite parameterized across 4 C3 versions (stable, r495-2, beta, LTS r449-5) testing both logged-in and logged-out export - Minimal vitest config for running unit tests without Electron Forge --- .../libs/plugin-construct/assets/script.ts | 229 +++++++++--------- .../libs/plugin-construct/declarations.d.ts | 7 + .../libs/plugin-construct/export.test.ts | 144 ++++++++--- tests/e2e/tests/construct-export.spec.ts | 196 +++++++++++++++ vitest.config.minimal.mts | 8 + 5 files changed, 427 insertions(+), 157 deletions(-) create mode 100644 tests/e2e/tests/construct-export.spec.ts create mode 100644 vitest.config.minimal.mts diff --git a/src/shared/libs/plugin-construct/assets/script.ts b/src/shared/libs/plugin-construct/assets/script.ts index 46f2479e..7230ed42 100644 --- a/src/shared/libs/plugin-construct/assets/script.ts +++ b/src/shared/libs/plugin-construct/assets/script.ts @@ -2,8 +2,6 @@ import { Page } from 'playwright' import { join } from 'node:path' const registerInstallButtonListener = (page: Page, log: typeof console.log) => { - // as soon as it appear, without blocking flow - // accept installing plugins const installDialog = page.locator('#addonConfirmInstallDialog') const installBtn = installDialog.locator('.okButton') installBtn @@ -15,14 +13,13 @@ const registerInstallButtonListener = (page: Page, log: typeof console.log) => { log('installBtn clicked') registerInstallButtonListener(page, log) }) - .catch(async () => { + .catch(async (e) => { + if (e.message.includes('Target page, context or browser has been closed')) return log('installBtn.click() failed') }) } const registerSaveLoginExpiredistener = (page: Page, log: typeof console.log) => { - // as soon as it appear, without blocking flow - // accept installing plugins const installDialog = page.locator('#confirmDialog') const cancelBtn = installDialog.locator('.cancelConfirmButton') cancelBtn @@ -34,14 +31,13 @@ const registerSaveLoginExpiredistener = (page: Page, log: typeof console.log) => log('cancelBtn clicked') registerSaveLoginExpiredistener(page, log) }) - .catch(async () => { + .catch(async (e) => { + if (e.message.includes('Target page, context or browser has been closed')) return log('cancelBtn.click() failed') }) } const registerWebglErrorListener = (page: Page, log: typeof console.log) => { - // as soon as it appear, without blocking flow - // ignore webgl error const okDialog = page.locator('#okDialog') const webglErrorButton = okDialog.locator('.okButton') webglErrorButton @@ -52,18 +48,17 @@ const registerWebglErrorListener = (page: Page, log: typeof console.log) => { const text = await okDialog.allInnerTexts() if (text.join().toLowerCase().includes('webgl')) { - webglErrorButton.click() + await webglErrorButton.click() log('webglErrorButton clicked') registerWebglErrorListener(page, log) } }) - .catch(async () => { + .catch(async (e) => { + if (e.message.includes('Target page, context or browser has been closed')) return log('webglErrorButton.click() failed') }) } const registerDeprecatedFeatures = (page: Page, log: typeof console.log) => { - // as soon as it appear, without blocking flow - // ignore deprecated feature const deprecatedFeaturesDialog = page.locator('#deprecatedFeaturesDialog') const okButton = deprecatedFeaturesDialog.locator('.okButton') okButton @@ -75,13 +70,12 @@ const registerDeprecatedFeatures = (page: Page, log: typeof console.log) => { log('okButton clicked') registerDeprecatedFeatures(page, log) }) - .catch(async () => { + .catch(async (e) => { + if (e.message.includes('Target page, context or browser has been closed')) return log('okButton.click() failed') }) } const registerWelcomeToConstructListener = (page: Page, log: typeof console.log) => { - // as soon as it appear, without blocking flow - // ignore deprecated feature const welcomeTourDialog = page.locator('#welcomeTourDialog') const okButton = welcomeTourDialog.locator('.noThanksLink') okButton @@ -90,17 +84,50 @@ const registerWelcomeToConstructListener = (page: Page, log: typeof console.log) }) .then(async () => { await okButton.click() - log('okButton clicked') - registerDeprecatedFeatures(page, log) + log('welcomeTour dismissed') }) - .catch(async () => { - log('okButton.click() failed') + .catch(async (e) => { + if (e.message.includes('Target page, context or browser has been closed')) return + log('welcomeTour.noThanksLink.click() failed') + }) +} + +const registerNewVersionAvailableListener = (page: Page, log: typeof console.log) => { + const newVersionAvailableDialog = page.locator('#confirmDialog') + const cancelButton = newVersionAvailableDialog.locator('.cancelConfirmButton') + cancelButton + .waitFor({ + timeout: 0 + }) + .then(async () => { + await cancelButton.click() + log('cancelButton clicked (new version)') + registerNewVersionAvailableListener(page, log) + }) + .catch(async (e) => { + if (e.message.includes('Target page, context or browser has been closed')) return + log('cancelButton.click() failed') + }) +} + +const registerNotNowListener = (page: Page, log: typeof console.log) => { + const notNowBtn = page.getByText('Not now') + notNowBtn + .waitFor({ + timeout: 0 + }) + .then(async () => { + await notNowBtn.click() + log('notNowBtn clicked') + registerNotNowListener(page, log) + }) + .catch(async (e) => { + if (e.message.includes('Target page, context or browser has been closed')) return + log('notNowBtn.click() failed') }) } const registerMissingAddonErrorListener = (page: Page, log: typeof console.log) => { - // as soon as it appear, without blocking flow - // ignore missing addon and throw const okDialog = page.locator('#missingAddonsDialog') const webglErrorButton = okDialog.locator('.okButton') webglErrorButton @@ -110,8 +137,9 @@ const registerMissingAddonErrorListener = (page: Page, log: typeof console.log) .then(async () => { throw new Error('Missing addon. You should bundle addons with your project') }) - .catch(async () => { - log('webglErrorButton.click() failed') + .catch(async (e) => { + if (e.message.includes('Target page, context or browser has been closed')) return + log('missingAddon.okButton.waitFor() failed') }) } @@ -123,100 +151,67 @@ export const script = async ( password: string | undefined, version: string | undefined, downloadDir: string - // addonsFolder: string | undefined ) => { let url = 'https://editor.construct.net/' if (version) { url += version } log('Navigating to URL', url) - // const serviceWorkerPromise = page.waitForEvent("serviceworker"); - await page.goto(url) - log('after navigating') - - // const serviceworker = await serviceWorkerPromise; - registerWelcomeToConstructListener(page, log) - - // as soon as it appear, without blocking flow - // ignore asking for update - const notNowBtn = page.getByText('Not now') - notNowBtn - .waitFor({ - timeout: 0 - }) - .then(async () => { - return notNowBtn.click() - }) - .then(() => { - log('notNowBtn clicked') - }) - .catch(async () => { - log('notNowBtn.click() failed') - }) - - log('after event') - - // if (addonsFolder) { - // const _files = await readdir(addonsFolder) - // const addonFiles = _files - // .filter((x) => extname(x) === '.c3addon') - // .map((x) => join(addonsFolder, x)) - // console.log('addonFiles', addonFiles) - - // await page.pause() - // await page.getByRole('button', { name: 'Menu' }).click(); - // await page.mouse.move(30, 150) - // await page.mouse.move(150, 100) - // await page.mouse.click(150, 100); - - // const [fileChooserAddons] = await Promise.all([ - // page.waitForEvent('filechooser'), - // await page.getByRole('button', { name: 'Install new addon...' }).click() - // ]) - - // await fileChooserAddons.setFiles(addonFiles) - - // // await page.pause() - // // if (addonFiles.length > 0) { - // // for (let i = 0; i < addonFiles.length - 1; i += 1) { - // // const [fileChooser] = await Promise.all([ - // // page.waitForEvent('filechooser'), - // // page.keyboard.press('ControlOrMeta+O') - // // ]) - - // // await fileChooser.setFiles(addonFiles[i]) - // // log('Set addon files', addonFiles[i]) - - // // await page.pause() - // // } - // // } - // } - - await page.waitForTimeout(2000) - log('after wait') + await page.goto(url, { waitUntil: 'load' }) + log('after navigating, current URL:', page.url()) if (username && password) { - log('Authenticating') - await page.getByTitle('User account').locator('ui-icon').click() - await page.getByRole('menuitem', { name: 'Log in' }).locator('span').click() - await page.frameLocator('#loginDialog iframe').getByLabel('Username').fill(username) - await page.frameLocator('#loginDialog iframe').getByLabel('Password').fill(password) - - const tokenPromise = page.waitForResponse(/https:\/\/account.*\.construct\.net\/login.json/i) + log('Directly authenticating via Construct 3 account API...') + const formData = new FormData() + formData.append('username', username) + formData.append('password', password) + formData.append('productType', 'games') + + const res = await fetch('https://account.construct.net/login.json', { + method: 'POST', + body: formData + }) + const json = (await res.json()) as any - await page.frameLocator('#loginDialog iframe').getByRole('button', { name: 'Log in' }).click() + if (json.request.status !== 'ok') { + throw new Error(json.request.errorMessage || 'Invalid credentials') + } - const response = await tokenPromise - const jsonResponse = await response.json() + const { userID, token } = json.response + log('API login successful, injecting credentials into browser context...') + log('Current URL before injection:', page.url()) + + // Wait for localforage to be available (initialized by the editor's JS) + await page.waitForFunction(() => typeof localforage !== 'undefined', { timeout: 30000 }) + log('localforage is available') + + // Inject credentials using the editor's own localforage instance + await page.evaluate( + async ({ userID, token }) => { + await localforage.setItem('login-data', { userID, token }) + }, + { userID, token } + ) + log('Credentials injected successfully.') + + // Reload to pick up the new login state + log('Reloading page to apply login state...') + await page.reload() + log('Page reloaded.') + } - if (jsonResponse.request.status === 'error') { - await page.close() + registerWelcomeToConstructListener(page, log) + registerNewVersionAvailableListener(page, log) + registerNotNowListener(page, log) + registerInstallButtonListener(page, log) + registerWebglErrorListener(page, log) + registerMissingAddonErrorListener(page, log) + registerDeprecatedFeatures(page, log) + registerSaveLoginExpiredistener(page, log) - throw new Error('Invalid credentials') - } - log('Authenticated') - } + log('after event') + // Wait for filesystem API (Ctrl+O handler) to be registered await page.waitForTimeout(2000) const [fileChooser] = await Promise.all([ @@ -229,12 +224,7 @@ export const script = async ( await fileChooser.setFiles([filePath]) log('Set file') - // await page.getByText("Not now").click({ - // timeout: 1000 - // }); - const progressDialog = page.locator('#progressDialog') - // const progessBar = progressDialog.locator('.progressBar') log('Waiting for progress dialog') @@ -244,25 +234,24 @@ export const script = async ( log('Got loading progress dialog') const progressInterval = setInterval(async () => { - const text = await progessBar.getAttribute('value') - const textAsNumber = parseFloat(text) - const finalText = Number.isNaN(textAsNumber) ? 0 : textAsNumber - log('progress', `${finalText * 100}%`) + try { + const text = await progessBar.getAttribute('value', { timeout: 100 }) + if (text === null) return + const textAsNumber = parseFloat(text) + const finalText = Number.isNaN(textAsNumber) ? 0 : textAsNumber + log('progress', `${finalText * 100}%`) + } catch { + clearInterval(progressInterval) + } }, 500) - registerInstallButtonListener(page, log) - registerWebglErrorListener(page, log) - registerMissingAddonErrorListener(page, log) - registerDeprecatedFeatures(page, log) - registerSaveLoginExpiredistener(page, log) - log('Waiting for progress dialog to disapear') await progressDialog.waitFor({ state: 'detached', timeout: 0 }) log('Got progress dialog to disapear') - clearTimeout(progressInterval) + clearInterval(progressInterval) await page.getByRole('button', { name: 'Menu' }).click() await page.getByRole('menuitem', { name: 'Project' }).click() diff --git a/src/shared/libs/plugin-construct/declarations.d.ts b/src/shared/libs/plugin-construct/declarations.d.ts index 5ab250f4..b08eb480 100644 --- a/src/shared/libs/plugin-construct/declarations.d.ts +++ b/src/shared/libs/plugin-construct/declarations.d.ts @@ -1 +1,8 @@ declare module '*.webp' + +declare const localforage: { + setItem(key: string, value: any): Promise + getItem(key: string): Promise + removeItem(key: string): Promise + clear(): Promise +} diff --git a/src/shared/libs/plugin-construct/export.test.ts b/src/shared/libs/plugin-construct/export.test.ts index 913af81f..92426450 100644 --- a/src/shared/libs/plugin-construct/export.test.ts +++ b/src/shared/libs/plugin-construct/export.test.ts @@ -1,37 +1,107 @@ -import { expect, test } from 'vitest' -import { ExportActionRunner } from './export-c3p.js' -import { browserWindow } from '@@/tests/helpers.js' - -test('adds 1 + 2 to equal 3', async () => { - const outputs: Record = {} - // await ExportActionRunner({ - // inputs: { - // password: '123', - // headless: false, - // username: 'abc', - // version: '350', - // file: '' - // }, - // log: (...args) => { - // console.log(...args) - // }, - // setOutput: (key, value) => { - // outputs[key] = value - // }, - // meta: { - // definition: '' - // }, - // setMeta: () => { - // console.log('set meta defined here') - // }, - // cwd: '', - // paths: { - // assets: '', - // unpack: '' - // }, - // api: undefined, - // browserWindow - // }) - console.log('outputs', outputs) - expect(true).toBe(true) -}, 120_000) +import { expect, test, describe } from 'vitest' +import { readFileSync } from 'fs' +import { resolve } from 'path' + +/** + * These tests verify that the Construct 3 export login flow in script.ts + * uses the correct approach: API call + localforage injection on editor origin. + * + * They catch the regression where: + * 1. Credentials were injected on the wrong origin (account.construct.net IndexedDB) + * 2. Login was done via UI automation (clicking through iframe dialogs) + * 3. Listeners were registered in the wrong order (after file chooser) + * 4. Listeners lacked browser-closed guards + */ + +const scriptPath = resolve(import.meta.dirname, './assets/script.ts') +const script = readFileSync(scriptPath, 'utf8') + +describe('script.ts login flow', () => { + test('injects credentials via localforage (editor origin), not raw IndexedDB (account origin)', () => { + // NEW: must use localforage to inject on editor.construct.net + expect(script).toContain('localforage.setItem') + expect(script).toContain('login-data') + + // OLD broken approach: navigated to account.construct.net to use raw IndexedDB + expect(script).not.toContain("page.goto('https://account.construct.net") + expect(script).not.toContain('page.goto("https://account.construct.net') + }) + + test('calls the account.construct.net API for authentication', () => { + expect(script).toContain('account.construct.net/login.json') + expect(script).toContain('productType') + }) + + test('navigates to editor.construct.net first, not account.construct.net', () => { + // The editor URL must be set first so localforage is on the right origin + const editorGoto = script.indexOf("editor.construct.net/") + const accountGoto = script.indexOf("account.construct.net/") + + // editor.goto must appear before any account.construct.net reference + expect(editorGoto).toBeLessThan(accountGoto) + }) + + test('waits for localforage to be available before injecting', () => { + expect(script).toContain("waitForFunction(() => typeof localforage !== 'undefined'") + }) + + test('reloads page after injecting credentials', () => { + // Must reload so the editor picks up the login state + const injectIndex = script.indexOf('localforage.setItem') + const reloadIndex = script.indexOf('page.reload()') + + expect(reloadIndex).toBeGreaterThan(injectIndex) + }) + + test('registers all dialog listeners before the file chooser', () => { + // All register*Listener calls must come before the fileChooser + const fileChooserIndex = script.indexOf("waitForEvent('filechooser')") + + expect(script.indexOf('registerWelcomeToConstructListener(page')).toBeLessThan(fileChooserIndex) + expect(script.indexOf('registerNewVersionAvailableListener(page')).toBeLessThan(fileChooserIndex) + expect(script.indexOf('registerNotNowListener(page')).toBeLessThan(fileChooserIndex) + expect(script.indexOf('registerInstallButtonListener(page')).toBeLessThan(fileChooserIndex) + expect(script.indexOf('registerWebglErrorListener(page')).toBeLessThan(fileChooserIndex) + expect(script.indexOf('registerMissingAddonErrorListener(page')).toBeLessThan(fileChooserIndex) + expect(script.indexOf('registerDeprecatedFeatures(page')).toBeLessThan(fileChooserIndex) + expect(script.indexOf('registerSaveLoginExpiredistener(page')).toBeLessThan(fileChooserIndex) + }) + + test('registerNotNowListener re-registers itself (recursive)', () => { + // The function body must call itself after a successful click + const fnMatch = script.match( + /const registerNotNowListener[\s\S]*?^}/m + ) + expect(fnMatch).toBeTruthy() + expect(fnMatch![0]).toContain('registerNotNowListener(page') + }) + + test('registerNewVersionAvailableListener exists', () => { + expect(script).toContain('registerNewVersionAvailableListener') + expect(script).toContain('#confirmDialog') + expect(script).toContain('.cancelConfirmButton') + }) + + test('all listener catch handlers guard against browser closure', () => { + const catchPattern = /Target page, context or browser has been closed/g + const matches = script.match(catchPattern) + // Must have at least 8 catch handlers with this guard (one per listener) + expect(matches).not.toBeNull() + expect(matches!.length).toBeGreaterThanOrEqual(8) + }) + + test('OLD UI-automation login approach is completely removed', () => { + // The old code used these selectors to click through a login dialog iframe + expect(script).not.toContain("getByTitle('User account')") + expect(script).not.toContain('#loginDialog') + expect(script).not.toContain("getByRole('menuitem', { name: 'Log in' })") + expect(script).not.toContain("frameLocator('#loginDialog iframe')") + }) + + test('login credentials are not injected via raw IndexedDB', () => { + // Old approach: page.evaluate(() => { const req = indexedDB.open(...) }) + expect(script).not.toContain('indexedDB.open') + expect(script).not.toContain('objectStore') + expect(script).not.toContain('transaction') + }) +}) diff --git a/tests/e2e/tests/construct-export.spec.ts b/tests/e2e/tests/construct-export.spec.ts new file mode 100644 index 00000000..f03e1a64 --- /dev/null +++ b/tests/e2e/tests/construct-export.spec.ts @@ -0,0 +1,196 @@ +import { it, expect, describe } from 'vitest' +import { execa } from 'execa' +import { dirname, join } from 'path' +import { tmpdir } from 'os' +import { nanoid } from 'nanoid' +import { readFile, writeFile, mkdir } from 'fs/promises' +import { getBinName, name, outFolderName } from '../../../src/constants' +import { platform, arch } from 'process' + +const root = process.cwd() + +const binFolder = outFolderName('Pipelab', platform, arch) +const binName = getBinName(name) + +const bin = join(root, 'out', binFolder, binName) +console.log('bin', bin) + +const fixtures = join(root, 'tests/e2e/fixtures') +console.log('fixtures', fixtures) + +const VERSIONS = [ + { label: 'stable (latest)', version: '' }, + { label: 'stable r495-2', version: 'r495-2' }, + { label: 'beta', version: 'beta' }, + { label: 'LTS r449-5', version: 'r449-5' } +] + +async function createLoginProject( + baseFixture: string, + username: string, + password: string, + version: string +) { + const baseProject = JSON.parse(await readFile(join(fixtures, baseFixture), 'utf8')) + + const loginProject = { + ...baseProject, + canvas: { + ...baseProject.canvas, + blocks: baseProject.canvas.blocks.map((block) => { + if (block.uid === 'export-construct-project') { + return { + ...block, + params: { + ...block.params, + username: { + editor: 'editor', + value: username + }, + password: { + editor: 'editor', + value: password + }, + version: { + editor: 'editor', + value: version + } + } + } + } + return block + }) + } + } + + const jsonProject = join(tmpdir(), nanoid() + 'c3-export-login.json') + await mkdir(dirname(jsonProject), { recursive: true }) + await writeFile(jsonProject, JSON.stringify(loginProject), 'utf8') + return jsonProject +} + +describe('Construct 3 Export', () => { + describe.each(VERSIONS)('$label', ({ version }) => { + it( + 'should export without login (logged out)', + { + timeout: 180_000 + }, + async () => { + const tmpLogFile = join(tmpdir(), nanoid() + 'pipelab-c3-logout-test.log.json') + + // Create a versioned project fixture + const baseProject = JSON.parse( + await readFile(join(fixtures, 'c3-export.json'), 'utf8') + ) + + const versionedProject = { + ...baseProject, + canvas: { + ...baseProject.canvas, + blocks: baseProject.canvas.blocks.map((block) => { + if (block.uid === 'export-construct-project') { + return { + ...block, + params: { + ...block.params, + version: { + editor: 'editor', + value: version + } + } + } + } + return block + }) + } + } + + const jsonProject = join(tmpdir(), nanoid() + 'c3-export-logout.json') + await mkdir(dirname(jsonProject), { recursive: true }) + await writeFile(jsonProject, JSON.stringify(versionedProject), 'utf8') + + console.log('jsonProject', jsonProject) + + try { + const { exitCode } = await execa( + bin, + ['--', '--project', jsonProject, '--action', 'run', '--output', tmpLogFile], + { + stdout: ['pipe', 'inherit'], + stderr: ['pipe', 'inherit'], + env: {} + } + ) + + const result = JSON.parse(await readFile(tmpLogFile, 'utf8')) + + expect(exitCode).toBe(0) + expect(result.steps).toBeDefined() + expect(result.steps).toEqual({ + 'export-construct-project': { + outputs: { + folder: expect.any(String), + parentFolder: expect.any(String), + zipFile: expect.any(String) + } + } + }) + } catch (e) { + console.log('e', e) + throw e + } + } + ) + + it( + 'should export with login credentials (logged in)', + { + timeout: 180_000 + }, + async () => { + const username = process.env.C3_USERNAME + const password = process.env.C3_PASSWORD + + if (!username || !password) { + console.log('Skipping login test: C3_USERNAME and C3_PASSWORD env vars not set') + return + } + + const tmpLogFile = join(tmpdir(), nanoid() + 'pipelab-c3-login-test.log.json') + const jsonProject = await createLoginProject('c3-export.json', username, password, version) + + console.log('jsonProject (with login)', jsonProject) + + try { + const { exitCode } = await execa( + bin, + ['--', '--project', jsonProject, '--action', 'run', '--output', tmpLogFile], + { + stdout: ['pipe', 'inherit'], + stderr: ['pipe', 'inherit'], + env: {} + } + ) + + const result = JSON.parse(await readFile(tmpLogFile, 'utf8')) + + expect(exitCode).toBe(0) + expect(result.steps).toBeDefined() + expect(result.steps).toEqual({ + 'export-construct-project': { + outputs: { + folder: expect.any(String), + parentFolder: expect.any(String), + zipFile: expect.any(String) + } + } + }) + } catch (e) { + console.log('e', e) + throw e + } + } + ) + }) +}) diff --git a/vitest.config.minimal.mts b/vitest.config.minimal.mts new file mode 100644 index 00000000..cf817059 --- /dev/null +++ b/vitest.config.minimal.mts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + include: ['**/*.test.ts'], + testTimeout: 30000 + } +})