diff --git a/README.md b/README.md index 2d45141..31a8a95 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,7 @@ To use the Acurast CLI, type `acurast` followed by any of the available options - `deployments update editor [options]` - Transfer editor permissions for a mutable deployment. - `live [options] [project]` - Setup a "live-code-processor" and run your project on the processor in real time. - `init` - Create an acurast.json file and .env file. +- `devtools ` - Request a DevTools view key and print the URL for a deployment. - `open` - Open the Acurast resources in your browser. - `help [command]` - Display help for command. @@ -239,6 +240,48 @@ When running `acurast deploy`, the environment variables will now automatically When running interval based deployments with multiple executions, the environment variables can be updated between executions. To do that, update the `.env` file and run `acurast deployments -e`. This will update the environment variables for the deployment with the given ID. +## DevTools + +Acurast DevTools lets you see live `console.log`, `console.warn`, `console.error`, `console.info`, and `console.debug` output from your processors in a web dashboard. + +### Setup + +Add `"enableDevtools": true` to your project config in `acurast.json`: + +```json +{ + "projects": { + "my-project": { + "projectName": "my-project", + "fileUrl": "dist/bundle.js", + "enableDevtools": true, + ... + } + } +} +``` + +Then deploy as usual with `acurast deploy my-project`. After deployment, the CLI prints a DevTools URL with a view key — open it to see your logs. + +### Requesting a new view key + +View keys are time-limited. If yours has expired, request a new one: + +```bash +acurast devtools +``` + +### Privacy + +Logs are only accessible with a valid view key. The key is scoped to the specific deployment and only the deployment owner can request new keys. + +### Environment variables + +| Variable | Default | Description | +|---|---|---| +| `ACURAST_DEVTOOLS_URL` | `https://devtools.acurast.com` | DevTools frontend URL | +| `ACURAST_DEVTOOLS_API_URL` | `https://api.devtools.acurast.com` | DevTools API URL | + ## Deployment Management The Acurast CLI provides comprehensive deployment management capabilities, including the ability to update mutable deployments and transfer editor permissions. diff --git a/acurast.json b/acurast.json index 4a1cca7..00a7f63 100644 --- a/acurast.json +++ b/acurast.json @@ -204,6 +204,32 @@ "maxCostPerExecution": 1000000000, "includeEnvironmentVariables": [], "processorWhitelist": [] + }, + "test-devtools": { + "projectName": "test-devtools", + "fileUrl": "examples/devtools-test.js", + "network": "mainnet", + "onlyAttestedDevices": true, + "enableDevtools": true, + "assignmentStrategy": { + "type": "Single" + }, + "execution": { + "type": "onetime", + "maxExecutionTimeInMs": 60000 + }, + "maxAllowedStartDelayInMs": 10000, + "usageLimit": { + "maxMemory": 0, + "maxNetworkRequests": 0, + "maxStorage": 0 + }, + "numberOfReplicas": 20, + "requiredModules": [], + "minProcessorReputation": 0, + "maxCostPerExecution": 3000000000, + "includeEnvironmentVariables": [], + "processorWhitelist": [] } } } diff --git a/examples/devtools-test.js b/examples/devtools-test.js new file mode 100644 index 0000000..72c1269 --- /dev/null +++ b/examples/devtools-test.js @@ -0,0 +1,27 @@ +// Test script that logs various things for ~1 minute, then exits. + +console.log("Devtools test started", { jobId: _STD_.job.getId(), device: _STD_.device.getAddress() }) +console.info("Processor info", { timestamp: Date.now() }) + +let tick = 0 +const interval = setInterval(() => { + tick++ + console.log("Tick " + tick, { elapsed: tick * 5 + "s" }) + + if (tick % 3 === 0) { + console.warn("Warning at tick " + tick + ": this is a test warning") + } + + if (tick % 5 === 0) { + try { + JSON.parse("not valid json {{{") + } catch (e) { + console.error("Caught error at tick " + tick + ":", e.message) + } + } + + if (tick >= 12) { + clearInterval(interval) + console.log("Devtools test complete after " + tick + " ticks") + } +}, 5000) diff --git a/package-lock.json b/package-lock.json index eefcf8e..e94808d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@acurast/cli", - "version": "0.6.0", + "version": "0.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@acurast/cli", - "version": "0.6.0", + "version": "0.7.0", "license": "UNLICENSED", "dependencies": { "@acurast/dapp": "^1.0.1", diff --git a/package.json b/package.json index a175bf9..14a779e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@acurast/cli", - "version": "0.6.0", + "version": "0.7.0", "description": "A cli to interact with the Acurast Cloud.", "main": "dist/index.js", "bin": { diff --git a/src/acurast/createJob.ts b/src/acurast/createJob.ts index 57a775e..bdfcd34 100644 --- a/src/acurast/createJob.ts +++ b/src/acurast/createJob.ts @@ -15,7 +15,13 @@ import { filelogger } from '../util/fileLogger.js' import { zipFolder } from '../util/zipFolder.js' import { createManifest } from '../util/createManifest.js' import { checkIsFolder } from '../util/checkIsFolder.js' -import { basename } from 'node:path' +import { basename, dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { injectDevtoolsSnippet } from '../devtools/injectDevtoolsSnippet.js' +import { getEnv } from '../config.js' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = dirname(__filename) const BUNDLE_FOLDER = '.acurast/bundles' @@ -71,6 +77,19 @@ export const createJob = async ( config.projectName ) + if (config.enableDevtools) { + const devtoolsApiUrl = getEnv('ACURAST_DEVTOOLS_API_URL') + const snippetDir = join(__dirname, '..', 'devtools') + zipPath = await injectDevtoolsSnippet( + zipPath, + config.entrypoint ?? basename(config.fileUrl), + devtoolsApiUrl, + wallet.address, + snippetDir + ) + filelogger.debug('Devtools snippet injected into bundle') + } + filelogger.log(`zipPath ${zipPath}`) ipfsHash = await uploadScript({ file: zipPath }) diff --git a/src/commands/deploy.ts b/src/commands/deploy.ts index 0359ae0..7b6834e 100644 --- a/src/commands/deploy.ts +++ b/src/commands/deploy.ts @@ -37,6 +37,7 @@ import { fetchAndDisplayPricing } from '../util/fetchPricingAdvice.js' import { BigNumber } from 'bignumber.js' import { confirm, select, input } from '@inquirer/prompts' import { AssignmentStrategyVariant } from '../types.js' +import { getDevtoolsViewKey, buildDevtoolsUrl } from '../devtools/devtoolsApi.js' const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) @@ -480,6 +481,7 @@ export const addCommandDeploy = (program: Command) => { const deploymentTime = new Date() let jobRegistrationTemp: JobRegistration | undefined = undefined + let deployedJobId: string | undefined = undefined const job = convertConfigToJob(config) @@ -513,6 +515,7 @@ export const addCommandDeploy = (program: Command) => { if (!jobRegistrationTemp) { throw new Error('Deployment Registration is null!') } + deployedJobId = String(data.jobIds[0]?.[1] ?? data.jobIds[0]) await storeDeployment( deploymentTime, originalConfig, @@ -790,6 +793,29 @@ export const addCommandDeploy = (program: Command) => { try { await tasks.run() + if (config.enableDevtools && deployedJobId) { + try { + const viewKeyResponse = await getDevtoolsViewKey(deployedJobId) + log('') + log( + `DevTools: ${toAcurastColor( + buildDevtoolsUrl(deployedJobId, viewKeyResponse.viewKey) + )}` + ) + log( + `View key expires at ${new Date(viewKeyResponse.expiresAt).toLocaleString()}` + ) + log('') + } catch (e: any) { + filelogger.error( + `Failed to get devtools view key: ${e.message}` + ) + log( + `Warning: Could not retrieve DevTools view key: ${e.message}` + ) + } + } + process.exit(0) } catch (e) { console.log('Error', e) diff --git a/src/commands/devtools.ts b/src/commands/devtools.ts new file mode 100644 index 0000000..e859c70 --- /dev/null +++ b/src/commands/devtools.ts @@ -0,0 +1,25 @@ +import { Command } from 'commander' +import { getDevtoolsViewKey, buildDevtoolsUrl } from '../devtools/devtoolsApi.js' +import { acurastColor } from '../util.js' + +export const addCommandDevtools = (program: Command) => { + program + .command('devtools ') + .description( + 'Request a DevTools view key for a deployment and print the URL.' + ) + .action(async (deploymentId: string) => { + const viewKeyResponse = await getDevtoolsViewKey(deploymentId) + + console.log('') + console.log( + `DevTools: ${acurastColor( + buildDevtoolsUrl(deploymentId, viewKeyResponse.viewKey) + )}` + ) + console.log( + `View key expires at ${new Date(viewKeyResponse.expiresAt).toLocaleString()}` + ) + console.log('') + }) +} diff --git a/src/config.ts b/src/config.ts index 84677ae..bb667d4 100644 --- a/src/config.ts +++ b/src/config.ts @@ -15,12 +15,17 @@ const INDEXER_MAINNET_API_KEY = 'HbLxqSJoPTnzwa_rkF-tYv' const IPFS_PROXY = 'https://ipfs-proxy.acurast.prod.gke.papers.tech' +const DEVTOOLS_URL = 'https://devtools.acurast.com' +const DEVTOOLS_API_URL = 'https://api.devtools.acurast.com' + export type EnvKeys = | 'ACURAST_MNEMONIC' | 'ACURAST_IPFS_URL' | 'ACURAST_IPFS_API_KEY' | 'ACURAST_RPC' | 'ACURAST_CANARY_RPC' + | 'ACURAST_DEVTOOLS_URL' + | 'ACURAST_DEVTOOLS_API_URL' | 'DEBUG' const defaultValues: Record = { @@ -29,6 +34,8 @@ const defaultValues: Record = { ACURAST_IPFS_API_KEY: '', // With the default IPFS Proxy, no API key is required ACURAST_RPC: RPC_MAINNET, ACURAST_CANARY_RPC: RPC_CANARY, + ACURAST_DEVTOOLS_URL: DEVTOOLS_URL, + ACURAST_DEVTOOLS_API_URL: DEVTOOLS_API_URL, DEBUG: 'false', } diff --git a/src/devtools/acurast-processor.d.ts b/src/devtools/acurast-processor.d.ts new file mode 100644 index 0000000..f110dc4 --- /dev/null +++ b/src/devtools/acurast-processor.d.ts @@ -0,0 +1,25 @@ +// Type declarations for Acurast processor runtime globals + +declare function httpPOST( + url: string, + body: string, + headers: Record, + onSuccess: (response: string, certificate: string) => void, + onError: (error: string) => void +): void + +declare const _STD_: { + job: { + getId(): { origin: { kind: string; source: string }; id: string } + getPublicKeys(): { p256: string; secp256k1: string; ed25519: string } + } + device: { + getAddress(): string + } + env: Record + signers: { + ed25519: { + sign(payloadHex: string): string + } + } +} diff --git a/src/devtools/devtools-snippet.ts b/src/devtools/devtools-snippet.ts new file mode 100644 index 0000000..eca2a86 --- /dev/null +++ b/src/devtools/devtools-snippet.ts @@ -0,0 +1,144 @@ +// This file is compiled to devtools-snippet.js and injected at the beginning +// of user scripts when enableDevtools is true. It overrides console methods to +// forward logs to the Acurast DevTools API. +// +// Placeholders __DEVTOOLS_API_URL__ and __DEVTOOLS_DEPLOYER__ are replaced at +// injection time by the CLI. + +;(() => { + const DEVTOOLS_API_URL = '__DEVTOOLS_API_URL__' + const DEVTOOLS_DEPLOYER = '__DEVTOOLS_DEPLOYER__' + + const originalConsole = { + log: console.log, + warn: console.warn, + error: console.error, + info: console.info, + debug: console.debug, + } + + // --- Auth: obtain a Bearer token from the devtools API --- + let apiKey: string | null = null + const pendingLogs: string[] = [] // buffered payloads while awaiting auth + + const toHex = (str: string): string => { + let hex = '' + for (let i = 0; i < str.length; i++) { + hex += str.charCodeAt(i).toString(16).padStart(2, '0') + } + return hex + } + + const sendBuffered = () => { + for (const body of pendingLogs.splice(0)) { + httpPOST( + `${DEVTOOLS_API_URL}/v1/logs`, + body, + { 'Content-Type': 'application/json', Authorization: 'Bearer ' + apiKey }, + () => {}, + (error: string) => { originalConsole.error('[devtools] buffered log post failed:', error) } + ) + } + } + + try { + const jobId = String(_STD_.job.getId().id) + const processorAddress = _STD_.device.getAddress() + const pubKeyHex = _STD_.job.getPublicKeys().ed25519 + const timestamp = String(Math.floor(Date.now() / 1000)) + const message = pubKeyHex + ':' + timestamp + const messageHex = toHex(message) + const signatureHex = _STD_.signers.ed25519.sign(messageHex) + + httpPOST( + `${DEVTOOLS_API_URL}/v1/auth/api-key`, + JSON.stringify({ jobId, processorAddress }), + { + 'Content-Type': 'application/json', + 'X-Signature': signatureHex, + 'X-PublicKey': pubKeyHex, + 'X-Timestamp': timestamp, + }, + (response: string, _certificate: string) => { + try { + const parsed = JSON.parse(response) + apiKey = parsed.apiKey + sendBuffered() + } catch (_e) { + originalConsole.error('[devtools] failed to parse auth response:', response) + } + }, + (error: string) => { + originalConsole.error('[devtools] auth failed:', error) + } + ) + } catch (e: any) { + originalConsole.error('[devtools] auth setup failed:', e?.message ?? String(e)) + } + + // --- Rate limiting --- + const RATE_LIMIT_MAX = 20 + const RATE_LIMIT_WINDOW_MS = 10_000 + let rateBucketStart = Date.now() + let rateBucketCount = 0 + let dropped = 0 + + const sendLog = (level: string, args: unknown[]) => { + const now = Date.now() + + if (now - rateBucketStart >= RATE_LIMIT_WINDOW_MS) { + if (dropped > 0) { + enqueue([{ type: 'warn', data: `[devtools] rate limit ended: ${dropped} log(s) were dropped`, timestamp: now }]) + } + rateBucketStart = now + rateBucketCount = 0 + dropped = 0 + } + + if (rateBucketCount >= RATE_LIMIT_MAX) { + dropped++ + return + } + rateBucketCount++ + + try { + const data = args.length === 1 ? args[0] : args + let serializable: unknown + try { + JSON.stringify(data) + serializable = data + } catch { + serializable = String(data) + } + + enqueue([{ type: level, data: serializable, timestamp: now }]) + } catch (_e) { + // Silently ignore serialization errors to avoid breaking user scripts + } + } + + const enqueue = (entries: { type: string; data: unknown; timestamp: number }[]) => { + const body = JSON.stringify(entries) + + if (apiKey) { + httpPOST( + `${DEVTOOLS_API_URL}/v1/logs`, + body, + { 'Content-Type': 'application/json', Authorization: 'Bearer ' + apiKey }, + () => {}, + (error: string) => { originalConsole.error('[devtools] log post failed:', error) } + ) + } else { + pendingLogs.push(body) + } + } + + for (const level of Object.keys(originalConsole) as Array< + keyof typeof originalConsole + >) { + ;(console as any)[level] = (...args: unknown[]) => { + originalConsole[level](...(args as [any, ...any[]])) + sendLog(level, args) + } + } +})() diff --git a/src/devtools/devtoolsApi.ts b/src/devtools/devtoolsApi.ts new file mode 100644 index 0000000..bae9d91 --- /dev/null +++ b/src/devtools/devtoolsApi.ts @@ -0,0 +1,77 @@ +import axios from 'axios' +import Keyring from '@polkadot/keyring' +import { u8aToHex } from '@polkadot/util' +import { waitReady } from '@polkadot/wasm-crypto' +import { getEnv } from '../config.js' +import { filelogger } from '../util/fileLogger.js' + +export interface ViewKeyResponse { + viewKey: string + jobId: string + expiresAt: string +} + +/** + * Derives an ed25519 keypair from the same mnemonic used for the CLI wallet. + * The devtools API accepts ed25519 (32-byte) public keys, not sr25519. + */ +async function getEd25519Wallet() { + await waitReady() + const keyring = new Keyring({ type: 'ed25519' }) + return keyring.addFromMnemonic(getEnv('ACURAST_MNEMONIC')) +} + +export function buildDevtoolsUrl( + deploymentId: string, + viewKey: string +): string { + const devtoolsUrl = getEnv('ACURAST_DEVTOOLS_URL') + return `${devtoolsUrl}/deployment/${deploymentId}?viewKey=${viewKey}` +} + +export async function getDevtoolsViewKey( + jobId: string +): Promise { + const apiUrl = getEnv('ACURAST_DEVTOOLS_API_URL') + const wallet = await getEd25519Wallet() + + const publicKeyHex = u8aToHex(wallet.publicKey).slice(2) // no 0x prefix + const timestamp = Math.floor(Date.now() / 1000).toString() + + // Message format expected by devtools API: "publicKeyHex:timestamp" + const message = `${publicKeyHex}:${timestamp}` + const encoder = new TextEncoder() + const signature = u8aToHex(wallet.sign(encoder.encode(message))).slice(2) + + filelogger.debug( + `DevTools view-key request: POST ${apiUrl}/v1/auth/view-key jobId=${jobId} publicKey=${publicKeyHex}` + ) + + try { + const response = await axios.post( + `${apiUrl}/v1/auth/view-key`, + { jobId }, + { + headers: { + 'Content-Type': 'application/json', + 'X-Signature': signature, + 'X-PublicKey': publicKeyHex, + 'X-Timestamp': timestamp, + }, + timeout: 10_000, + } + ) + + return response.data + } catch (error: any) { + const detail = error.response?.data + ? JSON.stringify(error.response.data) + : error.message + filelogger.error( + `DevTools view-key failed: ${error.response?.status} ${detail}` + ) + throw new Error( + `DevTools API ${error.response?.status ?? 'error'}: ${detail}` + ) + } +} diff --git a/src/devtools/injectDevtoolsSnippet.ts b/src/devtools/injectDevtoolsSnippet.ts new file mode 100644 index 0000000..9d1a170 --- /dev/null +++ b/src/devtools/injectDevtoolsSnippet.ts @@ -0,0 +1,44 @@ +import AdmZip from 'adm-zip' +import { readFileSync } from 'fs' +import { join } from 'path' + +/** + * Reads devtools-snippet.js from snippetDir, replaces placeholders, + * and prepends it to the entrypoint file inside the zip bundle. + */ +export async function injectDevtoolsSnippet( + zipPath: string, + entrypoint: string, + devtoolsApiUrl: string, + deployerAddress: string, + snippetDir: string +): Promise { + const snippetPath = join(snippetDir, 'devtools-snippet.js') + let snippet = readFileSync(snippetPath, 'utf-8') + + // Strip TSC module/sourcemap artifacts that shouldn't be in the injected snippet + snippet = snippet + .replace(/^export\s*\{\s*\}\s*;?\s*$/m, '') + .replace(/^\/\/#\s*sourceMappingURL=.*$/m, '') + .trim() + + snippet = snippet.replace(/__DEVTOOLS_API_URL__/g, devtoolsApiUrl) + snippet = snippet.replace(/__DEVTOOLS_DEPLOYER__/g, deployerAddress) + + const zip = new AdmZip(zipPath) + const entry = zip.getEntry(entrypoint) + + if (!entry) { + throw new Error( + `Could not find entrypoint "${entrypoint}" in bundle to inject devtools snippet` + ) + } + + const originalContent = entry.getData().toString('utf-8') + const injectedContent = snippet + '\n' + originalContent + + zip.updateFile(entrypoint, Buffer.from(injectedContent, 'utf-8')) + zip.writeZip(zipPath) + + return zipPath +} diff --git a/src/index.ts b/src/index.ts index ac8ece9..0e1366b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,6 +21,7 @@ import { dirname, join } from 'path' import { addCommandDeployments } from './commands/deployments.js' import { addCommandNew } from './commands/new.js' import { addCommandEstimateFee } from './commands/estimate-fee.js' +import { addCommandDevtools } from './commands/devtools.js' import { ACURAST_CLI_VERSION_CHECK_URL } from './constants.js' import { filelogger } from './util/fileLogger.js' @@ -54,6 +55,7 @@ addCommandOpen(program) // addCommandWatch(program) addCommandNew(program) addCommandEstimateFee(program) +addCommandDevtools(program) if (!process.argv.slice(2).length) { console.log(acurastColor(textSync('Acurast CLI'))) diff --git a/src/types.ts b/src/types.ts index 1777474..53c0cae 100644 --- a/src/types.ts +++ b/src/types.ts @@ -114,6 +114,9 @@ export interface AcurastProjectConfig { // The reuseKeysFrom field. Format: [MultiOrigin, string, number] where MultiOrigin is the chain name (e.g., "Acurast"), the second element is the address of the original deployer, and the last element is the deploymentId. reuseKeysFrom?: [MultiOrigin, string, number] + + // Enable Acurast DevTools to forward console logs to the developer tools page. + enableDevtools?: boolean } export interface AcurastDeployment { diff --git a/test/devtools.test.ts b/test/devtools.test.ts new file mode 100644 index 0000000..a5995ff --- /dev/null +++ b/test/devtools.test.ts @@ -0,0 +1,178 @@ +import AdmZip from 'adm-zip' +import { mkdtempSync, rmSync } from 'fs' +import { join, resolve } from 'path' +import { tmpdir } from 'os' +import { injectDevtoolsSnippet } from '../src/devtools/injectDevtoolsSnippet.js' + +// Point to the compiled snippet in dist/ (built by `npm run build`) +const SNIPPET_DIR = resolve(__dirname, '..', 'dist', 'devtools') + +describe('devtools snippet injection', () => { + let tempDir: string + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'devtools-test-')) + }) + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }) + }) + + function createTestZip( + entrypoint: string, + entrypointContent: string + ): string { + const zipPath = join(tempDir, 'test-bundle.zip') + const zip = new AdmZip() + zip.addFile( + 'manifest.json', + Buffer.from( + JSON.stringify({ name: 'test', version: 1, entrypoint }), + 'utf-8' + ) + ) + zip.addFile(entrypoint, Buffer.from(entrypointContent, 'utf-8')) + zip.writeZip(zipPath) + return zipPath + } + + it('should prepend the devtools snippet to the entrypoint', async () => { + const originalCode = 'console.log("hello world")' + const zipPath = createTestZip('index.js', originalCode) + + await injectDevtoolsSnippet( + zipPath, + 'index.js', + 'https://api.devtools.acurast.com', + '5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY', + SNIPPET_DIR + ) + + const zip = new AdmZip(zipPath) + const content = zip.getEntry('index.js')!.getData().toString('utf-8') + + // Original code should still be present + expect(content).toContain(originalCode) + + // Snippet should be prepended (original code at the end) + expect(content.indexOf('httpPOST')).toBeLessThan( + content.indexOf(originalCode) + ) + }) + + it('should replace the API URL placeholder', async () => { + const zipPath = createTestZip('index.js', '// user code') + const apiUrl = 'https://custom-api.example.com' + + await injectDevtoolsSnippet( + zipPath, + 'index.js', + apiUrl, + '5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY', + SNIPPET_DIR + ) + + const zip = new AdmZip(zipPath) + const content = zip.getEntry('index.js')!.getData().toString('utf-8') + + expect(content).toContain(apiUrl) + expect(content).not.toContain('__DEVTOOLS_API_URL__') + }) + + it('should replace the deployer placeholder', async () => { + const zipPath = createTestZip('index.js', '// user code') + const deployer = '5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY' + + await injectDevtoolsSnippet( + zipPath, + 'index.js', + 'https://api.devtools.acurast.com', + deployer, + SNIPPET_DIR + ) + + const zip = new AdmZip(zipPath) + const content = zip.getEntry('index.js')!.getData().toString('utf-8') + + expect(content).toContain(deployer) + expect(content).not.toContain('__DEVTOOLS_DEPLOYER__') + }) + + it('should strip TSC artifacts (export {}, sourcemap comment)', async () => { + const zipPath = createTestZip('index.js', '// user code') + + await injectDevtoolsSnippet( + zipPath, + 'index.js', + 'https://api.devtools.acurast.com', + '5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY', + SNIPPET_DIR + ) + + const zip = new AdmZip(zipPath) + const content = zip.getEntry('index.js')!.getData().toString('utf-8') + + expect(content).not.toContain('export {}') + expect(content).not.toContain('sourceMappingURL') + }) + + it('should throw if entrypoint is not found in zip', async () => { + const zipPath = createTestZip('index.js', '// user code') + + await expect( + injectDevtoolsSnippet( + zipPath, + 'nonexistent.js', + 'https://api.devtools.acurast.com', + '5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY', + SNIPPET_DIR + ) + ).rejects.toThrow('Could not find entrypoint') + }) + + it('should not break the manifest or other files in the zip', async () => { + const zipPath = createTestZip('index.js', '// user code') + + // Add an extra file + const zip = new AdmZip(zipPath) + zip.addFile('lib/helper.js', Buffer.from('// helper', 'utf-8')) + zip.writeZip(zipPath) + + await injectDevtoolsSnippet( + zipPath, + 'index.js', + 'https://api.devtools.acurast.com', + '5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY', + SNIPPET_DIR + ) + + const result = new AdmZip(zipPath) + const manifest = JSON.parse( + result.getEntry('manifest.json')!.getData().toString('utf-8') + ) + expect(manifest.entrypoint).toBe('index.js') + + const helper = result.getEntry('lib/helper.js')!.getData().toString('utf-8') + expect(helper).toBe('// helper') + }) + + it('should override all console methods in the snippet', async () => { + const zipPath = createTestZip('index.js', '// user code') + + await injectDevtoolsSnippet( + zipPath, + 'index.js', + 'https://api.devtools.acurast.com', + '5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY', + SNIPPET_DIR + ) + + const zip = new AdmZip(zipPath) + const content = zip.getEntry('index.js')!.getData().toString('utf-8') + + // The snippet should capture all 5 console methods + for (const method of ['log', 'warn', 'error', 'info', 'debug']) { + expect(content).toContain(`console.${method}`) + } + }) +})