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