diff --git a/CONTEXT.md b/CONTEXT.md index 9d6fd04..0aedd33 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -12,18 +12,20 @@ Client library and CLI for the That Open Platform — a cloud platform for build ``` src/ - core/client.ts # EngineServicesClient — the main API class + core/client.ts # EngineServicesClient — the main API class cli/ - commands/create.ts # thatopen create — scaffolds new projects - commands/serve.ts # thatopen serve — dev server (esbuild watch + serve) - commands/login.ts # thatopen login — authenticate with the platform - commands/publish.ts # thatopen publish — build and upload to the platform - commands/run.ts # thatopen run — test cloud components locally - templates/ # Template generators for scaffolded projects - lib/ # CLI helper utilities (config, certificates) - built-in/index.ts # Built-in component type stubs + runtime UUID constants - types/ # TypeScript type definitions (items, execution, etc.) - index.ts # Library entry point (re-exports everything) + commands/create.ts # thatopen create — scaffolds new projects + commands/serve.ts # thatopen serve — dev server (esbuild watch + serve) + commands/login.ts # thatopen login — authenticate with the platform + commands/publish.ts # thatopen publish — build and upload to the platform + commands/run.ts # thatopen run — test cloud components locally + commands/create-tests.ts # thatopen create-tests — scaffolds test app + test component + commands/serve-tests.ts # thatopen serve-tests — serves both test projects in parallel + templates/ # Template generators for scaffolded projects + lib/ # CLI helper utilities (config, certificates) + built-in/index.ts # Built-in component type stubs + runtime UUID constants + types/ # TypeScript type definitions (items, execution, etc.) + index.ts # Library entry point (re-exports everything) ``` ## Build system @@ -47,6 +49,8 @@ npm run test:ui # Interactive browser test page npm run test:cli-build-app # Scaffold + build a test app npm run test:cli-build-component # Scaffold + build a test cloud component npm run test:cli-run-component # Run the test cloud component locally +npm run test:cli-build-tests # Build CLI + scaffold test app & test component into temp/ +npm run test:cli-serve-tests # Serve the test app and test component's local server in parallel ``` ### Publishing to npm @@ -66,7 +70,7 @@ yarn create-version # Build → changeset → version → publish | **Entry point** | Side effects in `main.ts` (renders UI) | `export async function main()` | | **Context** | `window.__THATOPEN_CONTEXT__` provides `{ appId, projectId, accessToken, apiUrl }` | Globals: `thatOpenServices`, `executionParams`, `executionReporter`, `OBC`, `THREE`, `fs` | | **Build output** | IIFE `dist/bundle.js` (all deps bundled) | IIFE `dist/bundle.js` (platform deps externalized) | -| **Template** | `bim` or `default` | `cloud` | +| **Template** | `bim`, `default`, or `test` | `cloud` or `cloud-test` | ### Authentication @@ -162,13 +166,16 @@ Full API reference with config interfaces, method signatures, and `@example` blo ## CLI commands ```bash -thatopen create [--template bim|default|cloud] # Scaffold project + auto npm install +thatopen create [--template bim|default|cloud|test|cloud-test] + # Scaffold project + auto npm install # Use "." as name to scaffold in current directory thatopen serve [--port N] # Dev server (esbuild watch + serve bundle) thatopen login [--token T] [--api-url U] [--local] # Authenticate thatopen publish [--name N] [--version-tag T] [--skip-build] [--app-id ID | --component-id ID] thatopen run [--params '{}'] [--skip-build] # Test cloud component locally thatopen local-server [--port N] [--skip-build] # Local execution server (API-compatible) +thatopen create-tests [directory] # Scaffold test app + test component (cleans directory first) +thatopen serve-tests [directory] # Serve test app + test component in parallel ``` ## Dependencies diff --git a/README.md b/README.md index 5defd5a..1598a9f 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,8 @@ npm run run # Build and test locally | `bim` (default) | `npx thatopen create my-app` | Three.js + BIM viewer + platform UI components | | `default` | `npx thatopen create my-app --template default` | Minimal app showing platform context | | `cloud` | `npx thatopen create my-component --template cloud` | Server-side Node.js component | +| `test` | `npx thatopen create my-tests --template test` | Browser test app that exercises every API endpoint | +| `cloud-test` | `npx thatopen create my-tests --template cloud-test` | Cloud component test suite for server-side API testing | Use `npx thatopen create .` to scaffold in the current directory instead of creating a new one. @@ -87,11 +89,13 @@ const client = new EngineServicesClient(ctx.accessToken, ctx.apiUrl, { useBearer | Command | Description | |---------|-------------| -| `thatopen create [--template bim\|default\|cloud]` | Scaffold a new project (use `.` for current directory) | +| `thatopen create [--template bim\|default\|cloud\|test\|cloud-test]` | Scaffold a new project (use `.` for current directory) | | `thatopen serve [--port N]` | Dev server (esbuild watch + serve bundle) | | `thatopen login [--token T] [--local]` | Authenticate with the platform | | `thatopen publish` | Build and publish to the platform | | `thatopen run [--params '{}']` | Build and test a cloud component locally | +| `thatopen create-tests [directory]` | Scaffold both a test app and test cloud component | +| `thatopen serve-tests [directory]` | Serve both the test app and test component in parallel | ## App workflow @@ -228,6 +232,28 @@ npm run test:cli-build-component npm run test:cli-run-component ``` +### Running the platform API test suite + +The test suite consists of two projects scaffolded together: a **test app** (browser-based, template `test`) and a **test cloud component** (server-side, template `cloud-test`). Both exercise every `EngineServicesClient` endpoint. + +```bash +# 1. Build the CLI and scaffold both test projects into a temp/ directory +# (deletes temp/ first if it already exists) +yarn test:cli-build-tests + +# 2. Serve both the test app and the test component's local server +yarn test:cli-serve-tests +``` + +Then open your project on [platform.thatopen.com](https://platform.thatopen.com) and click the debug button. The test app will show a panel with: + +- **Context** — current app/project/API info +- **Execution Config** — input fields for a deployed Component ID and the local server URL (defaults to `http://localhost:4001`) +- **Controls** — "Run All Tests" button +- **Results** — test results grouped by API area (Context & Auth, Folders, Files, Hidden Files, Icons, Components, Apps, Execution, Built-in Components) + +When execution tests run, the cloud component's output appears in the same Results section as additional groups prefixed with "Local Component:" or "Deployed Component:". + ### Publishing a new version Publishing is handled automatically by CI when a PR with changesets is merged to `main`. diff --git a/package.json b/package.json index 62fabd2..43a5ef2 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,9 @@ "test:cli-serve-app": "cd temp/test-app && thatopen serve", "test:cli-build-component": "npm run build:cli && node test/setup-test-component.mjs", "test:cli-run-component": "cd temp/test-component && thatopen run --skip-build", - "test:cli-local-server": "cd temp/test-component && thatopen local-server", + "test:cli-serve-component": "cd temp/test-component && thatopen local-server", + "test:cli-build-tests": "npm run build:cli && thatopen create-tests temp", + "test:cli-serve-tests": "thatopen serve-tests temp", "changeset": "changeset", "version": "changeset version" }, diff --git a/src/cli/commands/create-tests.ts b/src/cli/commands/create-tests.ts new file mode 100644 index 0000000..70d23c7 --- /dev/null +++ b/src/cli/commands/create-tests.ts @@ -0,0 +1,74 @@ +import { Command } from 'commander'; +import { resolve } from 'node:path'; +import { existsSync, rmSync, mkdirSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; + +export const createTestsCommand = new Command('create-tests') + .argument( + '', + 'Parent directory for both projects (required to prevent accidental deletion)', + ) + .option('--app-name ', 'Name of the test app project', 'test-app') + .option( + '--component-name ', + 'Name of the test component project', + 'test-component', + ) + .description( + 'Scaffold both a test app (--template test) and a test cloud component (--template cloud-test)', + ) + .action( + async ( + directory: string, + opts: { appName: string; componentName: string }, + ) => { + const parentDir = resolve(process.cwd(), directory); + const bin = process.argv[1]; // path to the thatopen CLI entry point + + // Clean up previous test suite if it exists + if (existsSync(parentDir)) { + console.log(`Removing existing directory "${directory}"...`); + rmSync(parentDir, { recursive: true, force: true }); + } + mkdirSync(parentDir, { recursive: true }); + + console.log('Creating test suite...'); + console.log(''); + + // 1 ── Test App + console.log(`── Creating test app "${opts.appName}" ──`); + const appResult = spawnSync( + process.execPath, + [bin, 'create', opts.appName, '-t', 'test'], + { cwd: parentDir, stdio: 'inherit' }, + ); + if (appResult.status !== 0) { + process.exit(appResult.status ?? 1); + } + + console.log(''); + + // 2 ── Test Cloud Component + console.log(`── Creating test component "${opts.componentName}" ──`); + const compResult = spawnSync( + process.execPath, + [bin, 'create', opts.componentName, '-t', 'cloud-test'], + { cwd: parentDir, stdio: 'inherit' }, + ); + if (compResult.status !== 0) { + process.exit(compResult.status ?? 1); + } + + console.log(''); + console.log('Test suite ready!'); + console.log(''); + console.log('Quick start:'); + console.log( + ` 1. cd ${opts.componentName} && npm run run # Run the test component locally`, + ); + console.log( + ` 2. cd ${opts.appName} && npm run dev # Start the test app`, + ); + console.log(''); + }, + ); diff --git a/src/cli/commands/create.ts b/src/cli/commands/create.ts index a2ac6d2..9dcbe24 100644 --- a/src/cli/commands/create.ts +++ b/src/cli/commands/create.ts @@ -6,13 +6,15 @@ import { getIndexHtml } from '../templates/index-html'; import { getMainTs } from '../templates/main-js'; import { getMainBim } from '../templates/main-bim'; import { getMainCloud } from '../templates/main-cloud'; +import { getMainTest } from '../templates/main-test'; +import { getMainCloudTest } from '../templates/main-cloud-test'; import { getViteConfig } from '../templates/vite-config'; import { getPackageJson } from '../templates/package-json'; -import { getContextMdBim, getContextMdDefault, getContextMdCloud } from '../templates/context-md'; +import { getContextMdBim, getContextMdDefault, getContextMdCloud, getContextMdTest, getContextMdCloudTest } from '../templates/context-md'; import { getTsconfig } from '../templates/tsconfig'; import { writeLocalConfig } from '../lib/config'; -const TEMPLATES = ['default', 'bim', 'cloud'] as const; +const TEMPLATES = ['default', 'bim', 'cloud', 'test', 'cloud-test'] as const; type Template = (typeof TEMPLATES)[number]; function getMainSource(template: Template): string { @@ -21,6 +23,10 @@ function getMainSource(template: Template): string { return getMainBim(); case 'cloud': return getMainCloud(); + case 'test': + return getMainTest(); + case 'cloud-test': + return getMainCloudTest(); default: return getMainTs(); } @@ -32,6 +38,10 @@ function getContextMd(template: Template): string { return getContextMdBim(); case 'cloud': return getContextMdCloud(); + case 'test': + return getContextMdTest(); + case 'cloud-test': + return getContextMdCloudTest(); default: return getContextMdDefault(); } @@ -49,7 +59,7 @@ export const createCommand = new Command('create') process.exit(1); } - const isCloud = template === 'cloud'; + const isCloud = template === 'cloud' || template === 'cloud-test'; const projectKind = isCloud ? 'cloud component' : 'app'; const useCurrentDir = projectName === '.'; diff --git a/src/cli/commands/serve-tests.ts b/src/cli/commands/serve-tests.ts new file mode 100644 index 0000000..1dae583 --- /dev/null +++ b/src/cli/commands/serve-tests.ts @@ -0,0 +1,96 @@ +import { Command } from 'commander'; +import { resolve } from 'node:path'; +import { spawn } from 'node:child_process'; + +export const serveTestsCommand = new Command('serve-tests') + .argument( + '', + 'Parent directory containing both projects', + ) + .option('--app-name ', 'Name of the test app project', 'test-app') + .option( + '--component-name ', + 'Name of the test component project', + 'test-component', + ) + .option('--app-port ', 'Port for the app bundle server', '4000') + .option('--component-port ', 'Port for the component local server', '4001') + .description( + 'Serve both the test app (thatopen serve) and the test component (thatopen local-server) in parallel', + ) + .action( + async ( + directory: string, + opts: { + appName: string; + componentName: string; + appPort: string; + componentPort: string; + }, + ) => { + const parentDir = resolve(process.cwd(), directory); + const bin = process.argv[1]; + + const appDir = resolve(parentDir, opts.appName); + const componentDir = resolve(parentDir, opts.componentName); + + console.log('Starting test suite servers...'); + console.log(''); + + const children: ReturnType[] = []; + + // Helper: spawn a child with prefixed stdout/stderr + function startProcess( + label: string, + cwd: string, + args: string[], + ) { + const child = spawn(process.execPath, [bin, ...args], { + cwd, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + const prefix = `[${label}]`; + + child.stdout.on('data', (data: Buffer) => { + for (const line of data.toString().split('\n')) { + if (line) console.log(`${prefix} ${line}`); + } + }); + + child.stderr.on('data', (data: Buffer) => { + for (const line of data.toString().split('\n')) { + if (line) console.error(`${prefix} ${line}`); + } + }); + + child.on('exit', (code) => { + console.log(`${prefix} exited with code ${code}`); + }); + + children.push(child); + } + + // 1 — Component local-server (start first so it's ready when the app connects) + startProcess('component', componentDir, [ + 'local-server', + '--port', + opts.componentPort, + ]); + + // 2 — App serve + startProcess('app', appDir, ['serve', '--port', opts.appPort]); + + console.log(`[component] ${componentDir} → http://localhost:${opts.componentPort}`); + console.log(`[app] ${appDir} → http://localhost:${opts.appPort}`); + console.log(''); + + // Forward SIGINT to children + process.on('SIGINT', () => { + for (const child of children) { + child.kill('SIGINT'); + } + setTimeout(() => process.exit(0), 500); + }); + }, + ); diff --git a/src/cli/index.ts b/src/cli/index.ts index 1599035..46d9fa5 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -7,6 +7,8 @@ import { publishCommand } from './commands/publish'; import { serveCommand } from './commands/serve'; import { runCommand } from './commands/run'; import { localServerCommand } from './commands/local-server'; +import { createTestsCommand } from './commands/create-tests'; +import { serveTestsCommand } from './commands/serve-tests'; const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8')); @@ -23,5 +25,7 @@ program.addCommand(publishCommand); program.addCommand(serveCommand); program.addCommand(runCommand); program.addCommand(localServerCommand); +program.addCommand(createTestsCommand); +program.addCommand(serveTestsCommand); program.parse(process.argv); diff --git a/src/cli/templates/context-md.ts b/src/cli/templates/context-md.ts index 5894661..d6c092d 100644 --- a/src/cli/templates/context-md.ts +++ b/src/cli/templates/context-md.ts @@ -346,6 +346,123 @@ const { executionId } = await client.executeComponent(componentId, { param: "val `; } +export function getContextMdTest(): string { + return `# ThatOpen Platform API Test Suite (App) + +This is a test app that exercises every EngineServicesClient endpoint to verify the +That Open Platform is working correctly. It runs in the browser inside the platform's +iframe and renders a test results dashboard. + +## How this app works + +- **Entry point**: \`src/main.ts\` — an IIFE loaded by the platform. +- **Build output**: \`dist/bundle.js\` — built by Vite. +- **Platform context**: The platform injects \`window.__THATOPEN_CONTEXT__\` with + \`appId\`, \`projectId\`, \`accessToken\`, and \`apiUrl\`. +- **Mount point**: Renders into \`#that-open-app\` in \`index.html\`. + +## What it tests + +The test suite covers every API group in EngineServicesClient: + +| Group | Endpoints tested | +|-------|-----------------| +| **Context & Auth** | Validates all context fields are present | +| **Projects** | getProject, getProjectData, checkPermission | +| **Folders** | createFolder, getFolder, listFolders, updateFolder, archiveFolder, recoverFolder, downloadFolder | +| **Files** | createFile, getFile, listFiles, downloadFile, getFileMetadata, updateFile, archiveFile, recoverFile | +| **Hidden Files** | createHiddenFile, getHiddenFile, getHiddenFilesByParent, downloadHiddenFile, deleteHiddenFile, deleteHiddenFilesByParent | +| **Icons** | uploadItemIcon, getItemIcon, removeItemIcon | +| **General Items** | updateItem, createVersion | +| **Components** | createComponent, getComponent, listComponents, updateComponent, downloadComponent, downloadComponentBundle, archiveComponent, recoverComponent | +| **Apps** | createApp, listApps, downloadApp, downloadAppBundle, archiveApp | +| **Execution** | executeComponent, getExecution, listExecutions, onExecutionProgress, abortExecution | +| **Built-in** | getBuiltInComponent | + +## Running + +1. Publish to the platform: \`npm run publish\` +2. Open the app in a project on the platform +3. Click **Run All Tests** +4. Review the pass/fail results + +## Commands + +\`\`\`bash +npm run dev # Start dev server +npm run build # Build dist/bundle.js +npm run login # Authenticate with the platform +npm run publish # Publish to the platform +\`\`\` + +## Cleanup + +The test suite creates temporary folders, files, components, apps, and hidden files +during execution. All test resources are archived (soft-deleted) after tests complete. +The execution tests use the test component created in the Components test group. +`; +} + +export function getContextMdCloudTest(): string { + return `# ThatOpen Platform API Test Suite (Cloud Component) + +This is a cloud component that exercises every EngineServicesClient endpoint from a +server-side context. It verifies that the platform API and runtime globals work correctly. + +## How this component works + +- **Entry point**: \`src/main.ts\` — exports \`async function main()\`. +- **Build output**: \`dist/bundle.js\` — an IIFE built by Vite with platform deps externalized. +- **Execution**: The platform (or \`thatopen run\` locally) calls \`main()\`. + +## What it tests + +| Group | Endpoints tested | +|-------|-----------------| +| **Runtime Globals** | thatOpenServices, executionParams, executionReporter, OBC, THREE, fs | +| **Folders** | createFolder, getFolder, listFolders, updateFolder, archiveFolder, recoverFolder, downloadFolder | +| **Files** | createFile, getFile, listFiles, downloadFile, getFileMetadata, updateFile, archiveFile, recoverFile | +| **Hidden Files** | createHiddenFile, getHiddenFile, getHiddenFilesByParent, downloadHiddenFile, deleteHiddenFile, deleteHiddenFilesByParent | +| **Icons** | uploadItemIcon, getItemIcon, removeItemIcon | +| **General Items** | updateItem, createVersion | +| **Components** | createComponent, getComponent, listComponents, updateComponent, downloadComponent, downloadComponentBundle, archiveComponent, recoverComponent | +| **Apps** | createApp, listApps, downloadApp, downloadAppBundle, archiveApp | +| **Execution** | executeComponent, getExecution, listExecutions, onExecutionProgress, abortExecution | +| **Built-in** | getBuiltInComponent | + +## Running locally + +\`\`\`bash +npm run run # Build and run once +npm run local-server # Start local execution server +\`\`\` + +## Running on the platform + +\`\`\`bash +npm run login # Authenticate +npm run publish # Publish to the platform +\`\`\` + +Then execute via the platform UI or from another app: +\`\`\`ts +const { executionId } = await client.executeComponent(componentId, {}); +client.onExecutionProgress(executionId, (data) => { ... }); +\`\`\` + +## Output + +- **Progress**: Reported via \`executionReporter\` after each test group +- **Messages**: Each group logs individual test results (pass/fail/skip) +- **Return**: \`{ type: "SUCCESS", message }\` if all pass, \`{ type: "WARNING", message }\` if any fail + +## Cleanup + +All test resources (folders, files, components, apps, hidden files) are archived after tests. +The execution tests use the test component created in the Components test group. +`; +} + export function getContextMdCloud(): string { return `# ThatOpen Cloud Component diff --git a/src/cli/templates/main-cloud-test.ts b/src/cli/templates/main-cloud-test.ts new file mode 100644 index 0000000..4dfad88 --- /dev/null +++ b/src/cli/templates/main-cloud-test.ts @@ -0,0 +1,550 @@ +export function getMainCloudTest(): string { + return [ + '// Cloud Component — Platform API Test Suite', + '// Tests all EngineServicesClient endpoints from a cloud component context.', + '// Generated by: thatopen create --template cloud-test', + '', + '// Globals injected by the execution engine at runtime', + 'declare const thatOpenServices: import("thatopen-services").EngineServicesClient;', + 'declare const executionParams: Record;', + 'declare const executionReporter: {', + ' message(msg: string): void;', + ' progress(pct: number): void;', + '};', + 'declare const OBC: typeof import("@thatopen/components");', + 'declare const THREE: typeof import("three");', + 'declare const fs: typeof import("fs");', + '', + 'type TestResult = {', + ' name: string;', + ' status: "pass" | "fail" | "skip";', + ' message: string;', + '};', + '', + 'type TestGroup = {', + ' name: string;', + ' results: TestResult[];', + '};', + '', + 'async function runTest(', + ' name: string,', + ' fn: () => Promise,', + '): Promise {', + ' try {', + ' await fn();', + ' return { name, status: "pass", message: "OK" };', + ' } catch (err: any) {', + ' return { name, status: "fail", message: err?.message || String(err) };', + ' }', + '}', + '', + 'function assert(condition: boolean, msg: string) {', + ' if (!condition) throw new Error(msg);', + '}', + '', + 'export async function main() {', + ' const groups: TestGroup[] = [];', + ' const ts = Date.now();', + ' let totalPass = 0;', + ' let totalFail = 0;', + ' let totalSkip = 0;', + ' const groupNames = [', + ' "Runtime Globals", "Folders", "Files", "Hidden Files", "Icons",', + ' "General Item Operations", "Components", "Apps", "Execution", "Built-in Components",', + ' ];', + '', + ' function report(group: TestGroup) {', + ' groups.push(group);', + ' for (const r of group.results) {', + ' if (r.status === "pass") totalPass++;', + ' else if (r.status === "fail") totalFail++;', + ' else totalSkip++;', + ' }', + ' const summary = group.results', + ' .map((r) => (r.status === "pass" ? " \\u2713 " : r.status === "fail" ? " \\u2717 " : " \\u25CB ") + r.name + ": " + r.message)', + ' .join("\\n");', + ' executionReporter.message(group.name + "\\n" + summary);', + ' executionReporter.progress(Math.round((groups.length / groupNames.length) * 100));', + ' }', + '', + ' executionReporter.message("Starting Platform API Test Suite...");', + '', + ' // ─── Runtime Globals ──────────────────────────────────────', + ' const globalsResults: TestResult[] = [];', + ' globalsResults.push(', + ' await runTest("thatOpenServices exists", async () => {', + ' assert(typeof thatOpenServices === "object", "Not an object");', + ' assert(typeof thatOpenServices.listFiles === "function", "listFiles not a function");', + ' }),', + ' );', + ' globalsResults.push(', + ' await runTest("executionParams exists", async () => {', + ' assert(typeof executionParams === "object", "Not an object");', + ' }),', + ' );', + ' globalsResults.push(', + ' await runTest("executionReporter exists", async () => {', + ' assert(typeof executionReporter === "object", "Not an object");', + ' assert(typeof executionReporter.message === "function", "message not a function");', + ' assert(typeof executionReporter.progress === "function", "progress not a function");', + ' }),', + ' );', + ' globalsResults.push(', + ' await runTest("OBC exists", async () => {', + ' assert(typeof OBC === "object", "Not an object");', + ' assert(typeof OBC.Components === "function", "Components not a constructor");', + ' }),', + ' );', + ' globalsResults.push(', + ' await runTest("THREE exists", async () => {', + ' assert(typeof THREE === "object", "Not an object");', + ' assert(typeof THREE.Vector3 === "function", "Vector3 not a constructor");', + ' }),', + ' );', + ' globalsResults.push(', + ' await runTest("fs exists", async () => {', + ' assert(typeof fs === "object", "Not an object");', + ' assert(typeof fs.readFileSync === "function", "readFileSync not a function");', + ' }),', + ' );', + ' report({ name: "Runtime Globals", results: globalsResults });', + '', + ' // ─── Folders ───────────────────────────────────────────────', + ' const folderResults: TestResult[] = [];', + ' let testFolderId = "";', + '', + ' folderResults.push(', + ' await runTest("createFolder", async () => {', + ' const folder = await thatOpenServices.createFolder("_cloud_test_folder_" + ts);', + ' assert(!!folder._id, "_id missing");', + ' assert(!!folder.name, "name missing");', + ' testFolderId = folder._id;', + ' }),', + ' );', + ' folderResults.push(', + ' await runTest("getFolder", async () => {', + ' assert(!!testFolderId, "No folder");', + ' const folder = await thatOpenServices.getFolder(testFolderId);', + ' assert(folder._id === testFolderId, "_id mismatch");', + ' }),', + ' );', + ' folderResults.push(', + ' await runTest("listFolders", async () => {', + ' const folders = await thatOpenServices.listFolders();', + ' assert(Array.isArray(folders), "Not an array");', + ' }),', + ' );', + ' folderResults.push(', + ' await runTest("updateFolder", async () => {', + ' assert(!!testFolderId, "No folder");', + ' const updated = await thatOpenServices.updateFolder(testFolderId, {', + ' name: "_cloud_test_folder_renamed_" + ts,', + ' });', + ' assert(updated.name.includes("renamed"), "Name not updated");', + ' }),', + ' );', + ' folderResults.push(', + ' await runTest("archiveFolder", async () => {', + ' assert(!!testFolderId, "No folder");', + ' await thatOpenServices.archiveFolder(testFolderId);', + ' }),', + ' );', + ' folderResults.push(', + ' await runTest("recoverFolder", async () => {', + ' assert(!!testFolderId, "No folder");', + ' await thatOpenServices.recoverFolder(testFolderId);', + ' }),', + ' );', + ' folderResults.push(', + ' await runTest("downloadFolder", async () => {', + ' assert(!!testFolderId, "No folder");', + ' const response = await thatOpenServices.downloadFolder(testFolderId);', + ' assert(!!response, "No response");', + ' }),', + ' );', + ' report({ name: "Folders", results: folderResults });', + '', + ' // ─── Files ─────────────────────────────────────────────────', + ' const fileResults: TestResult[] = [];', + ' let testFileId = "";', + ' const testContent = "cloud test file content " + ts;', + ' const testBlob = new Blob([testContent], { type: "text/plain" });', + '', + ' fileResults.push(', + ' await runTest("createFile", async () => {', + ' const result = await thatOpenServices.createFile({', + ' file: testBlob,', + ' name: "_cloud_test_file_" + ts + ".txt",', + ' versionTag: "v1",', + ' parentFolderId: testFolderId || undefined,', + ' metadata: { testKey: "testValue" },', + ' });', + ' assert(!!result.item._id, "item._id missing");', + ' assert(!!result.version, "version missing");', + ' testFileId = result.item._id;', + ' }),', + ' );', + ' fileResults.push(', + ' await runTest("getFile (with versions)", async () => {', + ' assert(!!testFileId, "No file");', + ' const file = await thatOpenServices.getFile(testFileId, { showVersions: true });', + ' assert(file._id === testFileId, "_id mismatch");', + ' assert(Array.isArray(file.versions), "versions not array");', + ' assert(file.versions.length > 0, "versions empty");', + ' }),', + ' );', + ' fileResults.push(', + ' await runTest("listFiles", async () => {', + ' const files = await thatOpenServices.listFiles();', + ' assert(Array.isArray(files), "Not an array");', + ' }),', + ' );', + ' fileResults.push(', + ' await runTest("downloadFile", async () => {', + ' assert(!!testFileId, "No file");', + ' const response = await thatOpenServices.downloadFile(testFileId);', + ' assert(response.ok !== false, "Response not ok");', + ' }),', + ' );', + ' fileResults.push(', + ' await runTest("getFileMetadata", async () => {', + ' assert(!!testFileId, "No file");', + ' const metadata = await thatOpenServices.getFileMetadata(testFileId);', + ' assert(typeof metadata === "object", "metadata not object");', + ' }),', + ' );', + ' fileResults.push(', + ' await runTest("updateFile (rename + new version)", async () => {', + ' assert(!!testFileId, "No file");', + ' const blob = new Blob(["updated content"], { type: "text/plain" });', + ' const result = await thatOpenServices.updateFile(testFileId, {', + ' name: "_cloud_test_file_renamed_" + ts + ".txt",', + ' file: blob,', + ' versionTag: "v2",', + ' });', + ' assert(!!result.version || !!result.item, "No update result");', + ' }),', + ' );', + ' fileResults.push(', + ' await runTest("archiveFile", async () => {', + ' assert(!!testFileId, "No file");', + ' await thatOpenServices.archiveFile(testFileId);', + ' }),', + ' );', + ' fileResults.push(', + ' await runTest("recoverFile", async () => {', + ' assert(!!testFileId, "No file");', + ' await thatOpenServices.recoverFile(testFileId);', + ' }),', + ' );', + ' report({ name: "Files", results: fileResults });', + '', + ' // ─── Hidden Files ──────────────────────────────────────────', + ' const hiddenResults: TestResult[] = [];', + ' let testHiddenId = "";', + '', + ' hiddenResults.push(', + ' await runTest("createHiddenFile", async () => {', + ' assert(!!testFileId, "No parent file");', + ' const blob = new Blob(["hidden content"], { type: "text/plain" });', + ' const result = await thatOpenServices.createHiddenFile(blob, testFileId);', + ' assert(!!result.hiddenFileId, "hiddenFileId missing");', + ' testHiddenId = result.hiddenFileId;', + ' }),', + ' );', + ' hiddenResults.push(', + ' await runTest("getHiddenFile", async () => {', + ' assert(!!testHiddenId, "No hidden file");', + ' const hidden = await thatOpenServices.getHiddenFile(testHiddenId);', + ' assert(!!hidden._id, "_id missing");', + ' }),', + ' );', + ' hiddenResults.push(', + ' await runTest("getHiddenFilesByParent", async () => {', + ' assert(!!testFileId, "No parent file");', + ' const files = await thatOpenServices.getHiddenFilesByParent(testFileId);', + ' assert(Array.isArray(files), "Not an array");', + ' assert(files.length > 0, "No hidden files found");', + ' }),', + ' );', + ' hiddenResults.push(', + ' await runTest("downloadHiddenFile", async () => {', + ' assert(!!testHiddenId, "No hidden file");', + ' const response = await thatOpenServices.downloadHiddenFile(testHiddenId);', + ' assert(!!response, "No response");', + ' }),', + ' );', + ' hiddenResults.push(', + ' await runTest("deleteHiddenFile", async () => {', + ' assert(!!testHiddenId, "No hidden file");', + ' await thatOpenServices.deleteHiddenFile(testHiddenId);', + ' }),', + ' );', + ' hiddenResults.push(', + ' await runTest("deleteHiddenFilesByParent", async () => {', + ' assert(!!testFileId, "No parent file");', + ' const blob = new Blob(["temp hidden"], { type: "text/plain" });', + ' await thatOpenServices.createHiddenFile(blob, testFileId);', + ' const result = await thatOpenServices.deleteHiddenFilesByParent(testFileId);', + ' assert(Array.isArray(result), "Not an array");', + ' }),', + ' );', + ' report({ name: "Hidden Files", results: hiddenResults });', + '', + ' // ─── Icons ─────────────────────────────────────────────────', + ' const iconResults: TestResult[] = [];', + ' // 1x1 transparent PNG', + ' const pngBytes = new Uint8Array([', + ' 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00,', + ' 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00,', + ' 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xde,', + ' 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, 0x54, 0x08, 0xd7, 0x63,', + ' 0xf8, 0xcf, 0xc0, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, 0xe2, 0x21,', + ' 0xbc, 0x33, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae,', + ' 0x42, 0x60, 0x82,', + ' ]);', + ' const iconBlob = new Blob([pngBytes], { type: "image/png" });', + '', + ' iconResults.push(', + ' await runTest("uploadItemIcon", async () => {', + ' assert(!!testFileId, "No item");', + ' await thatOpenServices.uploadItemIcon(testFileId, iconBlob);', + ' }),', + ' );', + ' iconResults.push(', + ' await runTest("getItemIcon", async () => {', + ' assert(!!testFileId, "No item");', + ' const response = await thatOpenServices.getItemIcon(testFileId);', + ' assert(!!response, "No response");', + ' }),', + ' );', + ' iconResults.push(', + ' await runTest("removeItemIcon", async () => {', + ' assert(!!testFileId, "No item");', + ' await thatOpenServices.removeItemIcon(testFileId);', + ' }),', + ' );', + ' report({ name: "Icons", results: iconResults });', + '', + ' // ─── General Item Operations ───────────────────────────────', + ' const generalResults: TestResult[] = [];', + '', + ' generalResults.push(', + ' await runTest("updateItem (rename without version)", async () => {', + ' assert(!!testFileId, "No item");', + ' const item = await thatOpenServices.updateItem(testFileId, {', + ' name: "_cloud_test_file_general_" + ts + ".txt",', + ' });', + ' assert(!!item._id, "item._id missing");', + ' }),', + ' );', + ' generalResults.push(', + ' await runTest("createVersion", async () => {', + ' assert(!!testFileId, "No item");', + ' const blob = new Blob(["version 3 content"], { type: "text/plain" });', + ' const version = await thatOpenServices.createVersion(testFileId, blob, "v3");', + ' assert(!!version, "version missing");', + ' }),', + ' );', + ' report({ name: "General Item Operations", results: generalResults });', + '', + ' // Cleanup test file', + ' if (testFileId) {', + ' try { await thatOpenServices.archiveFile(testFileId); } catch { /* cleanup */ }', + ' }', + '', + ' // ─── Components ────────────────────────────────────────────', + ' const compResults: TestResult[] = [];', + ' let testComponentId = "";', + '', + ' compResults.push(', + ' await runTest("createComponent", async () => {', + ' const blob = new Blob(["// test component"], { type: "application/javascript" });', + ' const result = await thatOpenServices.createComponent({', + ' file: blob,', + ' name: "_cloud_test_component_" + ts,', + ' versionTag: "v1",', + ' componentProps: { type: "CLOUD", tier: "FREE" },', + ' });', + ' assert(!!result.item._id, "item._id missing");', + ' assert(!!result.version, "version missing");', + ' testComponentId = result.item._id;', + ' }),', + ' );', + ' compResults.push(', + ' await runTest("getComponent (with versions)", async () => {', + ' assert(!!testComponentId, "No component");', + ' const comp = await thatOpenServices.getComponent(testComponentId, { showVersions: true });', + ' assert(comp._id === testComponentId, "_id mismatch");', + ' assert(Array.isArray(comp.versions), "versions not array");', + ' }),', + ' );', + ' compResults.push(', + ' await runTest("listComponents", async () => {', + ' const components = await thatOpenServices.listComponents();', + ' assert(Array.isArray(components), "Not an array");', + ' }),', + ' );', + ' compResults.push(', + ' await runTest("updateComponent", async () => {', + ' assert(!!testComponentId, "No component");', + ' const blob = new Blob(["// updated"], { type: "application/javascript" });', + ' const result = await thatOpenServices.updateComponent(testComponentId, {', + ' name: "_cloud_test_component_renamed_" + ts,', + ' file: blob,', + ' versionTag: "v2",', + ' componentProps: { type: "CLOUD", tier: "FREE" },', + ' });', + ' assert(!!result.version || !!result.item, "No update result");', + ' }),', + ' );', + ' compResults.push(', + ' await runTest("downloadComponent", async () => {', + ' assert(!!testComponentId, "No component");', + ' const response = await thatOpenServices.downloadComponent(testComponentId);', + ' assert(!!response, "No response");', + ' }),', + ' );', + ' compResults.push(', + ' await runTest("downloadComponentBundle", async () => {', + ' assert(!!testComponentId, "No component");', + ' const response = await thatOpenServices.downloadComponentBundle(testComponentId);', + ' assert(!!response, "No response");', + ' }),', + ' );', + ' compResults.push(', + ' await runTest("archiveComponent", async () => {', + ' assert(!!testComponentId, "No component");', + ' await thatOpenServices.archiveComponent(testComponentId);', + ' }),', + ' );', + ' compResults.push(', + ' await runTest("recoverComponent", async () => {', + ' assert(!!testComponentId, "No component");', + ' await thatOpenServices.recoverComponent(testComponentId);', + ' }),', + ' );', + ' report({ name: "Components", results: compResults });', + '', + ' // ─── Apps ───────────────────────────────────────────────────', + ' const appResults: TestResult[] = [];', + ' let testAppId = "";', + '', + ' appResults.push(', + ' await runTest("listApps", async () => {', + ' const apps = await thatOpenServices.listApps();', + ' assert(Array.isArray(apps), "Not an array");', + ' }),', + ' );', + ' appResults.push(', + ' await runTest("createApp", async () => {', + ' const blob = new Blob(["// test app"], { type: "application/javascript" });', + ' const result = await thatOpenServices.createApp({', + ' file: blob,', + ' name: "_cloud_test_app_" + ts,', + ' versionTag: "v1",', + ' });', + ' assert(!!result.item._id, "item._id missing");', + ' assert(!!result.version, "version missing");', + ' testAppId = result.item._id;', + ' }),', + ' );', + ' appResults.push(', + ' await runTest("downloadApp", async () => {', + ' assert(!!testAppId, "No app");', + ' const response = await thatOpenServices.downloadApp(testAppId);', + ' assert(!!response, "No response");', + ' }),', + ' );', + ' appResults.push(', + ' await runTest("downloadAppBundle", async () => {', + ' assert(!!testAppId, "No app");', + ' const response = await thatOpenServices.downloadAppBundle(testAppId);', + ' assert(!!response, "No response");', + ' }),', + ' );', + ' appResults.push(', + ' await runTest("archiveApp", async () => {', + ' assert(!!testAppId, "No app");', + ' await thatOpenServices.archiveApp(testAppId);', + ' }),', + ' );', + ' report({ name: "Apps", results: appResults });', + '', + ' // ─── Execution ─────────────────────────────────────────────', + ' // Uses the test component created above to exercise execution endpoints.', + ' // Note: executeComponent triggers a NEW execution of the given component.', + ' // The component bundle is not valid, so the execution itself will likely', + ' // fail, but the API calls are still fully tested.', + ' const execResults: TestResult[] = [];', + ' let testExecId = "";', + '', + ' if (testComponentId) {', + ' execResults.push(', + ' await runTest("executeComponent", async () => {', + ' const result = await thatOpenServices.executeComponent(testComponentId, {', + ' testParam: "hello",', + ' });', + ' assert(!!result.executionId, "executionId missing");', + ' testExecId = result.executionId;', + ' }),', + ' );', + ' execResults.push(', + ' await runTest("getExecution", async () => {', + ' assert(!!testExecId, "No execution");', + ' const execution = await thatOpenServices.getExecution(testExecId);', + ' assert(!!execution, "execution missing");', + ' }),', + ' );', + ' execResults.push(', + ' await runTest("listExecutions", async () => {', + ' assert(!!testComponentId, "No component");', + ' const executions = await thatOpenServices.listExecutions(testComponentId);', + ' assert(Array.isArray(executions), "Not an array");', + ' }),', + ' );', + ' } else {', + ' const skip = (n: string) => ({ name: n, status: "skip" as const, message: "Component creation failed" });', + ' execResults.push(skip("executeComponent"), skip("getExecution"), skip("listExecutions"));', + ' }', + ' report({ name: "Execution", results: execResults });', + '', + ' // ─── Built-in Components ───────────────────────────────────', + ' const builtInResults: TestResult[] = [];', + ' builtInResults.push(', + ' await runTest("getBuiltInComponent (HelloWorld)", async () => {', + ' const source = await thatOpenServices.getBuiltInComponent(', + ' "2c4ae432-fc24-43e9-9783-0c960c674e96",', + ' );', + ' assert(typeof source === "string", "Not a string");', + ' assert(source.length > 0, "Empty source");', + ' }),', + ' );', + ' report({ name: "Built-in Components", results: builtInResults });', + '', + ' // ─── Cleanup ───────────────────────────────────────────────', + ' if (testComponentId) {', + ' try { await thatOpenServices.archiveComponent(testComponentId); } catch { /* cleanup */ }', + ' }', + ' if (testFolderId) {', + ' try { await thatOpenServices.archiveFolder(testFolderId); } catch { /* cleanup */ }', + ' }', + '', + ' // ─── Summary ───────────────────────────────────────────────', + ' const summary = totalPass + " passed, " + totalFail + " failed, " + totalSkip + " skipped";', + ' executionReporter.message("\\nTest suite complete: " + summary);', + ' executionReporter.progress(100);', + '', + ' if (totalFail > 0) {', + ' const failedNames = groups', + ' .flatMap((g) => g.results)', + ' .filter((r) => r.status === "fail")', + ' .map((r) => r.name + ": " + r.message)', + ' .join("; ");', + ' return { type: "WARNING", message: summary + " — Failed: " + failedNames };', + ' }', + ' return { type: "SUCCESS", message: summary };', + '}', + '', + ].join('\n'); +} diff --git a/src/cli/templates/main-test.ts b/src/cli/templates/main-test.ts new file mode 100644 index 0000000..40dc13d --- /dev/null +++ b/src/cli/templates/main-test.ts @@ -0,0 +1,938 @@ +export function getMainTest(): string { + const backtick = '`'; + const dollar = '$'; + + return [ + '// Platform API Test Suite', + '// Tests all EngineServicesClient endpoints with a BIM viewer + results panel.', + '// Generated by: thatopen create --template test', + '', + 'import * as THREE from "three";', + 'import * as OBC from "@thatopen/components";', + 'import * as OBF from "@thatopen/components-front";', + 'import * as FRAGS from "@thatopen/fragments";', + 'import * as BUI from "@thatopen/ui";', + 'import * as CUI from "@thatopen/ui-obc";', + 'import {', + ' EngineServicesClient,', + ' AppManager,', + ' ViewportManager,', + '} from "thatopen-services";', + 'import type { ThatOpenContext } from "thatopen-services";', + '', + 'declare global {', + ' interface Window {', + ' __THATOPEN_CONTEXT__?: ThatOpenContext;', + ' }', + '}', + '', + 'type TestResult = {', + ' name: string;', + ' status: "pass" | "fail" | "skip";', + ' message: string;', + ' duration: number;', + '};', + '', + 'type TestGroup = {', + ' name: string;', + ' results: TestResult[];', + '};', + '', + 'async function runTest(', + ' name: string,', + ' fn: () => Promise,', + '): Promise {', + ' const start = performance.now();', + ' try {', + ' await fn();', + ' return {', + ' name,', + ' status: "pass",', + ' message: "OK",', + ' duration: performance.now() - start,', + ' };', + ' } catch (err: any) {', + ' return {', + ' name,', + ' status: "fail",', + ' message: err?.message || String(err),', + ' duration: performance.now() - start,', + ' };', + ' }', + '}', + '', + 'function assert(condition: boolean, msg: string) {', + ' if (!condition) throw new Error(msg);', + '}', + '', + 'function renderResults(', + ' container: HTMLElement,', + ' groups: TestGroup[],', + ' running: boolean,', + ') {', + ' let totalPass = 0;', + ' let totalFail = 0;', + ' let totalSkip = 0;', + ' for (const g of groups) {', + ' for (const r of g.results) {', + ' if (r.status === "pass") totalPass++;', + ' else if (r.status === "fail") totalFail++;', + ' else totalSkip++;', + ' }', + ' }', + ' const total = totalPass + totalFail + totalSkip;', + '', + ' let html = "";', + ' if (running) {', + ' html += \'

