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 + } +})