From 82f49f8ec3ade3e557e2d7f983d27b8e48ed05c6 Mon Sep 17 00:00:00 2001 From: i582 <51853996+i582@users.noreply.github.com> Date: Mon, 6 Oct 2025 20:05:51 +0400 Subject: [PATCH 01/10] feat: add websocket data transmission --- package.json | 4 +- src/blockchain/Blockchain.ts | 167 +++++++++++++++++++++++++- src/blockchain/MessageQueueManager.ts | 8 +- src/blockchain/SmartContract.ts | 13 +- src/blockchain/transport-websocket.ts | 77 ++++++++++++ yarn.lock | 26 ++++ 6 files changed, 286 insertions(+), 9 deletions(-) create mode 100644 src/blockchain/transport-websocket.ts diff --git a/package.json b/package.json index abd700f..1272042 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "@ton/toolchain": "the-ton-tech/toolchain#v1.4.0", "@types/jest": "^30.0.0", "@types/node": "^24.1.0", + "@types/ws": "^8.5.10", "eslint": "^9.28.0", "jest": "^30.0.5", "jest-config": "^30.0.5", @@ -64,6 +65,7 @@ "chalk": "^4.1.2", "fflate": "^0.8.2", "table": "^6.9.0", - "ton-assembly": "0.1.2" + "ton-assembly": "0.1.2", + "ws": "^8.18.2" } } diff --git a/src/blockchain/Blockchain.ts b/src/blockchain/Blockchain.ts index 00b26f8..110acd2 100644 --- a/src/blockchain/Blockchain.ts +++ b/src/blockchain/Blockchain.ts @@ -11,8 +11,14 @@ import { ExternalAddress, StateInit, OpenedContract, + beginCell, + storeTransaction, + Transaction, + storeShardAccount, + storeStateInit, } from '@ton/core'; import { getSecureRandomBytes } from '@ton/crypto'; +import WebSocket from 'ws'; import { defaultConfig } from '../config/defaultConfig'; import { IExecutor, Executor, TickOrTock, PrevBlocksInfo } from '../executor/Executor'; @@ -41,6 +47,13 @@ import { MessageQueueManager } from './MessageQueueManager'; import { AsyncLock } from '../utils/AsyncLock'; import { BlockchainSnapshot } from './BlockchainSnapshot'; import { requireOptional } from '../utils/require'; +import { + HexString, + RawContractData, + RawTransactionInfo, + RawTransactionsInfo, + websocketSend, +} from './transport-websocket'; const CREATE_WALLETS_PREFIX = 'CREATE_WALLETS'; @@ -50,6 +63,9 @@ function createWalletsSeed(idx: number) { const LT_ALIGN = 1000000n; +// eslint-disable-next-line no-undef +declare const expect: jest.Expect; + export type ExternalOutInfo = { type: 'external-out'; src: Address; @@ -130,6 +146,7 @@ export function toSandboxContract(contract: OpenedContract): SandboxContra export type PendingMessage = ( | ({ type: 'message'; + callStack?: string; mode?: number; } & Message) | { @@ -170,6 +187,11 @@ function blockchainConfigToBase64(config: BlockchainConfig | undefined): string } } +export type ConnectionOptions = { + readonly port?: number; + readonly host?: string; +}; + export type SendMessageIterParams = MessageParams & { allowParallel?: boolean; }; @@ -192,6 +214,9 @@ export class Blockchain { protected nextCreateWalletIndex = 0; protected shouldRecordStorage = false; protected meta?: ContractsMeta; + protected useWebsocket: boolean; + protected connectionOptions: ConnectionOptions; + protected ws: WebSocket | undefined = undefined; protected prevBlocksInfo?: PrevBlocksInfo; protected randomSeed?: Buffer; protected shouldDebug = false; @@ -323,12 +348,16 @@ export class Blockchain { storage: BlockchainStorage; meta?: ContractsMeta; autoDeployLibs?: boolean; + useWebsocket?: boolean; + connectionOptions?: ConnectionOptions; }) { this.networkConfig = blockchainConfigToBase64(opts.config); this.executor = opts.executor; this.storage = opts.storage; this.meta = opts.meta; this.autoDeployLibs = opts.autoDeployLibs ?? false; + this.useWebsocket = opts.useWebsocket ?? false; + this.connectionOptions = opts.connectionOptions ?? { port: 7743, host: 'localhost' }; this.defaultQueueManager = this.createQueueManager(); } @@ -343,6 +372,7 @@ export class Blockchain { getAutoDeployLibs: () => this.autoDeployLibs, registerTxsForCoverage: (txs) => this.registerTxsForCoverage(txs), addTransaction: (transaction: BlockchainTransaction) => this.transactions.push(transaction), + publishTransactions: (txs) => this.publishTransactions(txs), }); } @@ -655,11 +685,12 @@ export class Blockchain { * Opens contract. Returns proxy that substitutes the blockchain Provider in methods starting with get and set. * * @param contract Contract to open. + * @param name Name of the contract. * * @example * const contract = blockchain.openContract(new Contract(address)); */ - openContract(contract: T) { + openContract(contract: T, name?: string) { let address: Address; let init: StateInit | undefined = undefined; @@ -677,7 +708,7 @@ export class Blockchain { init = contract.init; } - this.meta?.upsert(address, { wrapperName: contract?.constructor?.name, abi: contract.abi }); + this.meta?.upsert(address, { wrapperName: name ?? contract?.constructor?.name, abi: contract.abi }); const provider = this.provider(address, init); @@ -888,6 +919,111 @@ export class Blockchain { return new Coverage(mergeCoverages(...coverages)); } + protected async publishTransactions(txs: BlockchainTransaction[]) { + if (!this.useWebsocket) { + return; + } + + const testName = expect === undefined ? '' : expect.getState().currentTestName; + const transactions = this.serializeTransactions(txs); + const contracts = await this.contractsData(); + + await this.websocketConnect(); + websocketSend(this.ws, { type: 'test-data', testName, transactions, contracts }); + this.websocketDisconnect(); + } + + private async contractsData(): Promise { + return Promise.all( + this.storage.knownContracts().map(async (contract): Promise => { + const state = contract.accountState; + const stateInit = beginCell(); + if (state?.type === 'active') { + stateInit.store(storeStateInit(state.state)); + } + + const account = contract.account; + const accountCell = beginCell().store(storeShardAccount(account)).endCell(); + + const stateInitCell = stateInit.asCell(); + return { + address: contract.address.toString(), + meta: this.meta?.get(contract.address), + stateInit: + stateInitCell.bits.length === 0 + ? undefined + : (stateInitCell.toBoc().toString('hex') as HexString), + account: accountCell.toBoc().toString('hex') as HexString, + }; + }), + ); + } + + /** + * Convert the ` BlockchainTransaction ` array to `RawTransactionsInfo` that can be safely sent over network. + * @param transactions Input transactions to serialize + */ + protected serializeTransactions(transactions: BlockchainTransaction[]): RawTransactionsInfo { + return { + transactions: transactions.map((t): RawTransactionInfo => { + const tx = beginCell() + .store(storeTransaction(t as Transaction)) + .endCell() + .toBoc() + .toString('hex'); + + return { + transaction: tx, + blockchainLogs: t.blockchainLogs, + vmLogs: t.vmLogs, + debugLogs: t.debugLogs, + code: undefined, + sourceMap: undefined, + contractName: undefined, + parentId: t.parent?.lt.toString(), + childrenIds: t.children?.map((c) => c?.lt?.toString()), + oldStorage: t.oldStorage?.toBoc().toString('hex') as HexString | undefined, + newStorage: t.newStorage?.toBoc().toString('hex') as HexString | undefined, + callStack: t.callStack, + }; + }), + }; + } + + protected async websocketConnect(): Promise { + await this.websocketConnectOrThrow().catch(() => { + // eslint-disable-next-line no-console + console.warn( + 'Unable to connect to sandbox server in Web UI mode. Make sure the port and host match the sandbox server. You can set the WebSocket address globally with `SANDBOX_WEBSOCKET_ADDR=ws://localhost:7743` or via `Blockchain.create({ connectionOptions: { host: "localhost", port: 7743 } })`.', + ); + }); + } + + protected async websocketConnectOrThrow(): Promise { + if (this.ws !== undefined) return; + return new Promise((resolve, reject) => { + const addr = + process.env.SANDBOX_WEBSOCKET_ADDR ?? + `ws://${this.connectionOptions.host ?? 'localhost'}:${this.connectionOptions.port ?? '7743'}`; + this.ws = new WebSocket(addr); + + this.ws.on('open', () => { + resolve(); + }); + + this.ws.on('error', (error) => { + reject(error); + }); + }); + } + + protected websocketDisconnect(): void { + if (this.ws) { + this.ws.close(); + this.ws = undefined; + } + } + /** * Creates instance of sandbox blockchain. * @@ -896,6 +1032,7 @@ export class Blockchain { * @param [opts.storage] Contracts storage used for blockchain. If omitted {@link LocalBlockchainStorage} is used. * @param [opts.meta] Optional contracts metadata provider. If not provided, {@link @ton/test-utils.contractsMeta} will be used to accumulate contracts metadata. * @param [opts.autoDeployLibs] Optional flag. If set to true, libraries will be collected automatically + * @param [opts.useWebsocket] Send data to websocket using `opts.connectionOptions` options. * @example * const blockchain = await Blockchain.create({ config: 'slim' }); * @@ -914,12 +1051,36 @@ export class Blockchain { storage?: BlockchainStorage; meta?: ContractsMeta; autoDeployLibs?: boolean; + useWebsocket?: boolean; + connectionOptions?: ConnectionOptions; }) { - return new Blockchain({ + const useWebsocket = opts?.useWebsocket ?? process.env['SANDBOX_USE_WEBSOCKET'] === 'true'; + + if ( + opts?.connectionOptions === undefined && + process.env['SANDBOX_WEBSOCKET_HOST'] !== undefined && + process.env['SANDBOX_WEBSOCKET_PORT'] !== undefined + ) { + opts = { + ...opts, + connectionOptions: { + host: process.env['SANDBOX_WEBSOCKET_HOST'], + port: Number.parseInt(process.env['SANDBOX_WEBSOCKET_PORT']), + }, + }; + } + + const blockchain = new Blockchain({ executor: opts?.executor ?? (await Executor.create()), storage: opts?.storage ?? new LocalBlockchainStorage(), meta: opts?.meta ?? requireOptional('@ton/test-utils')?.contractsMeta, ...opts, }); + if (useWebsocket) { + blockchain.verbosity.print = false; + blockchain.verbosity.vmLogs = 'vm_logs_verbose'; + await blockchain.websocketConnect(); + } + return blockchain; } } diff --git a/src/blockchain/MessageQueueManager.ts b/src/blockchain/MessageQueueManager.ts index 122bbfe..083f6ed 100644 --- a/src/blockchain/MessageQueueManager.ts +++ b/src/blockchain/MessageQueueManager.ts @@ -29,6 +29,7 @@ export class MessageQueueManager { setLibs(libs: Cell | undefined): void; getAutoDeployLibs(): boolean; registerTxsForCoverage(txs: BlockchainTransaction[]): void; + publishTransactions(txs: BlockchainTransaction[]): Promise; addTransaction(transaction: BlockchainTransaction): void; }, ) {} @@ -100,6 +101,8 @@ export class MessageQueueManager { return result; }); + + await this.blockchain.publishTransactions(results); this.blockchain.registerTxsForCoverage(results); return results; } @@ -110,9 +113,11 @@ export class MessageQueueManager { while (!done) { const message = this.messageQueue.shift()!; + let callStack: string | undefined; let tx: SmartContractTransaction; let smartContract: SmartContract; if (message.type === 'message') { + callStack = message.callStack; if (message.info.type === 'external-out') { done = this.messageQueue.length == 0; continue; @@ -120,7 +125,7 @@ export class MessageQueueManager { this.blockchain.increaseLt(); smartContract = await this.blockchain.getContract(message.info.dest); - tx = await smartContract.receiveMessage(message, params); + tx = await smartContract.receiveMessage(message, params, callStack); } else { this.blockchain.increaseLt(); smartContract = await this.blockchain.getContract(message.on); @@ -164,6 +169,7 @@ export class MessageQueueManager { type: 'message', parentTransaction: transaction, mode: sendMsgActions[index]?.mode, + callStack, ...message, }); diff --git a/src/blockchain/SmartContract.ts b/src/blockchain/SmartContract.ts index 65717e9..70f5614 100644 --- a/src/blockchain/SmartContract.ts +++ b/src/blockchain/SmartContract.ts @@ -120,6 +120,7 @@ export type SmartContractTransaction = Transaction & { blockchainLogs: string; vmLogs: string; debugLogs: string; + callStack?: string; oldStorage?: Cell; newStorage?: Cell; outActions?: OutAction[]; @@ -322,7 +323,7 @@ export class SmartContract { }; } - async receiveMessage(message: Message, params?: MessageParams) { + async receiveMessage(message: Message, params?: MessageParams, callStack?: string) { const args: RunTransactionArgs = { ...this.createCommonArgs(params), message: beginCell().store(storeMessage(message)).endCell(), @@ -333,14 +334,14 @@ export class SmartContract { const { uninitialized, debugInfo } = debugContext.getDebugInfo(this.account); if (debugInfo !== undefined) { const executor = await this.blockchain.getDebuggerExecutor(); - return await this.runCommon(() => debugContext.debugTransaction(executor, args, debugInfo)); + return await this.runCommon(() => debugContext.debugTransaction(executor, args, debugInfo), callStack); } else if (uninitialized) { // eslint-disable-next-line no-console console.log('Debugging uninitialized accounts is unsupported in debugger beta'); } } - return await this.runCommon(() => this.blockchain.executor.runTransaction(args)); + return await this.runCommon(() => this.blockchain.executor.runTransaction(args), callStack); } async runTickTock(which: TickOrTock, params?: MessageParams) { @@ -352,7 +353,10 @@ export class SmartContract { ); } - protected async runCommon(run: () => Promise): Promise { + protected async runCommon( + run: () => Promise, + callStack?: string, + ): Promise { let oldStorage: Cell | undefined = undefined; if (this.blockchain.recordStorage && this.account.account?.storage.state.type === 'active') { oldStorage = this.account.account?.storage.state.state.data ?? undefined; @@ -406,6 +410,7 @@ export class SmartContract { blockchainLogs: res.logs, vmLogs: res.result.vmLog, debugLogs: res.debugLogs, + callStack, oldStorage, newStorage, outActions, diff --git a/src/blockchain/transport-websocket.ts b/src/blockchain/transport-websocket.ts new file mode 100644 index 0000000..79cec8d --- /dev/null +++ b/src/blockchain/transport-websocket.ts @@ -0,0 +1,77 @@ +import { WebSocket } from 'ws'; + +import { ContractMeta } from '../meta/ContractsMeta'; + +declare const hexBrand: unique symbol; +export type HexString = string & { readonly [hexBrand]: true }; + +export interface RawContractData { + /** + * User-friendly representation of the contract address. + */ + readonly address: string; + /** + * Additional information about the contract. + */ + readonly meta: ContractMeta | undefined; + /** + * Hex-encoded state init of the contract. + */ + readonly stateInit: HexString | undefined; + /** + * Hex-encoded shard account info of the contract. + */ + readonly account: HexString | undefined; +} + +export interface MessageTestData { + readonly type: 'test-data'; + /** + * Name of the current running test or undefined. + */ + readonly testName: string | undefined; + /** + * All transactions in the chain. + */ + readonly transactions: RawTransactionsInfo; + /** + * Known contracts information. + */ + readonly contracts: readonly RawContractData[]; +} + +export interface RawTransactionsInfo { + readonly transactions: readonly RawTransactionInfo[]; +} + +export interface RawTransactionInfo { + readonly transaction: string; + readonly blockchainLogs: string; + readonly vmLogs: string; + readonly debugLogs: string; + readonly code: string | undefined; + readonly sourceMap: object | undefined; + readonly contractName: string | undefined; + readonly parentId: string | undefined; + readonly childrenIds: string[]; + readonly oldStorage: HexString | undefined; + readonly newStorage: HexString | undefined; + readonly callStack: string | undefined; +} + +export type Message = MessageTestData; + +export function websocketSend(ws: WebSocket | undefined, data: Message): void { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify(data)); + } + + if (ws === undefined) { + // eslint-disable-next-line no-console + console.warn('Cannot send, Websocket is undefined!'); + } + if (ws && ws.readyState !== WebSocket.OPEN) { + // eslint-disable-next-line no-console + console.warn('Cannot send, Websocket is not open!'); + } +} diff --git a/yarn.lock b/yarn.lock index dfb0947..973531e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1377,6 +1377,7 @@ __metadata: "@ton/toolchain": "the-ton-tech/toolchain#v1.4.0" "@types/jest": "npm:^30.0.0" "@types/node": "npm:^24.1.0" + "@types/ws": "npm:^8.5.10" "@vscode/debugadapter": "npm:^1.68.0" chalk: "npm:^4.1.2" eslint: "npm:^9.28.0" @@ -1389,6 +1390,7 @@ __metadata: ts-jest: "npm:^29.4.1" ts-node: "npm:^10.9.1" typescript: "npm:^5.8.3" + ws: "npm:^8.18.2" peerDependencies: "@ton-community/func-js": ">=0.10.0" "@ton/core": ">=0.61.0" @@ -1631,6 +1633,15 @@ __metadata: languageName: node linkType: hard +"@types/ws@npm:^8.5.10": + version: 8.18.1 + resolution: "@types/ws@npm:8.18.1" + dependencies: + "@types/node": "npm:*" + checksum: 10/1ce05e3174dcacf28dae0e9b854ef1c9a12da44c7ed73617ab6897c5cbe4fccbb155a20be5508ae9a7dde2f83bd80f5cf3baa386b934fc4b40889ec963e94f3a + languageName: node + linkType: hard + "@types/yargs-parser@npm:*": version: 21.0.3 resolution: "@types/yargs-parser@npm:21.0.3" @@ -6846,6 +6857,21 @@ __metadata: languageName: node linkType: hard +"ws@npm:^8.18.2": + version: 8.18.3 + resolution: "ws@npm:8.18.3" + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ">=5.0.2" + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + checksum: 10/725964438d752f0ab0de582cd48d6eeada58d1511c3f613485b5598a83680bedac6187c765b0fe082e2d8cc4341fc57707c813ae780feee82d0c5efe6a4c61b6 + languageName: node + linkType: hard + "y18n@npm:^5.0.5": version: 5.0.8 resolution: "y18n@npm:5.0.8" From c7617e721588add1bab5abb6a6e12a974d3e67ef Mon Sep 17 00:00:00 2001 From: i582 <51853996+i582@users.noreply.github.com> Date: Mon, 6 Oct 2025 20:26:53 +0400 Subject: [PATCH 02/10] fix --- src/blockchain/Blockchain.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/blockchain/Blockchain.ts b/src/blockchain/Blockchain.ts index 110acd2..a74929f 100644 --- a/src/blockchain/Blockchain.ts +++ b/src/blockchain/Blockchain.ts @@ -960,7 +960,7 @@ export class Blockchain { } /** - * Convert the ` BlockchainTransaction ` array to `RawTransactionsInfo` that can be safely sent over network. + * Convert the `BlockchainTransaction` array to `RawTransactionsInfo` that can be safely sent over network. * @param transactions Input transactions to serialize */ protected serializeTransactions(transactions: BlockchainTransaction[]): RawTransactionsInfo { From 0d00496492c304fd23d75ea7e993df2b17dc56d0 Mon Sep 17 00:00:00 2001 From: i582 <51853996+i582@users.noreply.github.com> Date: Mon, 6 Oct 2025 20:45:06 +0400 Subject: [PATCH 03/10] fix error message --- src/blockchain/Blockchain.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/blockchain/Blockchain.ts b/src/blockchain/Blockchain.ts index a74929f..5dd115a 100644 --- a/src/blockchain/Blockchain.ts +++ b/src/blockchain/Blockchain.ts @@ -994,7 +994,7 @@ export class Blockchain { await this.websocketConnectOrThrow().catch(() => { // eslint-disable-next-line no-console console.warn( - 'Unable to connect to sandbox server in Web UI mode. Make sure the port and host match the sandbox server. You can set the WebSocket address globally with `SANDBOX_WEBSOCKET_ADDR=ws://localhost:7743` or via `Blockchain.create({ connectionOptions: { host: "localhost", port: 7743 } })`.', + 'Unable to connect to websocket server. Make sure the port and host match the sandbox server or VS Code settings. You can set the WebSocket address globally with `SANDBOX_WEBSOCKET_ADDR=ws://localhost:7743` or via `Blockchain.create({ connectionOptions: { host: "localhost", port: 7743 } })`.', ); }); } From 4c9bb4b5928c660606c2dd16b48de3e36f00e59a Mon Sep 17 00:00:00 2001 From: i582 <51853996+i582@users.noreply.github.com> Date: Mon, 6 Oct 2025 20:53:10 +0400 Subject: [PATCH 04/10] add comment and remove duplicate envs --- src/blockchain/Blockchain.ts | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/src/blockchain/Blockchain.ts b/src/blockchain/Blockchain.ts index 5dd115a..eb96d54 100644 --- a/src/blockchain/Blockchain.ts +++ b/src/blockchain/Blockchain.ts @@ -928,6 +928,10 @@ export class Blockchain { const transactions = this.serializeTransactions(txs); const contracts = await this.contractsData(); + // This solution, requiring a reconnection for each sending, may seem inefficient. + // An alternative would be to establish a single connection when creating the Blockchain, + // but in that case, it's unclear when this connection should be closed. + // This solution does not have this problem since the connection is closed immediately after sending. await this.websocketConnect(); websocketSend(this.ws, { type: 'test-data', testName, transactions, contracts }); this.websocketDisconnect(); @@ -1056,20 +1060,6 @@ export class Blockchain { }) { const useWebsocket = opts?.useWebsocket ?? process.env['SANDBOX_USE_WEBSOCKET'] === 'true'; - if ( - opts?.connectionOptions === undefined && - process.env['SANDBOX_WEBSOCKET_HOST'] !== undefined && - process.env['SANDBOX_WEBSOCKET_PORT'] !== undefined - ) { - opts = { - ...opts, - connectionOptions: { - host: process.env['SANDBOX_WEBSOCKET_HOST'], - port: Number.parseInt(process.env['SANDBOX_WEBSOCKET_PORT']), - }, - }; - } - const blockchain = new Blockchain({ executor: opts?.executor ?? (await Executor.create()), storage: opts?.storage ?? new LocalBlockchainStorage(), From 242e1a99b46504b9108030cda34603d8390ae813 Mon Sep 17 00:00:00 2001 From: i582 <51853996+i582@users.noreply.github.com> Date: Mon, 6 Oct 2025 20:58:31 +0400 Subject: [PATCH 05/10] refactor --- src/blockchain/Blockchain.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/blockchain/Blockchain.ts b/src/blockchain/Blockchain.ts index eb96d54..4be2850 100644 --- a/src/blockchain/Blockchain.ts +++ b/src/blockchain/Blockchain.ts @@ -945,11 +945,11 @@ export class Blockchain { if (state?.type === 'active') { stateInit.store(storeStateInit(state.state)); } + const stateInitCell = stateInit.asCell(); const account = contract.account; const accountCell = beginCell().store(storeShardAccount(account)).endCell(); - const stateInitCell = stateInit.asCell(); return { address: contract.address.toString(), meta: this.meta?.get(contract.address), From 2cd94210b9634eda5107d0f933af0b02655debec Mon Sep 17 00:00:00 2001 From: i582 <51853996+i582@users.noreply.github.com> Date: Mon, 6 Oct 2025 21:12:20 +0400 Subject: [PATCH 06/10] clarify the problem --- src/blockchain/Blockchain.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/blockchain/Blockchain.ts b/src/blockchain/Blockchain.ts index 4be2850..4e9da32 100644 --- a/src/blockchain/Blockchain.ts +++ b/src/blockchain/Blockchain.ts @@ -931,7 +931,11 @@ export class Blockchain { // This solution, requiring a reconnection for each sending, may seem inefficient. // An alternative would be to establish a single connection when creating the Blockchain, // but in that case, it's unclear when this connection should be closed. + // Until the connection is closed, the Node process will not terminate, and Jest will issue + // a warning, but the test will continue to wait for the connection to be closed. + // // This solution does not have this problem since the connection is closed immediately after sending. + // The reconnection time, meanwhile, is short enough to not be noticeable during tests. await this.websocketConnect(); websocketSend(this.ws, { type: 'test-data', testName, transactions, contracts }); this.websocketDisconnect(); From 6c07bedcdd7996f559bcb681e82cd874c513c5db Mon Sep 17 00:00:00 2001 From: Alejandbel Date: Fri, 24 Oct 2025 23:51:10 +0300 Subject: [PATCH 07/10] feature: refactor --- src/blockchain/Blockchain.ts | 188 ++++-------------- src/utils/environment.ts | 22 ++ src/utils/noop.ts | 4 + src/websocket/WebSocketManager.ts | 139 +++++++++++++ .../transport-websocket.ts | 0 5 files changed, 205 insertions(+), 148 deletions(-) create mode 100644 src/utils/noop.ts create mode 100644 src/websocket/WebSocketManager.ts rename src/{blockchain => websocket}/transport-websocket.ts (100%) diff --git a/src/blockchain/Blockchain.ts b/src/blockchain/Blockchain.ts index 4e9da32..e41fd9b 100644 --- a/src/blockchain/Blockchain.ts +++ b/src/blockchain/Blockchain.ts @@ -11,14 +11,8 @@ import { ExternalAddress, StateInit, OpenedContract, - beginCell, - storeTransaction, - Transaction, - storeShardAccount, - storeStateInit, } from '@ton/core'; import { getSecureRandomBytes } from '@ton/crypto'; -import WebSocket from 'ws'; import { defaultConfig } from '../config/defaultConfig'; import { IExecutor, Executor, TickOrTock, PrevBlocksInfo } from '../executor/Executor'; @@ -40,20 +34,16 @@ import { internal } from '../utils/message'; import { slimConfig } from '../config/slimConfig'; import { testSubwalletId } from '../utils/testTreasurySubwalletId'; import { collectMetric } from '../metric/collectMetric'; -import { ContractsMeta } from '../meta/ContractsMeta'; +import { ContractMeta, ContractsMeta } from '../meta/ContractsMeta'; import { deepcopy } from '../utils/deepcopy'; import { collectAsmCoverage, collectTxsCoverage, mergeCoverages, Coverage } from '../coverage'; import { MessageQueueManager } from './MessageQueueManager'; import { AsyncLock } from '../utils/AsyncLock'; import { BlockchainSnapshot } from './BlockchainSnapshot'; import { requireOptional } from '../utils/require'; -import { - HexString, - RawContractData, - RawTransactionInfo, - RawTransactionsInfo, - websocketSend, -} from './transport-websocket'; +import { ConnectionOptions, IWebSocketManager, WebSocketManager } from '../websocket/WebSocketManager'; +import { noop, noopPromise } from '../utils/noop'; +import { getOptionalEnv } from '../utils/environment'; const CREATE_WALLETS_PREFIX = 'CREATE_WALLETS'; @@ -63,9 +53,6 @@ function createWalletsSeed(idx: number) { const LT_ALIGN = 1000000n; -// eslint-disable-next-line no-undef -declare const expect: jest.Expect; - export type ExternalOutInfo = { type: 'external-out'; src: Address; @@ -187,15 +174,12 @@ function blockchainConfigToBase64(config: BlockchainConfig | undefined): string } } -export type ConnectionOptions = { - readonly port?: number; - readonly host?: string; -}; - export type SendMessageIterParams = MessageParams & { allowParallel?: boolean; }; +export type WebsocketOptions = { enabled?: boolean } & ConnectionOptions; + export class Blockchain { protected lock = new AsyncLock(); @@ -214,9 +198,6 @@ export class Blockchain { protected nextCreateWalletIndex = 0; protected shouldRecordStorage = false; protected meta?: ContractsMeta; - protected useWebsocket: boolean; - protected connectionOptions: ConnectionOptions; - protected ws: WebSocket | undefined = undefined; protected prevBlocksInfo?: PrevBlocksInfo; protected randomSeed?: Buffer; protected shouldDebug = false; @@ -224,6 +205,7 @@ export class Blockchain { protected transactions: BlockchainTransaction[] = []; protected defaultQueueManager: MessageQueueManager; + protected webSocketManager: IWebSocketManager; protected collectCoverage: boolean = false; protected readonly coverageTransactions: BlockchainTransaction[][] = []; @@ -348,18 +330,34 @@ export class Blockchain { storage: BlockchainStorage; meta?: ContractsMeta; autoDeployLibs?: boolean; - useWebsocket?: boolean; - connectionOptions?: ConnectionOptions; + websocketOptions?: { + enabled?: boolean; + } & ConnectionOptions; }) { this.networkConfig = blockchainConfigToBase64(opts.config); this.executor = opts.executor; this.storage = opts.storage; this.meta = opts.meta; this.autoDeployLibs = opts.autoDeployLibs ?? false; - this.useWebsocket = opts.useWebsocket ?? false; - this.connectionOptions = opts.connectionOptions ?? { port: 7743, host: 'localhost' }; this.defaultQueueManager = this.createQueueManager(); + this.webSocketManager = this.createWebSocketManager(opts.websocketOptions); + } + + protected createWebSocketManager( + opts?: { + enabled?: boolean; + } & ConnectionOptions, + ): IWebSocketManager { + if (!opts?.enabled) { + // noop implementation + return { publishTransactions: noopPromise }; + } + + return new WebSocketManager(opts, { + getMeta: (address) => this.meta?.get(address), + knownContracts: () => this.storage.knownContracts(), + }); } protected createQueueManager(): MessageQueueManager { @@ -370,9 +368,10 @@ export class Blockchain { getLibs: () => this.libs, setLibs: (value: Cell | undefined) => (this.libs = value), getAutoDeployLibs: () => this.autoDeployLibs, + // TODO: add one async hook onTransactions, and blockchain class should subscribe registerTxsForCoverage: (txs) => this.registerTxsForCoverage(txs), addTransaction: (transaction: BlockchainTransaction) => this.transactions.push(transaction), - publishTransactions: (txs) => this.publishTransactions(txs), + publishTransactions: (txs) => this.webSocketManager.publishTransactions(txs), }); } @@ -839,6 +838,7 @@ export class Blockchain { */ public enableCoverage(enable: boolean = true) { this.collectCoverage = enable; + this.verbosity.print = false; this.verbosity.vmLogs = 'vm_logs_verbose'; } @@ -919,119 +919,6 @@ export class Blockchain { return new Coverage(mergeCoverages(...coverages)); } - protected async publishTransactions(txs: BlockchainTransaction[]) { - if (!this.useWebsocket) { - return; - } - - const testName = expect === undefined ? '' : expect.getState().currentTestName; - const transactions = this.serializeTransactions(txs); - const contracts = await this.contractsData(); - - // This solution, requiring a reconnection for each sending, may seem inefficient. - // An alternative would be to establish a single connection when creating the Blockchain, - // but in that case, it's unclear when this connection should be closed. - // Until the connection is closed, the Node process will not terminate, and Jest will issue - // a warning, but the test will continue to wait for the connection to be closed. - // - // This solution does not have this problem since the connection is closed immediately after sending. - // The reconnection time, meanwhile, is short enough to not be noticeable during tests. - await this.websocketConnect(); - websocketSend(this.ws, { type: 'test-data', testName, transactions, contracts }); - this.websocketDisconnect(); - } - - private async contractsData(): Promise { - return Promise.all( - this.storage.knownContracts().map(async (contract): Promise => { - const state = contract.accountState; - const stateInit = beginCell(); - if (state?.type === 'active') { - stateInit.store(storeStateInit(state.state)); - } - const stateInitCell = stateInit.asCell(); - - const account = contract.account; - const accountCell = beginCell().store(storeShardAccount(account)).endCell(); - - return { - address: contract.address.toString(), - meta: this.meta?.get(contract.address), - stateInit: - stateInitCell.bits.length === 0 - ? undefined - : (stateInitCell.toBoc().toString('hex') as HexString), - account: accountCell.toBoc().toString('hex') as HexString, - }; - }), - ); - } - - /** - * Convert the `BlockchainTransaction` array to `RawTransactionsInfo` that can be safely sent over network. - * @param transactions Input transactions to serialize - */ - protected serializeTransactions(transactions: BlockchainTransaction[]): RawTransactionsInfo { - return { - transactions: transactions.map((t): RawTransactionInfo => { - const tx = beginCell() - .store(storeTransaction(t as Transaction)) - .endCell() - .toBoc() - .toString('hex'); - - return { - transaction: tx, - blockchainLogs: t.blockchainLogs, - vmLogs: t.vmLogs, - debugLogs: t.debugLogs, - code: undefined, - sourceMap: undefined, - contractName: undefined, - parentId: t.parent?.lt.toString(), - childrenIds: t.children?.map((c) => c?.lt?.toString()), - oldStorage: t.oldStorage?.toBoc().toString('hex') as HexString | undefined, - newStorage: t.newStorage?.toBoc().toString('hex') as HexString | undefined, - callStack: t.callStack, - }; - }), - }; - } - - protected async websocketConnect(): Promise { - await this.websocketConnectOrThrow().catch(() => { - // eslint-disable-next-line no-console - console.warn( - 'Unable to connect to websocket server. Make sure the port and host match the sandbox server or VS Code settings. You can set the WebSocket address globally with `SANDBOX_WEBSOCKET_ADDR=ws://localhost:7743` or via `Blockchain.create({ connectionOptions: { host: "localhost", port: 7743 } })`.', - ); - }); - } - - protected async websocketConnectOrThrow(): Promise { - if (this.ws !== undefined) return; - return new Promise((resolve, reject) => { - const addr = - process.env.SANDBOX_WEBSOCKET_ADDR ?? - `ws://${this.connectionOptions.host ?? 'localhost'}:${this.connectionOptions.port ?? '7743'}`; - this.ws = new WebSocket(addr); - - this.ws.on('open', () => { - resolve(); - }); - - this.ws.on('error', (error) => { - reject(error); - }); - }); - } - - protected websocketDisconnect(): void { - if (this.ws) { - this.ws.close(); - this.ws = undefined; - } - } - /** * Creates instance of sandbox blockchain. * @@ -1059,22 +946,27 @@ export class Blockchain { storage?: BlockchainStorage; meta?: ContractsMeta; autoDeployLibs?: boolean; - useWebsocket?: boolean; - connectionOptions?: ConnectionOptions; + websocketOptions?: WebsocketOptions; }) { - const useWebsocket = opts?.useWebsocket ?? process.env['SANDBOX_USE_WEBSOCKET'] === 'true'; + const websocketEnabled = + opts?.websocketOptions?.enabled ?? getOptionalEnv('SANDBOX_WEBSOCKET_ENABLED', 'boolean'); const blockchain = new Blockchain({ executor: opts?.executor ?? (await Executor.create()), storage: opts?.storage ?? new LocalBlockchainStorage(), meta: opts?.meta ?? requireOptional('@ton/test-utils')?.contractsMeta, ...opts, + websocketOptions: { + enabled: websocketEnabled, + ...opts?.websocketOptions, + }, }); - if (useWebsocket) { + + if (websocketEnabled) { blockchain.verbosity.print = false; blockchain.verbosity.vmLogs = 'vm_logs_verbose'; - await blockchain.websocketConnect(); } + return blockchain; } } diff --git a/src/utils/environment.ts b/src/utils/environment.ts index 41ab001..c6bc92a 100644 --- a/src/utils/environment.ts +++ b/src/utils/environment.ts @@ -2,3 +2,25 @@ export function isBrowser(): boolean { // eslint-disable-next-line no-undef return typeof window !== 'undefined' && typeof window.document !== 'undefined'; } + +const converters = { + boolean: (value: string | undefined) => { + if (!value) return; + + return value.toLowerCase() === 'true'; + }, + string: (value: string | undefined) => value, +} as const; + +type Converters = typeof converters; + +export function getOptionalEnv( + envName: string, + envType?: EnvType, +): ReturnType { + if (!process || !process.env) return undefined as ReturnType; + + const converter = envType ? converters[envType] : converters.string; + + return converter(process.env[envName]) as ReturnType; +} diff --git a/src/utils/noop.ts b/src/utils/noop.ts new file mode 100644 index 0000000..024d996 --- /dev/null +++ b/src/utils/noop.ts @@ -0,0 +1,4 @@ +export function noop(): void {} +export function noopPromise(): Promise { + return Promise.resolve(); +} diff --git a/src/websocket/WebSocketManager.ts b/src/websocket/WebSocketManager.ts new file mode 100644 index 0000000..e4f13a2 --- /dev/null +++ b/src/websocket/WebSocketManager.ts @@ -0,0 +1,139 @@ +import WebSocket from 'ws'; +import { Address, beginCell, storeShardAccount, storeStateInit, storeTransaction, Transaction } from '@ton/core'; + +import { BlockchainTransaction } from '../blockchain/Blockchain'; +import { + HexString, + RawContractData, + RawTransactionInfo, + RawTransactionsInfo, + websocketSend, +} from './transport-websocket'; +import { ContractMeta } from '../meta/ContractsMeta'; +import { SmartContract } from '../blockchain/SmartContract'; + +// eslint-disable-next-line no-undef +declare const expect: jest.Expect; + +export interface IWebSocketManager { + publishTransactions(transactions: BlockchainTransaction[]): Promise; +} + +export type ConnectionOptions = { port?: number; host?: string }; + +export class WebSocketManager implements IWebSocketManager { + protected ws: WebSocket | undefined = undefined; + + constructor( + private readonly connectionOptions: ConnectionOptions = { port: 7743, host: 'localhost' }, + private readonly blockchain: { + getMeta(address: Address): ContractMeta | undefined; + knownContracts(): SmartContract[]; + }, + ) {} + + async publishTransactions(txs: BlockchainTransaction[]) { + const testName = expect === undefined ? '' : expect.getState().currentTestName; + const transactions = this.serializeTransactions(txs); + const contracts = this.contractsData(); + + // This solution, requiring a reconnection for each sending, may seem inefficient. + // An alternative would be to establish a single connection when creating the Blockchain, + // but in that case, it's unclear when this connection should be closed. + // Until the connection is closed, the Node process will not terminate, and Jest will issue + // a warning, but the test will continue to wait for the connection to be closed. + // + // This solution does not have this problem since the connection is closed immediately after sending. + // The reconnection time, meanwhile, is short enough to not be noticeable during tests. + await this.websocketConnect(); + websocketSend(this.ws, { type: 'test-data', testName, transactions, contracts }); + this.websocketDisconnect(); + } + + /** + * Convert the `BlockchainTransaction` array to `RawTransactionsInfo` that can be safely sent over network. + * @param transactions Input transactions to serialize + */ + protected serializeTransactions(transactions: BlockchainTransaction[]): RawTransactionsInfo { + return { + transactions: transactions.map((t): RawTransactionInfo => { + const tx = beginCell() + .store(storeTransaction(t as Transaction)) + .endCell() + .toBoc() + .toString('hex'); + + return { + transaction: tx, + blockchainLogs: t.blockchainLogs, + vmLogs: t.vmLogs, + debugLogs: t.debugLogs, + code: undefined, + sourceMap: undefined, + contractName: undefined, + parentId: t.parent?.lt.toString(), + childrenIds: t.children?.map((c) => c?.lt?.toString()), + oldStorage: t.oldStorage?.toBoc().toString('hex') as HexString | undefined, + newStorage: t.newStorage?.toBoc().toString('hex') as HexString | undefined, + callStack: t.callStack, + }; + }), + }; + } + + protected async websocketConnect(): Promise { + await this.websocketConnectOrThrow().catch(() => { + // eslint-disable-next-line no-console + console.warn( + 'Unable to connect to websocket server. Make sure the port and host match the sandbox server or VS Code settings. You can set the WebSocket address globally with `SANDBOX_WEBSOCKET_ADDR=ws://localhost:7743` or via `Blockchain.create({ connectionOptions: { host: "localhost", port: 7743 } })`.', + ); + }); + } + + private contractsData(): RawContractData[] { + return this.blockchain.knownContracts().map((contract): RawContractData => { + const state = contract.accountState; + const stateInit = beginCell(); + if (state?.type === 'active') { + stateInit.store(storeStateInit(state.state)); + } + const stateInitCell = stateInit.asCell(); + + const account = contract.account; + const accountCell = beginCell().store(storeShardAccount(account)).endCell(); + + return { + address: contract.address.toString(), + meta: this.blockchain.getMeta(contract.address), + stateInit: + stateInitCell.bits.length === 0 ? undefined : (stateInitCell.toBoc().toString('hex') as HexString), + account: accountCell.toBoc().toString('hex') as HexString, + }; + }); + } + + protected async websocketConnectOrThrow(): Promise { + if (this.ws !== undefined) return; + return new Promise((resolve, reject) => { + const addr = + process.env.SANDBOX_WEBSOCKET_ADDR ?? + `ws://${this.connectionOptions.host ?? 'localhost'}:${this.connectionOptions.port ?? '7743'}`; + this.ws = new WebSocket(addr); + + this.ws.on('open', () => { + resolve(); + }); + + this.ws.on('error', (error) => { + reject(error); + }); + }); + } + + protected websocketDisconnect(): void { + if (this.ws) { + this.ws.close(); + this.ws = undefined; + } + } +} diff --git a/src/blockchain/transport-websocket.ts b/src/websocket/transport-websocket.ts similarity index 100% rename from src/blockchain/transport-websocket.ts rename to src/websocket/transport-websocket.ts From 6ceb27041ce8ce9d82ca0481a54d5465151dd9c0 Mon Sep 17 00:00:00 2001 From: Alejandbel Date: Wed, 29 Oct 2025 00:53:35 +0300 Subject: [PATCH 08/10] feature: almost done sandbox ui --- eslint.config.js | 15 +- package.json | 5 +- src/blockchain/Blockchain.ts | 53 ++++--- src/blockchain/MessageQueueManager.ts | 10 +- src/jest/uiSetup.ts | 35 +++++ src/ui/UIManager.ts | 35 +++++ src/ui/connection/UIConnector.ts | 3 + .../websocket/ManagedWebSocketConnector.ts | 41 ++++++ .../websocket/OneTimeWebSocketConnector.ts | 36 +++++ src/ui/connection/websocket/types.ts | 4 + src/ui/protocol.md | 69 +++++++++ src/ui/protocol.ts | 92 ++++++++++++ src/utils/environment.ts | 4 +- src/utils/noop.ts | 3 - src/websocket/WebSocketManager.ts | 139 ------------------ src/websocket/transport-websocket.ts | 77 ---------- yarn.lock | 34 +---- 17 files changed, 371 insertions(+), 284 deletions(-) create mode 100644 src/jest/uiSetup.ts create mode 100644 src/ui/UIManager.ts create mode 100644 src/ui/connection/UIConnector.ts create mode 100644 src/ui/connection/websocket/ManagedWebSocketConnector.ts create mode 100644 src/ui/connection/websocket/OneTimeWebSocketConnector.ts create mode 100644 src/ui/connection/websocket/types.ts create mode 100644 src/ui/protocol.md create mode 100644 src/ui/protocol.ts delete mode 100644 src/websocket/WebSocketManager.ts delete mode 100644 src/websocket/transport-websocket.ts diff --git a/eslint.config.js b/eslint.config.js index 7a06fa5..cbe3274 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,3 +1,16 @@ const base = require('@ton/toolchain'); +const globals = require('globals'); -module.exports = [...base, { ignores: ['src/executor/emulator-emscripten*'] }]; +module.exports = [ + ...base, + { ignores: ['src/executor/emulator-emscripten*'] }, + { + files: ['src/jest/**/*.ts', 'src/jest/**/*.tsx'], + languageOptions: { + globals: { + ...globals.jest, + ...globals.node, + }, + }, + }, +]; diff --git a/package.json b/package.json index 1272042..d232aa8 100644 --- a/package.json +++ b/package.json @@ -25,8 +25,8 @@ "@ton/toolchain": "the-ton-tech/toolchain#v1.4.0", "@types/jest": "^30.0.0", "@types/node": "^24.1.0", - "@types/ws": "^8.5.10", "eslint": "^9.28.0", + "globals": "^16.4.0", "jest": "^30.0.5", "jest-config": "^30.0.5", "jest-environment-node": "^30.0.5", @@ -65,7 +65,6 @@ "chalk": "^4.1.2", "fflate": "^0.8.2", "table": "^6.9.0", - "ton-assembly": "0.1.2", - "ws": "^8.18.2" + "ton-assembly": "0.1.2" } } diff --git a/src/blockchain/Blockchain.ts b/src/blockchain/Blockchain.ts index e41fd9b..adf4161 100644 --- a/src/blockchain/Blockchain.ts +++ b/src/blockchain/Blockchain.ts @@ -34,16 +34,19 @@ import { internal } from '../utils/message'; import { slimConfig } from '../config/slimConfig'; import { testSubwalletId } from '../utils/testTreasurySubwalletId'; import { collectMetric } from '../metric/collectMetric'; -import { ContractMeta, ContractsMeta } from '../meta/ContractsMeta'; +import { ContractsMeta } from '../meta/ContractsMeta'; import { deepcopy } from '../utils/deepcopy'; import { collectAsmCoverage, collectTxsCoverage, mergeCoverages, Coverage } from '../coverage'; import { MessageQueueManager } from './MessageQueueManager'; import { AsyncLock } from '../utils/AsyncLock'; import { BlockchainSnapshot } from './BlockchainSnapshot'; import { requireOptional } from '../utils/require'; -import { ConnectionOptions, IWebSocketManager, WebSocketManager } from '../websocket/WebSocketManager'; -import { noop, noopPromise } from '../utils/noop'; +import { noop } from '../utils/noop'; import { getOptionalEnv } from '../utils/environment'; +import { IUIConnector } from '../ui/connection/UIConnector'; +import { WebSocketConnectionOptions } from '../ui/connection/websocket/types'; +import { OneTimeWebSocketConnector } from '../ui/connection/websocket/OneTimeWebSocketConnector'; +import { IUIManager, UIManager } from '../ui/UIManager'; const CREATE_WALLETS_PREFIX = 'CREATE_WALLETS'; @@ -178,7 +181,7 @@ export type SendMessageIterParams = MessageParams & { allowParallel?: boolean; }; -export type WebsocketOptions = { enabled?: boolean } & ConnectionOptions; +export type UIOptions = { enabled?: boolean; connector?: IUIConnector } & WebSocketConnectionOptions; export class Blockchain { protected lock = new AsyncLock(); @@ -205,7 +208,7 @@ export class Blockchain { protected transactions: BlockchainTransaction[] = []; protected defaultQueueManager: MessageQueueManager; - protected webSocketManager: IWebSocketManager; + protected uiManager: IUIManager; protected collectCoverage: boolean = false; protected readonly coverageTransactions: BlockchainTransaction[][] = []; @@ -330,9 +333,7 @@ export class Blockchain { storage: BlockchainStorage; meta?: ContractsMeta; autoDeployLibs?: boolean; - websocketOptions?: { - enabled?: boolean; - } & ConnectionOptions; + uiOptions?: UIOptions; }) { this.networkConfig = blockchainConfigToBase64(opts.config); this.executor = opts.executor; @@ -341,20 +342,18 @@ export class Blockchain { this.autoDeployLibs = opts.autoDeployLibs ?? false; this.defaultQueueManager = this.createQueueManager(); - this.webSocketManager = this.createWebSocketManager(opts.websocketOptions); + this.uiManager = this.createUiManager(opts.uiOptions); } - protected createWebSocketManager( - opts?: { - enabled?: boolean; - } & ConnectionOptions, - ): IWebSocketManager { + protected createUiManager(opts?: UIOptions): IUIManager { if (!opts?.enabled) { // noop implementation - return { publishTransactions: noopPromise }; + return { publishTransactions: noop }; } - return new WebSocketManager(opts, { + const connector = opts.connector ?? new OneTimeWebSocketConnector(opts); + + return new UIManager(connector, { getMeta: (address) => this.meta?.get(address), knownContracts: () => this.storage.knownContracts(), }); @@ -368,10 +367,11 @@ export class Blockchain { getLibs: () => this.libs, setLibs: (value: Cell | undefined) => (this.libs = value), getAutoDeployLibs: () => this.autoDeployLibs, - // TODO: add one async hook onTransactions, and blockchain class should subscribe - registerTxsForCoverage: (txs) => this.registerTxsForCoverage(txs), - addTransaction: (transaction: BlockchainTransaction) => this.transactions.push(transaction), - publishTransactions: (txs) => this.webSocketManager.publishTransactions(txs), + onTransactions: (txs) => { + this.registerTxsForCoverage(txs); + this.uiManager.publishTransactions(txs); + }, + onTransaction: (transaction: BlockchainTransaction) => this.transactions.push(transaction), }); } @@ -946,23 +946,22 @@ export class Blockchain { storage?: BlockchainStorage; meta?: ContractsMeta; autoDeployLibs?: boolean; - websocketOptions?: WebsocketOptions; + uiOptions?: UIOptions; }) { - const websocketEnabled = - opts?.websocketOptions?.enabled ?? getOptionalEnv('SANDBOX_WEBSOCKET_ENABLED', 'boolean'); + const uiEnabled = opts?.uiOptions?.enabled ?? getOptionalEnv('SANDBOX_UI_ENABLED', 'boolean'); const blockchain = new Blockchain({ executor: opts?.executor ?? (await Executor.create()), storage: opts?.storage ?? new LocalBlockchainStorage(), meta: opts?.meta ?? requireOptional('@ton/test-utils')?.contractsMeta, ...opts, - websocketOptions: { - enabled: websocketEnabled, - ...opts?.websocketOptions, + uiOptions: { + enabled: uiEnabled, + ...opts?.uiOptions, }, }); - if (websocketEnabled) { + if (uiEnabled) { blockchain.verbosity.print = false; blockchain.verbosity.vmLogs = 'vm_logs_verbose'; } diff --git a/src/blockchain/MessageQueueManager.ts b/src/blockchain/MessageQueueManager.ts index 083f6ed..f713824 100644 --- a/src/blockchain/MessageQueueManager.ts +++ b/src/blockchain/MessageQueueManager.ts @@ -28,9 +28,8 @@ export class MessageQueueManager { getLibs(): Cell | undefined; setLibs(libs: Cell | undefined): void; getAutoDeployLibs(): boolean; - registerTxsForCoverage(txs: BlockchainTransaction[]): void; - publishTransactions(txs: BlockchainTransaction[]): Promise; - addTransaction(transaction: BlockchainTransaction): void; + onTransactions(txs: BlockchainTransaction[]): void; + onTransaction(transaction: BlockchainTransaction): void; }, ) {} @@ -102,8 +101,7 @@ export class MessageQueueManager { return result; }); - await this.blockchain.publishTransactions(results); - this.blockchain.registerTxsForCoverage(results); + this.blockchain.onTransactions(results); return results; } @@ -142,7 +140,7 @@ export class MessageQueueManager { }; transaction.parent?.children.push(transaction); - this.blockchain.addTransaction(transaction); + this.blockchain.onTransaction(transaction); result = transaction; done = true; diff --git a/src/jest/uiSetup.ts b/src/jest/uiSetup.ts new file mode 100644 index 0000000..4befd08 --- /dev/null +++ b/src/jest/uiSetup.ts @@ -0,0 +1,35 @@ +import { Blockchain } from '../blockchain/Blockchain'; +import { ManagedWebSocketConnector } from '../ui/connection/websocket/ManagedWebSocketConnector'; + +let websocketConnector: ManagedWebSocketConnector | undefined; + +beforeAll(() => { + const originalCreate = Blockchain.create.bind(Blockchain); + + Blockchain.create = async (...args): Promise => { + let [opts, ...otherArgs] = args; + + if (!opts?.uiOptions?.connector) { + if (!websocketConnector) { + websocketConnector = new ManagedWebSocketConnector(opts?.uiOptions); + console.log('Connecting to websocket connector...'); + await websocketConnector.connect(); + } + + opts = { + ...opts, + uiOptions: { + ...opts?.uiOptions, + enabled: true, + connector: websocketConnector, + }, + }; + } + + return await originalCreate(opts, ...otherArgs); + }; +}); + +afterAll(() => { + websocketConnector?.disconnect(); +}); diff --git a/src/ui/UIManager.ts b/src/ui/UIManager.ts new file mode 100644 index 0000000..9b49d3c --- /dev/null +++ b/src/ui/UIManager.ts @@ -0,0 +1,35 @@ +import { Address } from '@ton/core'; + +import { BlockchainTransaction } from '../blockchain/Blockchain'; +import { ContractMeta } from '../meta/ContractsMeta'; +import { SmartContract } from '../blockchain/SmartContract'; +import { serializeTransactions, serializeContracts } from './protocol'; +import { IUIConnector } from './connection/UIConnector'; + +// eslint-disable-next-line no-undef +declare const expect: jest.Expect; + +export interface IUIManager { + publishTransactions(transactions: BlockchainTransaction[]): void; +} + +export class UIManager implements IUIManager { + constructor( + private readonly connector: IUIConnector, + private readonly blockchain: { + getMeta(address: Address): ContractMeta | undefined; + knownContracts(): SmartContract[]; + }, + ) {} + + publishTransactions(txs: BlockchainTransaction[]) { + const testName = expect === undefined ? '' : expect.getState().currentTestName; + const transactions = serializeTransactions(txs); + const knownContracts = this.blockchain.knownContracts(); + const contracts = serializeContracts( + knownContracts.map((contract) => ({ contract, meta: this.blockchain.getMeta(contract.address) })), + ); + + this.connector.send(JSON.stringify({ type: 'test-data', testName, transactions, contracts })); + } +} diff --git a/src/ui/connection/UIConnector.ts b/src/ui/connection/UIConnector.ts new file mode 100644 index 0000000..1f064b4 --- /dev/null +++ b/src/ui/connection/UIConnector.ts @@ -0,0 +1,3 @@ +export interface IUIConnector { + send(data: string): void; +} diff --git a/src/ui/connection/websocket/ManagedWebSocketConnector.ts b/src/ui/connection/websocket/ManagedWebSocketConnector.ts new file mode 100644 index 0000000..4e43aac --- /dev/null +++ b/src/ui/connection/websocket/ManagedWebSocketConnector.ts @@ -0,0 +1,41 @@ +import { getOptionalEnv } from '../../../utils/environment'; +import { WebSocketConnectionOptions } from './types'; +import { IUIConnector } from '../UIConnector'; + +// TODO: reconnects? handshake? +export class ManagedWebSocketConnector implements IUIConnector { + private readonly websocketAddress: string; + + ws: WebSocket | undefined; + + constructor({ wsPort = 7743, wsHost = 'localhost' }: WebSocketConnectionOptions = {}) { + this.websocketAddress = + getOptionalEnv('SANDBOX_WEBSOCKET_ADDR') ?? `ws://${wsHost ?? 'localhost'}:${wsPort ?? '7743'}`; + } + + async connect() { + try { + this.ws = await new Promise((resolve, reject) => { + const ws = new WebSocket(this.websocketAddress); + ws.addEventListener('open', () => resolve(ws)); + ws.addEventListener('error', reject); + }); + } catch (err) { + console.warn( + 'Unable to connect to websocket server. Make sure the port and host match the sandbox server or VS Code settings. You can set the WebSocket address globally with `SANDBOX_WEBSOCKET_ADDR=ws://localhost:7743` or via `Blockchain.create({ uiOptions: { enabled: true, wsHost: "localhost", wsPort: 7743 } })`.', + err, + ); + } + } + + disconnect() { + this.ws?.close(); + this.ws = undefined; + } + + send(data: string): void { + if (this?.ws?.readyState === WebSocket.OPEN) { + this.ws.send(data); + } + } +} diff --git a/src/ui/connection/websocket/OneTimeWebSocketConnector.ts b/src/ui/connection/websocket/OneTimeWebSocketConnector.ts new file mode 100644 index 0000000..05133bc --- /dev/null +++ b/src/ui/connection/websocket/OneTimeWebSocketConnector.ts @@ -0,0 +1,36 @@ +import { getOptionalEnv } from '../../../utils/environment'; +import { WebSocketConnectionOptions } from './types'; +import { IUIConnector } from '../UIConnector'; + +export class OneTimeWebSocketConnector implements IUIConnector { + private readonly websocketAddress: string; + + constructor({ wsPort = 7743, wsHost = 'localhost' }: WebSocketConnectionOptions) { + this.websocketAddress = + getOptionalEnv('SANDBOX_WEBSOCKET_ADDR') ?? `ws://${wsHost ?? 'localhost'}:${wsPort ?? '7743'}`; + } + + send(data: string): void { + // TODO: refine a little + // This solution, requiring a reconnection for each sending, may seem inefficient. + // But in case when not jest used, it's unclear when this connection should be closed. + // Until the connection is closed, the Node process will not terminate, and Jest will issue + // a warning, but the test will continue to wait for the connection to be closed. + // + // This solution does not have this problem since the connection is closed immediately after sending. + // The reconnection time, meanwhile, is short enough to not be noticeable during tests. + const ws = new WebSocket(this.websocketAddress); + + ws.addEventListener('open', () => { + ws.send(data); + ws.close(); + }); + + ws.addEventListener('error', () => { + console.warn( + 'Unable to connect to websocket server. Make sure the port and host match the sandbox server or VS Code settings. You can set the WebSocket address globally with `SANDBOX_WEBSOCKET_ADDR=ws://localhost:7743` or via `Blockchain.create({ uiOptions: { enabled: true, host: "localhost", port: 7743 } })`.', + ); + ws.close(); + }); + } +} diff --git a/src/ui/connection/websocket/types.ts b/src/ui/connection/websocket/types.ts new file mode 100644 index 0000000..2ded3ab --- /dev/null +++ b/src/ui/connection/websocket/types.ts @@ -0,0 +1,4 @@ +export type WebSocketConnectionOptions = { + wsPort?: number; + wsHost?: string; +}; diff --git a/src/ui/protocol.md b/src/ui/protocol.md new file mode 100644 index 0000000..a9f7c58 --- /dev/null +++ b/src/ui/protocol.md @@ -0,0 +1,69 @@ +# UI Protocol + +TODO: describe +SANDBOX_UI_ENABLED + +## Connection + +### Websocket +- **Port**: 7743 (default) +- **Host**: localhost (default) +- **Environment Variable**: `SANDBOX_WEBSOCKET_ADDR` (overrides default) + +## Message Types + +### 1. Test Data Message +```typescript +{ + "type": "test-data", + "testName": string | undefined, + "transactions": RawTransactionsInfo, + "contracts": RawContractData[] +} +``` + +## Data Formats + +### RawTransactionInfo +```typescript +{ + "transaction": string, // Hex-encoded BOC transaction + "blockchainLogs": string, // Blockchain execution logs + "vmLogs": string, // VM execution logs + "debugLogs": string, // Debug output logs + "code": string | undefined, // Contract code (hex) + "sourceMap": object | undefined, // Source mapping + "contractName": string | undefined, // Contract name + "parentId": string | undefined, // Parent transaction ID + "childrenIds": string[], // Child transaction IDs + "oldStorage": string | undefined, // Old storage state (hex) + "newStorage": string | undefined, // New storage state (hex) + "callStack": string | undefined // Function call stack +} +``` + +### RawTransactionsInfo +``` +{ + "transactions": RawTransactionInfo[] +} +``` + +### RawContractData +``` +{ + "address": string, // Contract address + "meta": ContractMeta | undefined, // Contract metadata + "stateInit": string | undefined, // State init (hex BOC) + "account": string // Account data (hex BOC) +} +``` + +### ContractMeta +``` +{ + "wrapperName": string | undefined; + "abi": ContractABI | undefined; + "treasurySeed": string | undefined; +} +``` diff --git a/src/ui/protocol.ts b/src/ui/protocol.ts new file mode 100644 index 0000000..af9f06b --- /dev/null +++ b/src/ui/protocol.ts @@ -0,0 +1,92 @@ +import { beginCell, storeShardAccount, storeStateInit, storeTransaction, Transaction } from '@ton/core'; + +import { BlockchainTransaction } from '../blockchain/Blockchain'; +import { ContractMeta } from '../meta/ContractsMeta'; +import { SmartContract } from '../blockchain/SmartContract'; + +declare const hexBrand: unique symbol; +export type HexString = string & { readonly [hexBrand]: true }; + +export type MessageTestData = { + readonly type: 'test-data'; + readonly testName: string | undefined; + readonly transactions: RawTransactionsInfo; + readonly contracts: readonly RawContractData[]; +}; + +export type Message = MessageTestData; + +export type RawTransactionsInfo = { + readonly transactions: readonly RawTransactionInfo[]; +}; + +export type RawTransactionInfo = { + readonly transaction: string; + readonly blockchainLogs: string; + readonly vmLogs: string; + readonly debugLogs: string; + readonly code: string | undefined; + readonly sourceMap: object | undefined; + readonly contractName: string | undefined; + readonly parentId: string | undefined; + readonly childrenIds: string[]; + readonly oldStorage: HexString | undefined; + readonly newStorage: HexString | undefined; + readonly callStack: string | undefined; +}; + +export function serializeTransactions(transactions: BlockchainTransaction[]): RawTransactionsInfo { + return { + transactions: transactions.map((t): RawTransactionInfo => { + const tx = beginCell() + .store(storeTransaction(t as Transaction)) + .endCell() + .toBoc() + .toString('hex'); + + return { + transaction: tx, + blockchainLogs: t.blockchainLogs, + vmLogs: t.vmLogs, + debugLogs: t.debugLogs, + code: undefined, + sourceMap: undefined, + contractName: undefined, + parentId: t.parent?.lt.toString(), + childrenIds: t.children?.map((c) => c?.lt?.toString()), + oldStorage: t.oldStorage?.toBoc().toString('hex') as HexString | undefined, + newStorage: t.newStorage?.toBoc().toString('hex') as HexString | undefined, + callStack: t.callStack, + }; + }), + }; +} + +export type RawContractData = { + readonly address: string; + readonly meta: ContractMeta | undefined; + readonly stateInit: HexString | undefined; + readonly account: HexString | undefined; +}; + +export function serializeContracts(contracts: { contract: SmartContract; meta?: ContractMeta }[]): RawContractData[] { + return contracts.map(({ contract, meta }): RawContractData => { + const state = contract.accountState; + const stateInit = beginCell(); + if (state?.type === 'active') { + stateInit.store(storeStateInit(state.state)); + } + const stateInitCell = stateInit.asCell(); + + const account = contract.account; + const accountCell = beginCell().store(storeShardAccount(account)).endCell(); + + return { + address: contract.address.toString(), + meta, + stateInit: + stateInitCell.bits.length === 0 ? undefined : (stateInitCell.toBoc().toString('hex') as HexString), + account: accountCell.toBoc().toString('hex') as HexString, + }; + }); +} diff --git a/src/utils/environment.ts b/src/utils/environment.ts index c6bc92a..3286fa7 100644 --- a/src/utils/environment.ts +++ b/src/utils/environment.ts @@ -14,10 +14,10 @@ const converters = { type Converters = typeof converters; -export function getOptionalEnv( +export function getOptionalEnv( envName: string, envType?: EnvType, -): ReturnType { +): ReturnType | undefined { if (!process || !process.env) return undefined as ReturnType; const converter = envType ? converters[envType] : converters.string; diff --git a/src/utils/noop.ts b/src/utils/noop.ts index 024d996..89966dd 100644 --- a/src/utils/noop.ts +++ b/src/utils/noop.ts @@ -1,4 +1 @@ export function noop(): void {} -export function noopPromise(): Promise { - return Promise.resolve(); -} diff --git a/src/websocket/WebSocketManager.ts b/src/websocket/WebSocketManager.ts deleted file mode 100644 index e4f13a2..0000000 --- a/src/websocket/WebSocketManager.ts +++ /dev/null @@ -1,139 +0,0 @@ -import WebSocket from 'ws'; -import { Address, beginCell, storeShardAccount, storeStateInit, storeTransaction, Transaction } from '@ton/core'; - -import { BlockchainTransaction } from '../blockchain/Blockchain'; -import { - HexString, - RawContractData, - RawTransactionInfo, - RawTransactionsInfo, - websocketSend, -} from './transport-websocket'; -import { ContractMeta } from '../meta/ContractsMeta'; -import { SmartContract } from '../blockchain/SmartContract'; - -// eslint-disable-next-line no-undef -declare const expect: jest.Expect; - -export interface IWebSocketManager { - publishTransactions(transactions: BlockchainTransaction[]): Promise; -} - -export type ConnectionOptions = { port?: number; host?: string }; - -export class WebSocketManager implements IWebSocketManager { - protected ws: WebSocket | undefined = undefined; - - constructor( - private readonly connectionOptions: ConnectionOptions = { port: 7743, host: 'localhost' }, - private readonly blockchain: { - getMeta(address: Address): ContractMeta | undefined; - knownContracts(): SmartContract[]; - }, - ) {} - - async publishTransactions(txs: BlockchainTransaction[]) { - const testName = expect === undefined ? '' : expect.getState().currentTestName; - const transactions = this.serializeTransactions(txs); - const contracts = this.contractsData(); - - // This solution, requiring a reconnection for each sending, may seem inefficient. - // An alternative would be to establish a single connection when creating the Blockchain, - // but in that case, it's unclear when this connection should be closed. - // Until the connection is closed, the Node process will not terminate, and Jest will issue - // a warning, but the test will continue to wait for the connection to be closed. - // - // This solution does not have this problem since the connection is closed immediately after sending. - // The reconnection time, meanwhile, is short enough to not be noticeable during tests. - await this.websocketConnect(); - websocketSend(this.ws, { type: 'test-data', testName, transactions, contracts }); - this.websocketDisconnect(); - } - - /** - * Convert the `BlockchainTransaction` array to `RawTransactionsInfo` that can be safely sent over network. - * @param transactions Input transactions to serialize - */ - protected serializeTransactions(transactions: BlockchainTransaction[]): RawTransactionsInfo { - return { - transactions: transactions.map((t): RawTransactionInfo => { - const tx = beginCell() - .store(storeTransaction(t as Transaction)) - .endCell() - .toBoc() - .toString('hex'); - - return { - transaction: tx, - blockchainLogs: t.blockchainLogs, - vmLogs: t.vmLogs, - debugLogs: t.debugLogs, - code: undefined, - sourceMap: undefined, - contractName: undefined, - parentId: t.parent?.lt.toString(), - childrenIds: t.children?.map((c) => c?.lt?.toString()), - oldStorage: t.oldStorage?.toBoc().toString('hex') as HexString | undefined, - newStorage: t.newStorage?.toBoc().toString('hex') as HexString | undefined, - callStack: t.callStack, - }; - }), - }; - } - - protected async websocketConnect(): Promise { - await this.websocketConnectOrThrow().catch(() => { - // eslint-disable-next-line no-console - console.warn( - 'Unable to connect to websocket server. Make sure the port and host match the sandbox server or VS Code settings. You can set the WebSocket address globally with `SANDBOX_WEBSOCKET_ADDR=ws://localhost:7743` or via `Blockchain.create({ connectionOptions: { host: "localhost", port: 7743 } })`.', - ); - }); - } - - private contractsData(): RawContractData[] { - return this.blockchain.knownContracts().map((contract): RawContractData => { - const state = contract.accountState; - const stateInit = beginCell(); - if (state?.type === 'active') { - stateInit.store(storeStateInit(state.state)); - } - const stateInitCell = stateInit.asCell(); - - const account = contract.account; - const accountCell = beginCell().store(storeShardAccount(account)).endCell(); - - return { - address: contract.address.toString(), - meta: this.blockchain.getMeta(contract.address), - stateInit: - stateInitCell.bits.length === 0 ? undefined : (stateInitCell.toBoc().toString('hex') as HexString), - account: accountCell.toBoc().toString('hex') as HexString, - }; - }); - } - - protected async websocketConnectOrThrow(): Promise { - if (this.ws !== undefined) return; - return new Promise((resolve, reject) => { - const addr = - process.env.SANDBOX_WEBSOCKET_ADDR ?? - `ws://${this.connectionOptions.host ?? 'localhost'}:${this.connectionOptions.port ?? '7743'}`; - this.ws = new WebSocket(addr); - - this.ws.on('open', () => { - resolve(); - }); - - this.ws.on('error', (error) => { - reject(error); - }); - }); - } - - protected websocketDisconnect(): void { - if (this.ws) { - this.ws.close(); - this.ws = undefined; - } - } -} diff --git a/src/websocket/transport-websocket.ts b/src/websocket/transport-websocket.ts deleted file mode 100644 index 79cec8d..0000000 --- a/src/websocket/transport-websocket.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { WebSocket } from 'ws'; - -import { ContractMeta } from '../meta/ContractsMeta'; - -declare const hexBrand: unique symbol; -export type HexString = string & { readonly [hexBrand]: true }; - -export interface RawContractData { - /** - * User-friendly representation of the contract address. - */ - readonly address: string; - /** - * Additional information about the contract. - */ - readonly meta: ContractMeta | undefined; - /** - * Hex-encoded state init of the contract. - */ - readonly stateInit: HexString | undefined; - /** - * Hex-encoded shard account info of the contract. - */ - readonly account: HexString | undefined; -} - -export interface MessageTestData { - readonly type: 'test-data'; - /** - * Name of the current running test or undefined. - */ - readonly testName: string | undefined; - /** - * All transactions in the chain. - */ - readonly transactions: RawTransactionsInfo; - /** - * Known contracts information. - */ - readonly contracts: readonly RawContractData[]; -} - -export interface RawTransactionsInfo { - readonly transactions: readonly RawTransactionInfo[]; -} - -export interface RawTransactionInfo { - readonly transaction: string; - readonly blockchainLogs: string; - readonly vmLogs: string; - readonly debugLogs: string; - readonly code: string | undefined; - readonly sourceMap: object | undefined; - readonly contractName: string | undefined; - readonly parentId: string | undefined; - readonly childrenIds: string[]; - readonly oldStorage: HexString | undefined; - readonly newStorage: HexString | undefined; - readonly callStack: string | undefined; -} - -export type Message = MessageTestData; - -export function websocketSend(ws: WebSocket | undefined, data: Message): void { - if (ws && ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify(data)); - } - - if (ws === undefined) { - // eslint-disable-next-line no-console - console.warn('Cannot send, Websocket is undefined!'); - } - if (ws && ws.readyState !== WebSocket.OPEN) { - // eslint-disable-next-line no-console - console.warn('Cannot send, Websocket is not open!'); - } -} diff --git a/yarn.lock b/yarn.lock index 973531e..01617ea 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1377,11 +1377,11 @@ __metadata: "@ton/toolchain": "the-ton-tech/toolchain#v1.4.0" "@types/jest": "npm:^30.0.0" "@types/node": "npm:^24.1.0" - "@types/ws": "npm:^8.5.10" "@vscode/debugadapter": "npm:^1.68.0" chalk: "npm:^4.1.2" eslint: "npm:^9.28.0" fflate: "npm:^0.8.2" + globals: "npm:^16.4.0" jest: "npm:^30.0.5" jest-config: "npm:^30.0.5" jest-environment-node: "npm:^30.0.5" @@ -1390,7 +1390,6 @@ __metadata: ts-jest: "npm:^29.4.1" ts-node: "npm:^10.9.1" typescript: "npm:^5.8.3" - ws: "npm:^8.18.2" peerDependencies: "@ton-community/func-js": ">=0.10.0" "@ton/core": ">=0.61.0" @@ -1633,15 +1632,6 @@ __metadata: languageName: node linkType: hard -"@types/ws@npm:^8.5.10": - version: 8.18.1 - resolution: "@types/ws@npm:8.18.1" - dependencies: - "@types/node": "npm:*" - checksum: 10/1ce05e3174dcacf28dae0e9b854ef1c9a12da44c7ed73617ab6897c5cbe4fccbb155a20be5508ae9a7dde2f83bd80f5cf3baa386b934fc4b40889ec963e94f3a - languageName: node - linkType: hard - "@types/yargs-parser@npm:*": version: 21.0.3 resolution: "@types/yargs-parser@npm:21.0.3" @@ -3708,6 +3698,13 @@ __metadata: languageName: node linkType: hard +"globals@npm:^16.4.0": + version: 16.4.0 + resolution: "globals@npm:16.4.0" + checksum: 10/1627a9f42fb4c82d7af6a0c8b6cd616e00110908304d5f1ddcdf325998f3aed45a4b29d8a1e47870f328817805263e31e4f1673f00022b9c2b210552767921cf + languageName: node + linkType: hard + "globalthis@npm:^1.0.4": version: 1.0.4 resolution: "globalthis@npm:1.0.4" @@ -6857,21 +6854,6 @@ __metadata: languageName: node linkType: hard -"ws@npm:^8.18.2": - version: 8.18.3 - resolution: "ws@npm:8.18.3" - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: ">=5.0.2" - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - checksum: 10/725964438d752f0ab0de582cd48d6eeada58d1511c3f613485b5598a83680bedac6187c765b0fe082e2d8cc4341fc57707c813ae780feee82d0c5efe6a4c61b6 - languageName: node - linkType: hard - "y18n@npm:^5.0.5": version: 5.0.8 resolution: "y18n@npm:5.0.8" From 0eb99656fc0c4414e76911a8de810376431d770d Mon Sep 17 00:00:00 2001 From: Alejandbel Date: Wed, 29 Oct 2025 12:47:59 +0300 Subject: [PATCH 09/10] feature: complete sandbox ui --- src/jest/uiSetup.ts | 11 +++++ src/ui/UIManager.ts | 24 ++++++++-- .../websocket/ManagedWebSocketConnector.ts | 24 +++++----- .../websocket/OneTimeWebSocketConnector.ts | 44 +++++++++++-------- src/ui/connection/websocket/constants.ts | 4 ++ src/ui/protocol.md | 16 +++++-- src/ui/protocol.ts | 8 +++- 7 files changed, 94 insertions(+), 37 deletions(-) create mode 100644 src/ui/connection/websocket/constants.ts diff --git a/src/jest/uiSetup.ts b/src/jest/uiSetup.ts index 4befd08..fd8d446 100644 --- a/src/jest/uiSetup.ts +++ b/src/jest/uiSetup.ts @@ -1,3 +1,5 @@ +import { randomUUID } from 'crypto'; + import { Blockchain } from '../blockchain/Blockchain'; import { ManagedWebSocketConnector } from '../ui/connection/websocket/ManagedWebSocketConnector'; @@ -12,6 +14,7 @@ beforeAll(() => { if (!opts?.uiOptions?.connector) { if (!websocketConnector) { websocketConnector = new ManagedWebSocketConnector(opts?.uiOptions); + // eslint-disable-next-line no-console console.log('Connecting to websocket connector...'); await websocketConnector.connect(); } @@ -30,6 +33,14 @@ beforeAll(() => { }; }); +beforeEach(() => { + expect.setState({ testId: randomUUID() }); +}); + +afterEach(() => { + expect.setState({ testId: undefined }); +}); + afterAll(() => { websocketConnector?.disconnect(); }); diff --git a/src/ui/UIManager.ts b/src/ui/UIManager.ts index 9b49d3c..b1b6129 100644 --- a/src/ui/UIManager.ts +++ b/src/ui/UIManager.ts @@ -3,11 +3,11 @@ import { Address } from '@ton/core'; import { BlockchainTransaction } from '../blockchain/Blockchain'; import { ContractMeta } from '../meta/ContractsMeta'; import { SmartContract } from '../blockchain/SmartContract'; -import { serializeTransactions, serializeContracts } from './protocol'; +import { serializeTransactions, serializeContracts, TestInfo, MessageTestData } from './protocol'; import { IUIConnector } from './connection/UIConnector'; // eslint-disable-next-line no-undef -declare const expect: jest.Expect; +declare const expect: jest.Expect | undefined; export interface IUIManager { publishTransactions(transactions: BlockchainTransaction[]): void; @@ -23,13 +23,29 @@ export class UIManager implements IUIManager { ) {} publishTransactions(txs: BlockchainTransaction[]) { - const testName = expect === undefined ? '' : expect.getState().currentTestName; + let testInfo = this.getTestInfo(); const transactions = serializeTransactions(txs); const knownContracts = this.blockchain.knownContracts(); const contracts = serializeContracts( knownContracts.map((contract) => ({ contract, meta: this.blockchain.getMeta(contract.address) })), ); - this.connector.send(JSON.stringify({ type: 'test-data', testName, transactions, contracts })); + this.connector.send( + JSON.stringify({ type: 'test-data', testInfo, transactions, contracts } satisfies MessageTestData), + ); + } + + private getTestInfo(): TestInfo | undefined { + if (expect === undefined) { + return; + } + + const expectState = expect.getState(); + + return { + id: expectState.testId, + name: expectState.currentTestName, + path: expectState.testPath, + }; } } diff --git a/src/ui/connection/websocket/ManagedWebSocketConnector.ts b/src/ui/connection/websocket/ManagedWebSocketConnector.ts index 4e43aac..a4cce32 100644 --- a/src/ui/connection/websocket/ManagedWebSocketConnector.ts +++ b/src/ui/connection/websocket/ManagedWebSocketConnector.ts @@ -1,30 +1,34 @@ import { getOptionalEnv } from '../../../utils/environment'; import { WebSocketConnectionOptions } from './types'; import { IUIConnector } from '../UIConnector'; +import { CONNECT_ERROR_MESSAGE, DEFAULT_HOST, DEFAULT_PORT } from './constants'; -// TODO: reconnects? handshake? export class ManagedWebSocketConnector implements IUIConnector { private readonly websocketAddress: string; ws: WebSocket | undefined; - constructor({ wsPort = 7743, wsHost = 'localhost' }: WebSocketConnectionOptions = {}) { - this.websocketAddress = - getOptionalEnv('SANDBOX_WEBSOCKET_ADDR') ?? `ws://${wsHost ?? 'localhost'}:${wsPort ?? '7743'}`; + constructor({ wsPort = DEFAULT_PORT, wsHost = DEFAULT_HOST }: WebSocketConnectionOptions = {}) { + this.websocketAddress = getOptionalEnv('SANDBOX_WEBSOCKET_ADDR') ?? `ws://${wsHost}:${wsPort}`; } async connect() { try { this.ws = await new Promise((resolve, reject) => { const ws = new WebSocket(this.websocketAddress); - ws.addEventListener('open', () => resolve(ws)); - ws.addEventListener('error', reject); + ws.addEventListener('open', () => resolve(ws), { once: true }); + ws.addEventListener( + 'error', + (err) => { + ws.close(); + reject(err); + }, + { once: true }, + ); }); } catch (err) { - console.warn( - 'Unable to connect to websocket server. Make sure the port and host match the sandbox server or VS Code settings. You can set the WebSocket address globally with `SANDBOX_WEBSOCKET_ADDR=ws://localhost:7743` or via `Blockchain.create({ uiOptions: { enabled: true, wsHost: "localhost", wsPort: 7743 } })`.', - err, - ); + // eslint-disable-next-line no-console + console.warn(CONNECT_ERROR_MESSAGE, err); } } diff --git a/src/ui/connection/websocket/OneTimeWebSocketConnector.ts b/src/ui/connection/websocket/OneTimeWebSocketConnector.ts index 05133bc..ded7ea3 100644 --- a/src/ui/connection/websocket/OneTimeWebSocketConnector.ts +++ b/src/ui/connection/websocket/OneTimeWebSocketConnector.ts @@ -1,36 +1,42 @@ import { getOptionalEnv } from '../../../utils/environment'; import { WebSocketConnectionOptions } from './types'; import { IUIConnector } from '../UIConnector'; +import { CONNECT_ERROR_MESSAGE, DEFAULT_HOST, DEFAULT_PORT } from './constants'; export class OneTimeWebSocketConnector implements IUIConnector { private readonly websocketAddress: string; - constructor({ wsPort = 7743, wsHost = 'localhost' }: WebSocketConnectionOptions) { - this.websocketAddress = - getOptionalEnv('SANDBOX_WEBSOCKET_ADDR') ?? `ws://${wsHost ?? 'localhost'}:${wsPort ?? '7743'}`; + constructor({ wsPort = DEFAULT_PORT, wsHost = DEFAULT_HOST }: WebSocketConnectionOptions) { + this.websocketAddress = getOptionalEnv('SANDBOX_WEBSOCKET_ADDR') ?? `ws://${wsHost}:${wsPort}`; } send(data: string): void { - // TODO: refine a little - // This solution, requiring a reconnection for each sending, may seem inefficient. - // But in case when not jest used, it's unclear when this connection should be closed. + // This solution, which requires a reconnection for each send, may seem inefficient. + // However, when Jest is not used, it’s unclear when the connection should be closed. // Until the connection is closed, the Node process will not terminate, and Jest will issue - // a warning, but the test will continue to wait for the connection to be closed. + // a warning, while the test will continue to wait for the connection to close. // - // This solution does not have this problem since the connection is closed immediately after sending. - // The reconnection time, meanwhile, is short enough to not be noticeable during tests. + // This solution avoids that problem, as the connection is closed immediately after sending. + // Meanwhile, the reconnection time is short enough to be unnoticeable during tests. const ws = new WebSocket(this.websocketAddress); - ws.addEventListener('open', () => { - ws.send(data); - ws.close(); - }); + ws.addEventListener( + 'open', + () => { + ws.send(data); + ws.close(); + }, + { once: true }, + ); - ws.addEventListener('error', () => { - console.warn( - 'Unable to connect to websocket server. Make sure the port and host match the sandbox server or VS Code settings. You can set the WebSocket address globally with `SANDBOX_WEBSOCKET_ADDR=ws://localhost:7743` or via `Blockchain.create({ uiOptions: { enabled: true, host: "localhost", port: 7743 } })`.', - ); - ws.close(); - }); + ws.addEventListener( + 'error', + (err) => { + // eslint-disable-next-line no-console + console.warn(CONNECT_ERROR_MESSAGE, err); + ws.close(); + }, + { once: true }, + ); } } diff --git a/src/ui/connection/websocket/constants.ts b/src/ui/connection/websocket/constants.ts new file mode 100644 index 0000000..eecc3de --- /dev/null +++ b/src/ui/connection/websocket/constants.ts @@ -0,0 +1,4 @@ +export const DEFAULT_PORT = 7743; +export const DEFAULT_HOST = 'localhost'; +export const CONNECT_ERROR_MESSAGE = + 'Unable to connect to websocket server. Make sure the port and host match the sandbox server or VS Code settings. You can set the WebSocket address globally with `SANDBOX_WEBSOCKET_ADDR=ws://localhost:7743` or via `Blockchain.create({ uiOptions: { enabled: true, wsHost: "localhost", wsPort: 7743 } })`.'; diff --git a/src/ui/protocol.md b/src/ui/protocol.md index a9f7c58..47cabcd 100644 --- a/src/ui/protocol.md +++ b/src/ui/protocol.md @@ -1,7 +1,8 @@ # UI Protocol -TODO: describe -SANDBOX_UI_ENABLED +The **UI Protocol** defines the communication interface between the testing environment and the Sandbox UI. +When `SANDBOX_UI_ENABLED` is set, test data and blockchain state updates are sent to the UI for visualization and debugging. +This allows developers to inspect transactions, contract states, and logs in real time. ## Connection @@ -16,7 +17,7 @@ SANDBOX_UI_ENABLED ```typescript { "type": "test-data", - "testName": string | undefined, + "testInfo": TestInfo | undefined, "transactions": RawTransactionsInfo, "contracts": RawContractData[] } @@ -24,6 +25,15 @@ SANDBOX_UI_ENABLED ## Data Formats +### TestInfo +```typescript +{ + "id": string | undefined, + "name": string | undefined, + "path": string | undefined +} +``` + ### RawTransactionInfo ```typescript { diff --git a/src/ui/protocol.ts b/src/ui/protocol.ts index af9f06b..4f43c1d 100644 --- a/src/ui/protocol.ts +++ b/src/ui/protocol.ts @@ -9,7 +9,7 @@ export type HexString = string & { readonly [hexBrand]: true }; export type MessageTestData = { readonly type: 'test-data'; - readonly testName: string | undefined; + readonly testInfo: TestInfo | undefined; readonly transactions: RawTransactionsInfo; readonly contracts: readonly RawContractData[]; }; @@ -90,3 +90,9 @@ export function serializeContracts(contracts: { contract: SmartContract; meta?: }; }); } + +export type TestInfo = { + readonly id?: string; + readonly name?: string; + readonly path?: string; +}; From 4ffc26e820feacb6dd4bac0d64fd303bbe812e38 Mon Sep 17 00:00:00 2001 From: Alejandbel Date: Wed, 29 Oct 2025 12:54:18 +0300 Subject: [PATCH 10/10] chore: update changelog --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 374b54c..2526adf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Added + +- Sandbox UI support + ## [0.37.2] - 2025-09-23 ### Fixed