Running tests...

\';', + ' }', + ' if (total > 0) {', + ' html += \'

\';', + ' html += \'\' + totalPass + " passed · ";', + ' html += \'\' + totalFail + " failed · ";', + ' html += \'\' + totalSkip + " skipped · ";', + ' html += total + " total

";', + ' }', + '', + ' for (const group of groups) {', + ' html += \'
\' + group.name + "";', + ' html += \'\';', + ' for (const r of group.results) {', + ' const icon = r.status === "pass" ? "\\u2713" : r.status === "fail" ? "\\u2717" : "\\u25CB";', + ' const color = r.status === "pass" ? "#16a34a" : r.status === "fail" ? "#dc2626" : "#9ca3af";', + ' const escapedMsg = r.message.replace(/&/g, "&").replace(/\';', + ' html += \'";', + ' html += \'";', + ' html += \'";', + ' html += \'";', + ' html += "";', + ' }', + ' html += "
\' + icon + "\' + r.name + "\' + escapedMsg + "\' + r.duration.toFixed(0) + "ms
";', + ' }', + '', + ' container.innerHTML = html;', + '}', + '', + '// ─── Execution Message Parser ───────────────────────────────────', + '// The cloud-test component sends messages in this format:', + '// "GroupName\\n \\u2713 testName: OK\\n \\u2717 testName: error"', + '// We parse each message into a TestGroup so it can be rendered', + '// using the same renderResults() as the local tests.', + '', + 'function parseExecMessage(content: string, prefix: string): TestGroup | null {', + ' const lines = content.split("\\n");', + ' if (lines.length < 2) return null;', + ' const groupName = lines[0].trim();', + ' if (!groupName) return null;', + ' const results: TestResult[] = [];', + ' for (let i = 1; i < lines.length; i++) {', + ' const line = lines[i].trim();', + ' if (!line) continue;', + ' let status: "pass" | "fail" | "skip" = "skip";', + ' let rest = line;', + ' if (line.startsWith("\\u2713 ")) {', + ' status = "pass";', + ' rest = line.slice(2);', + ' } else if (line.startsWith("\\u2717 ")) {', + ' status = "fail";', + ' rest = line.slice(2);', + ' } else if (line.startsWith("\\u25CB ")) {', + ' status = "skip";', + ' rest = line.slice(2);', + ' }', + ' const colonIdx = rest.indexOf(": ");', + ' if (colonIdx > 0) {', + ' results.push({ name: rest.slice(0, colonIdx), status, message: rest.slice(colonIdx + 2), duration: 0 });', + ' } else {', + ' results.push({ name: rest, status, message: "", duration: 0 });', + ' }', + ' }', + ' if (results.length === 0) return null;', + ' return { name: prefix + groupName, results };', + '}', + '', + '// ─── 1x1 transparent PNG for icon tests ────────────────────────', + 'function createTestPng(): Blob {', + ' const bytes = new Uint8Array([', + ' 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00,', + ' 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00,', + ' 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xde,', + ' 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, 0x54, 0x08, 0xd7, 0x63,', + ' 0xf8, 0xcf, 0xc0, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, 0xe2, 0x21,', + ' 0xbc, 0x33, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae,', + ' 0x42, 0x60, 0x82,', + ' ]);', + ' return new Blob([bytes], { type: "image/png" });', + '}', + '', + 'async function main() {', + ' const client = EngineServicesClient.fromPlatformContext();', + '', + ' // ─── OBC Components + Built-in Components ─────────────────────', + ' const { components } = await client.initApp(', + ' { OBC, OBF, BUI, CUI, THREE, FRAGS },', + ' AppManager, ViewportManager,', + ' );', + '', + ' // ─── 3D Viewport ──────────────────────────────────────────────', + ' const viewports = components.get(ViewportManager);', + ' const { element: viewerElement, world } = await viewports.create();', + '', + ' // ─── Load Model: GitHub → Storage → Scene ─────────────────────', + ' // Fetches a .frag model, uploads to platform storage, downloads it', + ' // back, and loads into the 3D viewport to prove the full pipeline.', + ' const fragments = components.get(OBC.FragmentsManager);', + ' const fragUrl = "https://thatopen.github.io/engine_components/resources/frags/school_arq.frag";', + ' try {', + ' const fetchRes = await fetch(fragUrl);', + ' const fragBuffer = await fetchRes.arrayBuffer();', + '', + ' // Upload to platform storage', + ' const uploadResult = await client.createFile({', + ' file: new Blob([fragBuffer], { type: "application/octet-stream" }),', + ' name: "_test_model_" + Date.now() + ".frag",', + ' versionTag: "v1",', + ' });', + ' const modelFileId = uploadResult.item._id;', + '', + ' // Download from platform storage', + ' const dlResponse = await client.downloadFile(modelFileId);', + ' const dlBuffer = await dlResponse.arrayBuffer();', + ' const bytes = new Uint8Array(dlBuffer);', + '', + ' // Load into 3D scene', + ' await fragments.core.load(bytes, { modelId: modelFileId });', + ' await fragments.core.update(true);', + ' await world.camera.controls.setLookAt(68, 23, -8.5, 21.5, -5.5, 23);', + '', + ' // Clean up the test file from storage', + ' try { await client.archiveFile(modelFileId); } catch { /* cleanup */ }', + ' console.log("Model loaded: GitHub → Storage → Scene");', + ' } catch (err) {', + ' console.warn("Could not load model:", err);', + ' }', + '', + ' // ─── Test Results Container ────────────────────────────────────', + ' const resultsContainer = document.createElement("div");', + ' resultsContainer.style.color = "#d5d5d5";', + '', + ' // ─── Execution Config Inputs ──────────────────────────────────', + ' const inputCss = "width:100%;padding:6px 8px;background:#262528;color:#d5d5d5;border:1px solid #3C3C41;border-radius:4px;font-size:12px;box-sizing:border-box;";', + '', + ' const componentIdInput = document.createElement("input");', + ' componentIdInput.type = "text";', + ' componentIdInput.placeholder = "Paste component ID here";', + ' componentIdInput.style.cssText = inputCss;', + '', + ' const localServerInput = document.createElement("input");', + ' localServerInput.type = "text";', + ' localServerInput.value = "http://localhost:4001";', + ' localServerInput.style.cssText = inputCss;', + '', + ' // ─── Panel ────────────────────────────────────────────────────', + ' const panel = () => {', + ' return BUI.html' + backtick + '', + ' ', + ' ', + ' App ID: ' + dollar + '{client.context.appId}', + ' Project ID: ' + dollar + '{client.context.projectId}', + ' API URL: ' + dollar + '{client.context.apiUrl}', + ' ', + ' ', + ' Component ID (deployed):', + ' ' + dollar + '{componentIdInput}', + ' Local Server URL:', + ' ' + dollar + '{localServerInput}', + ' ', + ' ', + ' runAllTests(resultsContainer, client, componentIdInput, localServerInput)}>', + ' ', + ' ', + ' ' + dollar + '{resultsContainer}', + ' ', + ' ', + ' ' + backtick + ';', + ' };', + '', + ' // ─── App Shell ─────────────────────────────────────────────────', + ' const appManager = components.get(AppManager);', + ' appManager.setup = {', + ' elements: {', + ' viewer: viewerElement,', + ' panel,', + ' },', + ' layouts: {', + ' Tests: {', + ' template: ' + backtick + '"panel viewer" 1fr / 24rem 1fr' + backtick + ',', + ' icon: "solar:test-tube-bold",', + ' },', + ' Viewer: { template: ' + backtick + '"viewer" 1fr / 1fr' + backtick + ' },', + ' },', + ' };', + ' appManager.init();', + '}', + '', + 'async function runAllTests(resultsEl: HTMLElement, client: EngineServicesClient, componentIdInput: HTMLInputElement, localServerInput: HTMLInputElement) {', + ' const groups: TestGroup[] = [];', + ' const ts = Date.now();', + '', + ' // ─── Context & Auth ────────────────────────────────────────', + ' const ctxResults: TestResult[] = [];', + ' ctxResults.push(', + ' await runTest("context.appId exists", async () => {', + ' assert(!!client.context.appId, "appId is empty");', + ' }),', + ' );', + ' ctxResults.push(', + ' await runTest("context.projectId exists", async () => {', + ' assert(!!client.context.projectId, "projectId is empty");', + ' }),', + ' );', + ' ctxResults.push(', + ' await runTest("context.accessToken exists", async () => {', + ' assert(!!client.context.accessToken, "accessToken is empty");', + ' }),', + ' );', + ' ctxResults.push(', + ' await runTest("context.apiUrl exists", async () => {', + ' assert(!!client.context.apiUrl, "apiUrl is empty");', + ' }),', + ' );', + ' groups.push({ name: "Context & Auth", results: ctxResults });', + ' renderResults(resultsEl, groups, true);', + '', + ' // ─── Projects & Permissions ────────────────────────────────', + ' const projResults: TestResult[] = [];', + ' if (client.context.projectId) {', + ' projResults.push(', + ' await runTest("getProject", async () => {', + ' const project = await client.getProject(client.context.projectId);', + ' assert(!!project._id, "project._id missing");', + ' assert(typeof project.title === "string", "project.title missing");', + ' }),', + ' );', + ' projResults.push(', + ' await runTest("getProjectData", async () => {', + ' const data = await client.getProjectData(client.context.projectId);', + ' assert(!!data.project, "project missing");', + ' assert(Array.isArray(data.users), "users not array");', + ' assert(Array.isArray(data.files), "files not array");', + ' assert(Array.isArray(data.folders), "folders not array");', + ' assert(Array.isArray(data.roles), "roles not array");', + ' }),', + ' );', + ' projResults.push(', + ' await runTest("checkPermission", async () => {', + ' const result = await client.checkPermission({', + ' resourceId: client.context.projectId,', + ' resourceType: "PROJECT",', + ' action: "READ",', + ' projectId: client.context.projectId,', + ' });', + ' assert(typeof result.hasPermission === "boolean", "hasPermission not boolean");', + ' }),', + ' );', + ' } else {', + ' const skip = (n: string) => ({ name: n, status: "skip" as const, message: "No projectId", duration: 0 });', + ' projResults.push(skip("getProject"), skip("getProjectData"), skip("checkPermission"));', + ' }', + ' groups.push({ name: "Projects & Permissions", results: projResults });', + ' renderResults(resultsEl, groups, true);', + '', + ' // ─── Folders ───────────────────────────────────────────────', + ' const folderResults: TestResult[] = [];', + ' let testFolderId = "";', + ' folderResults.push(', + ' await runTest("createFolder", async () => {', + ' const folder = await client.createFolder("_test_folder_" + ts);', + ' assert(!!folder._id, "_id missing");', + ' assert(!!folder.name, "name missing");', + ' testFolderId = folder._id;', + ' }),', + ' );', + ' folderResults.push(', + ' await runTest("getFolder", async () => {', + ' assert(!!testFolderId, "No folder created");', + ' const folder = await client.getFolder(testFolderId);', + ' assert(folder._id === testFolderId, "_id mismatch");', + ' }),', + ' );', + ' folderResults.push(', + ' await runTest("listFolders", async () => {', + ' const folders = await client.listFolders();', + ' assert(Array.isArray(folders), "Not an array");', + ' }),', + ' );', + ' folderResults.push(', + ' await runTest("updateFolder", async () => {', + ' assert(!!testFolderId, "No folder");', + ' const updated = await client.updateFolder(testFolderId, {', + ' name: "_test_folder_renamed_" + ts,', + ' });', + ' assert(updated.name.includes("renamed"), "Name not updated");', + ' }),', + ' );', + ' folderResults.push(', + ' await runTest("archiveFolder", async () => {', + ' assert(!!testFolderId, "No folder");', + ' await client.archiveFolder(testFolderId);', + ' }),', + ' );', + ' folderResults.push(', + ' await runTest("recoverFolder", async () => {', + ' assert(!!testFolderId, "No folder");', + ' await client.recoverFolder(testFolderId);', + ' }),', + ' );', + ' folderResults.push(', + ' await runTest("downloadFolder", async () => {', + ' assert(!!testFolderId, "No folder");', + ' const response = await client.downloadFolder(testFolderId);', + ' assert(!!response, "No response");', + ' }),', + ' );', + ' groups.push({ name: "Folders", results: folderResults });', + ' renderResults(resultsEl, groups, true);', + '', + ' // ─── Files ─────────────────────────────────────────────────', + ' const fileResults: TestResult[] = [];', + ' let testFileId = "";', + ' const testContent = "test file content " + ts;', + ' const testBlob = new Blob([testContent], { type: "text/plain" });', + '', + ' fileResults.push(', + ' await runTest("createFile", async () => {', + ' const result = await client.createFile({', + ' file: testBlob,', + ' name: "_test_file_" + ts + ".txt",', + ' versionTag: "v1",', + ' parentFolderId: testFolderId || undefined,', + ' metadata: { testKey: "testValue" },', + ' });', + ' assert(!!result.item._id, "item._id missing");', + ' assert(!!result.version, "version missing");', + ' testFileId = result.item._id;', + ' }),', + ' );', + ' fileResults.push(', + ' await runTest("getFile (with versions)", async () => {', + ' assert(!!testFileId, "No file");', + ' const file = await client.getFile(testFileId, { showVersions: true });', + ' assert(file._id === testFileId, "_id mismatch");', + ' assert(Array.isArray(file.versions), "versions not array");', + ' assert(file.versions.length > 0, "versions empty");', + ' }),', + ' );', + ' fileResults.push(', + ' await runTest("listFiles", async () => {', + ' const files = await client.listFiles();', + ' assert(Array.isArray(files), "Not an array");', + ' }),', + ' );', + ' fileResults.push(', + ' await runTest("listFiles (with folderId)", async () => {', + ' if (!testFolderId) throw new Error("No folder");', + ' const files = await client.listFiles({ folderId: testFolderId });', + ' assert(Array.isArray(files), "Not an array");', + ' }),', + ' );', + ' fileResults.push(', + ' await runTest("downloadFile", async () => {', + ' assert(!!testFileId, "No file");', + ' const response = await client.downloadFile(testFileId);', + ' assert(response.ok !== false, "Response not ok");', + ' const text = await response.text();', + ' assert(text === testContent, "Content mismatch");', + ' }),', + ' );', + ' fileResults.push(', + ' await runTest("getFileMetadata", async () => {', + ' assert(!!testFileId, "No file");', + ' const metadata = await client.getFileMetadata(testFileId);', + ' assert(typeof metadata === "object", "metadata not object");', + ' }),', + ' );', + ' fileResults.push(', + ' await runTest("updateFile (rename + new version)", async () => {', + ' assert(!!testFileId, "No file");', + ' const newBlob = new Blob(["updated content"], { type: "text/plain" });', + ' const result = await client.updateFile(testFileId, {', + ' name: "_test_file_renamed_" + ts + ".txt",', + ' file: newBlob,', + ' versionTag: "v2",', + ' });', + ' assert(!!result.version || !!result.item, "No update result");', + ' }),', + ' );', + ' fileResults.push(', + ' await runTest("archiveFile", async () => {', + ' assert(!!testFileId, "No file");', + ' await client.archiveFile(testFileId);', + ' }),', + ' );', + ' fileResults.push(', + ' await runTest("recoverFile", async () => {', + ' assert(!!testFileId, "No file");', + ' await client.recoverFile(testFileId);', + ' }),', + ' );', + ' groups.push({ name: "Files", results: fileResults });', + ' renderResults(resultsEl, groups, true);', + '', + ' // ─── Hidden Files ──────────────────────────────────────────', + ' const hiddenResults: TestResult[] = [];', + ' let testHiddenId = "";', + '', + ' hiddenResults.push(', + ' await runTest("createHiddenFile", async () => {', + ' assert(!!testFileId, "No parent file");', + ' const blob = new Blob(["hidden content"], { type: "text/plain" });', + ' const result = await client.createHiddenFile(blob, testFileId);', + ' assert(!!result.hiddenFileId, "hiddenFileId missing");', + ' testHiddenId = result.hiddenFileId;', + ' }),', + ' );', + ' hiddenResults.push(', + ' await runTest("getHiddenFile", async () => {', + ' assert(!!testHiddenId, "No hidden file");', + ' const hidden = await client.getHiddenFile(testHiddenId);', + ' assert(!!hidden._id, "_id missing");', + ' }),', + ' );', + ' hiddenResults.push(', + ' await runTest("getHiddenFilesByParent", async () => {', + ' assert(!!testFileId, "No parent file");', + ' const files = await client.getHiddenFilesByParent(testFileId);', + ' assert(Array.isArray(files), "Not an array");', + ' assert(files.length > 0, "No hidden files found");', + ' }),', + ' );', + ' hiddenResults.push(', + ' await runTest("downloadHiddenFile", async () => {', + ' assert(!!testHiddenId, "No hidden file");', + ' const response = await client.downloadHiddenFile(testHiddenId);', + ' assert(!!response, "No response");', + ' }),', + ' );', + ' hiddenResults.push(', + ' await runTest("deleteHiddenFile", async () => {', + ' assert(!!testHiddenId, "No hidden file");', + ' await client.deleteHiddenFile(testHiddenId);', + ' }),', + ' );', + ' hiddenResults.push(', + ' await runTest("deleteHiddenFilesByParent", async () => {', + ' assert(!!testFileId, "No parent file");', + ' // Create a fresh hidden file, then delete all by parent', + ' const blob = new Blob(["temp hidden"], { type: "text/plain" });', + ' await client.createHiddenFile(blob, testFileId);', + ' const result = await client.deleteHiddenFilesByParent(testFileId);', + ' assert(Array.isArray(result), "Not an array");', + ' }),', + ' );', + ' groups.push({ name: "Hidden Files", results: hiddenResults });', + ' renderResults(resultsEl, groups, true);', + '', + ' // ─── Icons ─────────────────────────────────────────────────', + ' const iconResults: TestResult[] = [];', + ' const iconBlob = createTestPng();', + '', + ' iconResults.push(', + ' await runTest("uploadItemIcon", async () => {', + ' assert(!!testFileId, "No item for icon");', + ' await client.uploadItemIcon(testFileId, iconBlob);', + ' }),', + ' );', + ' iconResults.push(', + ' await runTest("getItemIcon", async () => {', + ' assert(!!testFileId, "No item for icon");', + ' const response = await client.getItemIcon(testFileId);', + ' assert(!!response, "No response");', + ' }),', + ' );', + ' iconResults.push(', + ' await runTest("removeItemIcon", async () => {', + ' assert(!!testFileId, "No item for icon");', + ' await client.removeItemIcon(testFileId);', + ' }),', + ' );', + ' groups.push({ name: "Icons", results: iconResults });', + ' renderResults(resultsEl, groups, true);', + '', + ' // ─── General Item Operations ───────────────────────────────', + ' const generalResults: TestResult[] = [];', + '', + ' generalResults.push(', + ' await runTest("updateItem (rename without version)", async () => {', + ' assert(!!testFileId, "No item");', + ' const item = await client.updateItem(testFileId, {', + ' name: "_test_file_general_" + ts + ".txt",', + ' });', + ' assert(!!item._id, "item._id missing");', + ' }),', + ' );', + ' generalResults.push(', + ' await runTest("createVersion", async () => {', + ' assert(!!testFileId, "No item");', + ' const blob = new Blob(["version 3 content"], { type: "text/plain" });', + ' const version = await client.createVersion(testFileId, blob, "v3");', + ' assert(!!version, "version missing");', + ' }),', + ' );', + ' groups.push({ name: "General Item Operations", results: generalResults });', + ' renderResults(resultsEl, groups, true);', + '', + ' // Cleanup test file', + ' if (testFileId) {', + ' try {', + ' await client.archiveFile(testFileId);', + ' } catch {', + ' /* cleanup */', + ' }', + ' }', + '', + ' // ─── Components ────────────────────────────────────────────', + ' const compResults: TestResult[] = [];', + ' let testComponentId = "";', + ' const compBlob = new Blob(["// test component"], {', + ' type: "application/javascript",', + ' });', + '', + ' compResults.push(', + ' await runTest("createComponent", async () => {', + ' const result = await client.createComponent({', + ' file: compBlob,', + ' name: "_test_component_" + ts,', + ' versionTag: "v1",', + ' componentProps: { type: "CLOUD", tier: "FREE" },', + ' });', + ' assert(!!result.item._id, "item._id missing");', + ' assert(!!result.version, "version missing");', + ' testComponentId = result.item._id;', + ' }),', + ' );', + ' compResults.push(', + ' await runTest("getComponent (with versions)", async () => {', + ' assert(!!testComponentId, "No component");', + ' const comp = await client.getComponent(testComponentId, {', + ' showVersions: true,', + ' });', + ' assert(comp._id === testComponentId, "_id mismatch");', + ' assert(Array.isArray(comp.versions), "versions not array");', + ' }),', + ' );', + ' compResults.push(', + ' await runTest("listComponents", async () => {', + ' const components = await client.listComponents();', + ' assert(Array.isArray(components), "Not an array");', + ' }),', + ' );', + ' compResults.push(', + ' await runTest("updateComponent", async () => {', + ' assert(!!testComponentId, "No component");', + ' const blob = new Blob(["// updated component"], {', + ' type: "application/javascript",', + ' });', + ' const result = await client.updateComponent(testComponentId, {', + ' name: "_test_component_renamed_" + ts,', + ' file: blob,', + ' versionTag: "v2",', + ' componentProps: { type: "CLOUD", tier: "FREE" },', + ' });', + ' assert(!!result.version || !!result.item, "No update result");', + ' }),', + ' );', + ' compResults.push(', + ' await runTest("downloadComponent", async () => {', + ' assert(!!testComponentId, "No component");', + ' const response = await client.downloadComponent(testComponentId);', + ' assert(!!response, "No response");', + ' }),', + ' );', + ' compResults.push(', + ' await runTest("downloadComponentBundle", async () => {', + ' assert(!!testComponentId, "No component");', + ' const response = await client.downloadComponentBundle(testComponentId);', + ' assert(!!response, "No response");', + ' }),', + ' );', + ' compResults.push(', + ' await runTest("archiveComponent", async () => {', + ' assert(!!testComponentId, "No component");', + ' await client.archiveComponent(testComponentId);', + ' }),', + ' );', + ' compResults.push(', + ' await runTest("recoverComponent", async () => {', + ' assert(!!testComponentId, "No component");', + ' await client.recoverComponent(testComponentId);', + ' }),', + ' );', + ' groups.push({ name: "Components", results: compResults });', + ' renderResults(resultsEl, groups, true);', + '', + ' // ─── Apps ───────────────────────────────────────────────────', + ' const appResults: TestResult[] = [];', + ' let testAppId = "";', + ' const appBlob = new Blob(["// test app bundle"], {', + ' type: "application/javascript",', + ' });', + '', + ' appResults.push(', + ' await runTest("listApps", async () => {', + ' const apps = await client.listApps();', + ' assert(Array.isArray(apps), "Not an array");', + ' }),', + ' );', + ' appResults.push(', + ' await runTest("createApp", async () => {', + ' const result = await client.createApp({', + ' file: appBlob,', + ' name: "_test_app_" + ts,', + ' versionTag: "v1",', + ' });', + ' assert(!!result.item._id, "item._id missing");', + ' assert(!!result.version, "version missing");', + ' testAppId = result.item._id;', + ' }),', + ' );', + ' appResults.push(', + ' await runTest("downloadApp", async () => {', + ' assert(!!testAppId, "No app");', + ' const response = await client.downloadApp(testAppId);', + ' assert(!!response, "No response");', + ' }),', + ' );', + ' appResults.push(', + ' await runTest("downloadAppBundle", async () => {', + ' assert(!!testAppId, "No app");', + ' const response = await client.downloadAppBundle(testAppId);', + ' assert(!!response, "No response");', + ' }),', + ' );', + ' appResults.push(', + ' await runTest("archiveApp", async () => {', + ' assert(!!testAppId, "No app");', + ' await client.archiveApp(testAppId);', + ' }),', + ' );', + ' groups.push({ name: "Apps", results: appResults });', + ' renderResults(resultsEl, groups, true);', + '', + ' // ─── Execution (Deployed) ──────────────────────────────────', + ' // Tests execution against a published cloud component.', + ' // Requires a Component ID entered in the panel.', + ' const deployedResults: TestResult[] = [];', + ' const deployedCompId = componentIdInput.value.trim();', + '', + ' if (deployedCompId) {', + ' let deployedExecId = "";', + ' deployedResults.push(', + ' await runTest("executeComponent (deployed)", async () => {', + ' const result = await client.executeComponent(deployedCompId, {', + ' testParam: "deployed-test",', + ' });', + ' assert(!!result.executionId, "executionId missing");', + ' deployedExecId = result.executionId;', + ' client.onExecutionProgress(result.executionId, (data) => {', + ' if (data.messageUpdate) {', + ' const g = parseExecMessage(data.messageUpdate.content, "Deployed Component: ");', + ' if (g) { groups.push(g); renderResults(resultsEl, groups, true); }', + ' }', + ' });', + ' }),', + ' );', + ' deployedResults.push(', + ' await runTest("getExecution", async () => {', + ' assert(!!deployedExecId, "No execution");', + ' const execution = await client.getExecution(deployedExecId);', + ' assert(!!execution, "execution missing");', + ' }),', + ' );', + ' deployedResults.push(', + ' await runTest("listExecutions", async () => {', + ' const executions = await client.listExecutions(deployedCompId);', + ' assert(Array.isArray(executions), "Not an array");', + ' }),', + ' );', + ' deployedResults.push(', + ' await runTest("onExecutionProgress (subscribe)", async () => {', + ' const result = await client.executeComponent(deployedCompId, {', + ' testParam: "progress-test",', + ' });', + ' assert(!!result.executionId, "executionId missing");', + ' await new Promise((resolve) => {', + ' const timeout = setTimeout(() => resolve(), 10000);', + ' client.onExecutionProgress(result.executionId, (data) => {', + ' if (data.messageUpdate) {', + ' const g = parseExecMessage(data.messageUpdate.content, "Deployed Component: ");', + ' if (g) { groups.push(g); renderResults(resultsEl, groups, true); }', + ' }', + ' if (data.progressUpdate?.result) {', + ' clearTimeout(timeout);', + ' resolve();', + ' }', + ' });', + ' });', + ' }),', + ' );', + ' deployedResults.push(', + ' await runTest("abortExecution", async () => {', + ' const result = await client.executeComponent(deployedCompId, {', + ' testParam: "abort-test",', + ' });', + ' assert(!!result.executionId, "executionId missing");', + ' try {', + ' await client.abortExecution(result.executionId);', + ' } catch (err: any) {', + ' if (!err?.message?.includes("4")) throw err;', + ' }', + ' }),', + ' );', + ' } else {', + ' const skip = (n: string) => ({', + ' name: n,', + ' status: "skip" as const,', + ' message: "Enter a Component ID above",', + ' duration: 0,', + ' });', + ' deployedResults.push(', + ' skip("executeComponent (deployed)"),', + ' skip("getExecution"),', + ' skip("listExecutions"),', + ' skip("onExecutionProgress"),', + ' skip("abortExecution"),', + ' );', + ' }', + ' groups.push({ name: "Execution (Deployed)", results: deployedResults });', + ' renderResults(resultsEl, groups, true);', + '', + ' // ─── Execution (Local) ─────────────────────────────────────', + ' // Tests execution against a local component dev server.', + ' // Requires thatopen local-server running in the component project.', + ' const localResults: TestResult[] = [];', + ' const localUrl = localServerInput.value.trim();', + '', + ' if (localUrl) {', + ' client.localServerUrl = localUrl;', + ' let localExecId = "";', + ' localResults.push(', + ' await runTest("executeComponent (local)", async () => {', + ' const result = await client.executeComponent("local-test", {', + ' testParam: "local-test",', + ' });', + ' assert(!!result.executionId, "executionId missing");', + ' localExecId = result.executionId;', + ' client.onExecutionProgress(result.executionId, (data) => {', + ' if (data.messageUpdate) {', + ' const g = parseExecMessage(data.messageUpdate.content, "Local Component: ");', + ' if (g) { groups.push(g); renderResults(resultsEl, groups, true); }', + ' }', + ' });', + ' }),', + ' );', + ' localResults.push(', + ' await runTest("getExecution (local)", async () => {', + ' assert(!!localExecId, "No local execution");', + ' const execution = await client.getExecution(localExecId);', + ' assert(!!execution, "execution missing");', + ' }),', + ' );', + ' localResults.push(', + ' await runTest("listExecutions (local)", async () => {', + ' const executions = await client.listExecutions("local-test");', + ' assert(Array.isArray(executions), "Not an array");', + ' }),', + ' );', + ' localResults.push(', + ' await runTest("onExecutionProgress (local)", async () => {', + ' const result = await client.executeComponent("local-test", {', + ' testParam: "progress-local",', + ' });', + ' assert(!!result.executionId, "executionId missing");', + ' await new Promise((resolve) => {', + ' const timeout = setTimeout(() => resolve(), 10000);', + ' client.onExecutionProgress(result.executionId, (data) => {', + ' if (data.messageUpdate) {', + ' const g = parseExecMessage(data.messageUpdate.content, "Local Component: ");', + ' if (g) { groups.push(g); renderResults(resultsEl, groups, true); }', + ' }', + ' if (data.progressUpdate?.result) {', + ' clearTimeout(timeout);', + ' resolve();', + ' }', + ' });', + ' });', + ' }),', + ' );', + ' localResults.push(', + ' await runTest("abortExecution (local)", async () => {', + ' const result = await client.executeComponent("local-test", {', + ' testParam: "abort-local",', + ' });', + ' assert(!!result.executionId, "executionId missing");', + ' try {', + ' await client.abortExecution(result.executionId);', + ' } catch (err: any) {', + ' if (!err?.message?.includes("4")) throw err;', + ' }', + ' }),', + ' );', + ' client.localServerUrl = null;', + ' } else {', + ' const skip = (n: string) => ({', + ' name: n,', + ' status: "skip" as const,', + ' message: "Clear Local Server URL to skip",', + ' duration: 0,', + ' });', + ' localResults.push(', + ' skip("executeComponent (local)"),', + ' skip("getExecution (local)"),', + ' skip("listExecutions (local)"),', + ' skip("onExecutionProgress (local)"),', + ' skip("abortExecution (local)"),', + ' );', + ' }', + ' groups.push({ name: "Execution (Local)", results: localResults });', + ' renderResults(resultsEl, groups, true);', + '', + ' // ─── Built-in Components ───────────────────────────────────', + ' const builtInResults: TestResult[] = [];', + ' builtInResults.push(', + ' await runTest("getBuiltInComponent (HelloWorld)", async () => {', + ' const source = await client.getBuiltInComponent(', + ' "2c4ae432-fc24-43e9-9783-0c960c674e96",', + ' );', + ' assert(typeof source === "string", "Not a string");', + ' assert(source.length > 0, "Empty source");', + ' }),', + ' );', + ' groups.push({ name: "Built-in Components", results: builtInResults });', + '', + ' // ─── Cleanup ───────────────────────────────────────────────', + ' if (testComponentId) {', + ' try {', + ' await client.archiveComponent(testComponentId);', + ' } catch {', + ' /* cleanup */', + ' }', + ' }', + ' if (testFolderId) {', + ' try {', + ' await client.archiveFolder(testFolderId);', + ' } catch {', + ' /* cleanup */', + ' }', + ' }', + '', + ' renderResults(resultsEl, groups, false);', + ' console.log("Test suite complete.");', + '}', + '', + 'main().catch(console.error);', + '', + ].join('\n'); +} diff --git a/src/cli/templates/package-json.ts b/src/cli/templates/package-json.ts index f50a308..cda25c9 100644 --- a/src/cli/templates/package-json.ts +++ b/src/cli/templates/package-json.ts @@ -12,7 +12,7 @@ const libVersion: string = (() => { })(); export function getPackageJson(appName: string, template?: string): string { - if (template === 'cloud') { + if (template === 'cloud' || template === 'cloud-test') { const pkg: Record = { name: appName, version: '1.0.0', @@ -67,5 +67,18 @@ export function getPackageJson(appName: string, template?: string): string { (pkg.devDependencies as Record)['@types/three'] = '^0.182.0'; } + if (template === 'test') { + (pkg.dependencies as Record) = { + '@thatopen/components': '^3.3.1', + '@thatopen/components-front': '^3.3.1', + '@thatopen/fragments': '^3.3.1', + '@thatopen/ui': '^3.3.3', + '@thatopen/ui-obc': '^3.3.3', + 'thatopen-services': `^${libVersion}`, + three: '^0.182.0', + }; + (pkg.devDependencies as Record)['@types/three'] = '^0.182.0'; + } + return JSON.stringify(pkg, null, 2); } diff --git a/src/cli/templates/tsconfig.ts b/src/cli/templates/tsconfig.ts index 23c49be..e936f3a 100644 --- a/src/cli/templates/tsconfig.ts +++ b/src/cli/templates/tsconfig.ts @@ -1,5 +1,5 @@ export function getTsconfig(template?: string): string { - if (template === 'cloud') { + if (template === 'cloud' || template === 'cloud-test') { return JSON.stringify( { compilerOptions: { diff --git a/src/cli/templates/vite-config.ts b/src/cli/templates/vite-config.ts index a88f1bf..d129606 100644 --- a/src/cli/templates/vite-config.ts +++ b/src/cli/templates/vite-config.ts @@ -1,5 +1,5 @@ export function getViteConfig(template?: string): string { - if (template === 'cloud') { + if (template === 'cloud' || template === 'cloud-test') { return `// Production build config — builds dist/bundle.js as IIFE. // For local development, use: npm run dev (runs thatopen serve with esbuild). // Do NOT run "vite" or "vite build --watch" directly for dev. diff --git a/src/core/client.ts b/src/core/client.ts index 8413e90..d0896c0 100644 --- a/src/core/client.ts +++ b/src/core/client.ts @@ -56,6 +56,8 @@ export type CreateItemProps = { versionTag: string; /** Optional folder ID to place the item in. */ parentFolderId?: string; + /** Optional project ID to associate the item with. */ + projectId?: string; /** Optional key-value metadata (max 30 KB when serialized). */ metadata?: Record; }; @@ -500,9 +502,13 @@ export class EngineServicesClient { * @param parentId - Optional parent folder ID for nesting. * @returns The created folder. */ - async createFolder(name: string, parentId?: string) { + async createFolder(name: string, parentId?: string, projectId?: string) { return await this.#requestApi('POST', FOLDER_PATH, { - body: JSON.stringify({ name, ...(parentId && { parentId }) }), + body: JSON.stringify({ + name, + ...(parentId && { parentId }), + ...(projectId && { projectId }), + }), contentType: 'application/json', }); } @@ -1247,13 +1253,15 @@ export class EngineServicesClient { itemType: ItemType, extraProps?: P, ) { - const { name, versionTag, parentFolderId, file, metadata } = fileData; + const { name, versionTag, parentFolderId, projectId, file, metadata } = + fileData; const formData = new FormData(); formData.append('file', file); formData.append('name', name); formData.append('versionTag', versionTag); formData.append('itemType', itemType); parentFolderId && formData.append('folderId', parentFolderId); + projectId && formData.append('projectId', projectId); extraProps && formData.append('extraProps', JSON.stringify(extraProps)); metadata && diff --git a/test/setup-test-app.mjs b/test/setup-test-app.mjs index dd8c89c..62d68cb 100644 --- a/test/setup-test-app.mjs +++ b/test/setup-test-app.mjs @@ -17,7 +17,7 @@ try { mkdirSync(tempDir, { recursive: true }); // Scaffold the app -execSync(`node ${resolve(root, 'dist/cli.js')} create test-app`, { +execSync(`node ${resolve(root, 'dist/cli.js')} create test-app -t test`, { cwd: tempDir, stdio: 'inherit', }); diff --git a/test/setup-test-component.mjs b/test/setup-test-component.mjs index 5ae3ccc..3775d06 100644 --- a/test/setup-test-component.mjs +++ b/test/setup-test-component.mjs @@ -17,7 +17,7 @@ try { mkdirSync(tempDir, { recursive: true }); // Scaffold the cloud component -execSync(`node ${resolve(root, 'dist/cli.js')} create test-component -t cloud`, { +execSync(`node ${resolve(root, 'dist/cli.js')} create test-component -t cloud-test`, { cwd: tempDir, stdio: 'inherit', });