diff --git a/editors/code/src/assets/icons/test-icon.svg b/editors/code/src/assets/icons/test-icon.svg new file mode 100644 index 00000000..1062fa0f --- /dev/null +++ b/editors/code/src/assets/icons/test-icon.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + diff --git a/editors/code/src/commands/sandboxCommands.ts b/editors/code/src/commands/sandboxCommands.ts index c77776bb..e4c73ecd 100644 --- a/editors/code/src/commands/sandboxCommands.ts +++ b/editors/code/src/commands/sandboxCommands.ts @@ -2,8 +2,6 @@ // Copyright © 2025 TON Studio import vscode from "vscode" -import {ContractAbi} from "@shared/abi" - import {SandboxTreeProvider} from "../providers/sandbox/SandboxTreeProvider" import {SandboxActionsProvider, TransactionInfo} from "../providers/sandbox/SandboxActionsProvider" import {HistoryWebviewProvider} from "../providers/sandbox/HistoryWebviewProvider" @@ -14,7 +12,6 @@ import { deleteMessageTemplate, exportTrace, importTrace, - loadContractInfo, loadLatestOperationResult, OperationTrace, redeployContract, @@ -24,8 +21,6 @@ import { ShowTransactionDetailsCommand, } from "../webview-ui/src/views/actions/sandbox-actions-types" import {TransactionDetailsProvider} from "../providers/sandbox/TransactionDetailsProvider" -import {HexString} from "../common/hex-string" -import {Base64String} from "../common/base64-string" import {TransactionDetailsInfo} from "../common/types/transaction" import { detectPackageManager, @@ -428,35 +423,18 @@ export function registerSandboxCommands( const deployedContracts = treeProvider.getDeployedContracts() - let account: HexString | undefined - let stateInit: {code: Base64String; data: Base64String} | undefined - let abi: ContractAbi | undefined - let resultString = args.resultString - - try { - const contractInfo = await loadContractInfo(args.contractAddress) - if (contractInfo.success) { - account = contractInfo.data.account - stateInit = contractInfo.data.stateInit - abi = contractInfo.data.abi - } - } catch (error) { - vscode.window.showErrorMessage( - `Failed to fetch contract info from server: ${error}`, - ) - console.warn("Failed to fetch contract info from server:", error) - } - - if (!resultString) { + let serializedResult = args.serializedResult + if (!serializedResult) { + // For the "Transaction Details" button we need to load data, since we don't have it in the webview try { const latestOperationResult = await loadLatestOperationResult() - if (latestOperationResult.success) { - resultString = latestOperationResult.data.resultString - } else { - const message = `Failed to load latest operation result: ${latestOperationResult.error}` - vscode.window.showErrorMessage(message) - console.warn(message) + if (!latestOperationResult.success) { + throw new Error( + `Failed to load latest operation result: ${latestOperationResult.error}`, + ) } + + serializedResult = latestOperationResult.data.resultString } catch (error) { vscode.window.showErrorMessage( `Failed to fetch latest operation result from daemon: ${error}`, @@ -466,16 +444,8 @@ export function registerSandboxCommands( } const transaction: TransactionDetailsInfo = { - contractAddress: args.contractAddress, - methodName: args.methodName, - transactionId: args.transactionId, - timestamp: args.timestamp, - status: args.status, - resultString, + serializedResult, deployedContracts, - account, - stateInit, - abi, } transactionDetailsProvider.showTransactionDetails(transaction) @@ -483,8 +453,8 @@ export function registerSandboxCommands( ), vscode.commands.registerCommand( "ton.sandbox.addTransactionsToDetails", - (resultString: string): void => { - transactionDetailsProvider.addTransactions(resultString) + (serializedResult: string): void => { + transactionDetailsProvider.addTransactions(serializedResult) }, ), ) diff --git a/editors/code/src/common/call-stack-parser.test.ts b/editors/code/src/common/call-stack-parser.test.ts new file mode 100644 index 00000000..f04bd9b8 --- /dev/null +++ b/editors/code/src/common/call-stack-parser.test.ts @@ -0,0 +1,184 @@ +import {parseCallStack, CallStackEntry} from "./call-stack-parser" + +describe("Parse Call Stack", () => { + it("should parse empty call stack", () => { + expect(parseCallStack(undefined)).toEqual([]) + expect(parseCallStack("")).toEqual([]) + expect(parseCallStack("no stack traces")).toEqual([]) + }) + + it("should parse call stack from user example", () => { + const callStack = `Error: + at /root/node_modules/ton-sandbox-server-dev/dist/blockchain/Blockchain.js:294:24 + at AsyncLock.with (/root/node_modules/ton-sandbox-server-dev/dist/utils/AsyncLock.js:40:26) + at processTicksAndRejections (node:internal/process/task_queues:105:5) + at Blockchain.pushMessage (/root/node_modules/ton-sandbox-server-dev/dist/blockchain/Blockchain.js:291:9) + at BlockchainContractProvider.external (/root/node_modules/ton-sandbox-server-dev/dist/blockchain/BlockchainContractProvider.js:94:9) + at Object.send (/root/node_modules/ton-sandbox-server-dev/dist/treasury/Treasury.js:68:17) + at BlockchainContractProvider.internal (/root/node_modules/ton-sandbox-server-dev/dist/blockchain/BlockchainContractProvider.js:112:9) + at JettonWallet.sendTransfer (/root/wrappers/01_jetton/JettonWallet.ts:58:9) + at Proxy. (/root/node_modules/ton-sandbox-server-dev/dist/blockchain/Blockchain.js:652:39) + at Object. (/root/tests/01_jetton/JettonWallet.spec.ts:637:47) (at console. (file:///Applications/Visual%20Studio%20Code.app/Contents/Resources/app/out/vs/workbench/api/node/extensionHostProcess.js:201:30974))` + + const expected: CallStackEntry[] = [ + { + function: "", + file: "/root/node_modules/ton-sandbox-server-dev/dist/blockchain/Blockchain.js", + line: 294, + column: 24, + }, + { + function: "AsyncLock.with", + file: "/root/node_modules/ton-sandbox-server-dev/dist/utils/AsyncLock.js", + line: 40, + column: 26, + }, + { + function: "processTicksAndRejections", + file: "node:internal/process/task_queues", + line: 105, + column: 5, + }, + { + function: "Blockchain.pushMessage", + file: "/root/node_modules/ton-sandbox-server-dev/dist/blockchain/Blockchain.js", + line: 291, + column: 9, + }, + { + function: "BlockchainContractProvider.external", + file: "/root/node_modules/ton-sandbox-server-dev/dist/blockchain/BlockchainContractProvider.js", + line: 94, + column: 9, + }, + { + function: "Object.send", + file: "/root/node_modules/ton-sandbox-server-dev/dist/treasury/Treasury.js", + line: 68, + column: 17, + }, + { + function: "BlockchainContractProvider.internal", + file: "/root/node_modules/ton-sandbox-server-dev/dist/blockchain/BlockchainContractProvider.js", + line: 112, + column: 9, + }, + { + function: "JettonWallet.sendTransfer", + file: "/root/wrappers/01_jetton/JettonWallet.ts", + line: 58, + column: 9, + }, + { + function: "Proxy.", + file: "/root/node_modules/ton-sandbox-server-dev/dist/blockchain/Blockchain.js", + line: 652, + column: 39, + }, + { + function: "Object.", + file: "/root/tests/01_jetton/JettonWallet.spec.ts", + line: 637, + column: 47, + }, + ] + + const result = parseCallStack(callStack) + expect(result).toEqual(expected) + }) + + it("should parse simple function calls", () => { + const callStack = `Error: + at foo (bar.js:10:5) + at baz (qux.ts:20:15)` + + const expected: CallStackEntry[] = [ + { + function: "foo", + file: "bar.js", + line: 10, + column: 5, + }, + { + function: "baz", + file: "qux.ts", + line: 20, + column: 15, + }, + ] + + expect(parseCallStack(callStack)).toEqual(expected) + }) + + it("should parse calls without locations", () => { + const callStack = `Error: + at foo + at bar (baz.js:5:10) + at /some/path/file.js:42` + + const expected: CallStackEntry[] = [ + { + function: "foo", + }, + { + function: "bar", + file: "baz.js", + line: 5, + column: 10, + }, + { + function: "", + file: "/some/path/file.js", + line: 42, + }, + ] + + expect(parseCallStack(callStack)).toEqual(expected) + }) + + it("should parse calls with only file and line", () => { + const callStack = `Error: + at test.js:42 + at another.ts:100` + + const expected: CallStackEntry[] = [ + { + function: "", + file: "test.js", + line: 42, + }, + { + function: "", + file: "another.ts", + line: 100, + }, + ] + + expect(parseCallStack(callStack)).toEqual(expected) + }) + + it('should ignore lines that do not start with "at "', () => { + const callStack = `Error: Something went wrong + This is not a stack trace line + at foo (bar.js:1:2) + Another non-stack line + at baz (qux.ts:3:4)` + + const expected: CallStackEntry[] = [ + { + function: "foo", + file: "bar.js", + line: 1, + column: 2, + }, + { + function: "baz", + file: "qux.ts", + line: 3, + column: 4, + }, + ] + + expect(parseCallStack(callStack)).toEqual(expected) + }) +}) diff --git a/editors/code/src/common/call-stack-parser.ts b/editors/code/src/common/call-stack-parser.ts new file mode 100644 index 00000000..ceb74066 --- /dev/null +++ b/editors/code/src/common/call-stack-parser.ts @@ -0,0 +1,120 @@ +export interface CallStackEntry { + readonly function: string + readonly file?: string + readonly line?: number + readonly column?: number +} + +export function parseCallStack(callStack: string | undefined): CallStackEntry[] { + if (!callStack) { + return [] + } + + const lines = callStack.split("\n").filter(line => line.trim().startsWith("at ")) + const entries: CallStackEntry[] = [] + + for (const line of lines) { + const trimmed = line.trim() + const entry = parseStackLine(trimmed) + if (entry) { + entries.push(entry) + } + } + + return entries +} + +function parseStackLine(line: string): CallStackEntry | null { + const withoutAt = line.startsWith("at ") ? line.slice(3).trim() : line.trim() + + if (!withoutAt) { + return {function: ""} + } + + // Handle different formats: + // 1. function (file:line:column) + // 2. file:line:column + // 3. function + + const parenIndex = withoutAt.indexOf("(") + const lastParenIndex = withoutAt.lastIndexOf(")") + + if (parenIndex !== -1 && lastParenIndex !== -1 && lastParenIndex > parenIndex) { + // Format: function (file:line:column) [extra info] + const functionName = withoutAt.slice(0, parenIndex).trim() + const locationAndExtra = withoutAt.slice(parenIndex + 1, lastParenIndex).trim() + + const locationEnd = locationAndExtra.indexOf(" (") + const location = + locationEnd === -1 ? locationAndExtra : locationAndExtra.slice(0, locationEnd) + + const locationParts = parseLocation(location) + return { + function: functionName, + ...locationParts, + } + } else { + // Format: file:line:column or just function + // Check if it looks like a file path (contains / or \ or has extension or starts with known schemes) + const firstPart = withoutAt.split(":")[0] + if ( + withoutAt.includes("/") || + withoutAt.includes("\\") || + /\.\w+$/.test(firstPart) || + firstPart.includes("node:") || + firstPart.startsWith("file://") || + /^\w+:/.test(withoutAt) + ) { + const locationParts = parseLocation(withoutAt) + return { + function: "", + ...locationParts, + } + } else { + return { + function: withoutAt, + } + } + } +} + +function parseLocation(location: string): {file?: string; line?: number; column?: number} { + // Handle cases like: + // /path/to/file.js:123:45 + // node:internal/process/task_queues:105:5 + // C:\path\to\file.js:123:45 + + const parts = location.split(":") + if (parts.length >= 3) { + const columnStr = parts.at(-1) ?? "" + const lineStr = parts.at(-2) ?? "" + const column = Number.parseInt(columnStr, 10) + const line = Number.parseInt(lineStr, 10) + + if (!Number.isNaN(column) && !Number.isNaN(line)) { + const file = parts.slice(0, -2).join(":") + return { + file, + line, + column, + } + } + } + + if (parts.length >= 2) { + const lineStr = parts.at(-1) ?? "" + const line = Number.parseInt(lineStr, 10) + + if (!Number.isNaN(line)) { + const file = parts.slice(0, -1).join(":") + return { + file, + line, + } + } + } + + return { + file: location, + } +} diff --git a/editors/code/src/common/types/contract.ts b/editors/code/src/common/types/contract.ts index 447a2f6b..e0af8e25 100644 --- a/editors/code/src/common/types/contract.ts +++ b/editors/code/src/common/types/contract.ts @@ -1,4 +1,4 @@ -import {Address, ShardAccount, StateInit} from "@ton/core" +import {Address} from "@ton/core" import {SourceMap} from "ton-source-map" @@ -6,8 +6,6 @@ import {ContractAbi} from "@shared/abi" export interface ContractData { readonly address: Address - readonly stateInit: StateInit | undefined - readonly account: ShardAccount readonly letter: string readonly displayName: string readonly kind: "treasury" | "user-contract" diff --git a/editors/code/src/common/types/raw-transaction.ts b/editors/code/src/common/types/raw-transaction.ts index 10cf21c5..86321b17 100644 --- a/editors/code/src/common/types/raw-transaction.ts +++ b/editors/code/src/common/types/raw-transaction.ts @@ -43,6 +43,7 @@ export interface RawTransactionInfo { readonly childrenIds: string[] readonly oldStorage: HexString | undefined readonly newStorage: HexString | undefined + readonly callStack: string | undefined } // temp type only for building @@ -63,6 +64,7 @@ interface MutableTransactionInfo { readonly contractName: string | undefined readonly oldStorage: Cell | undefined readonly newStorage: Cell | undefined + readonly callStack: string | undefined parent: TransactionInfo | undefined children: TransactionInfo[] } @@ -141,6 +143,7 @@ const processRawTx = ( children: [], oldStorage: tx.oldStorage ? Cell.fromHex(tx.oldStorage) : undefined, newStorage: tx.newStorage ? Cell.fromHex(tx.newStorage) : undefined, + callStack: tx.callStack, } visited.set(tx, result) @@ -290,3 +293,28 @@ const computeFinalData = ( export const processRawTransactions = (txs: RawTransactionInfo[]): TransactionInfo[] => { return txs.map(tx => processRawTx(tx, txs, new Map())) } + +function parseTransactions(data: string): RawTransactions | undefined { + try { + return JSON.parse(data) as RawTransactions + } catch { + return undefined + } +} + +export function processTxString(serializedResult: string): TransactionInfo[] | undefined { + const rawTxs = parseTransactions(serializedResult) + if (!rawTxs) { + return undefined + } + + const parsedTransactions = rawTxs.transactions.map( + (it): RawTransactionInfo => ({ + ...it, + transaction: it.transaction, + parsedTransaction: loadTransaction(Cell.fromHex(it.transaction).asSlice()), + }), + ) + + return processRawTransactions(parsedTransactions) +} diff --git a/editors/code/src/common/types/transaction.ts b/editors/code/src/common/types/transaction.ts index bc21eefa..fcf785a0 100644 --- a/editors/code/src/common/types/transaction.ts +++ b/editors/code/src/common/types/transaction.ts @@ -1,27 +1,11 @@ import {type Address, Cell, type OutAction, type Transaction} from "@ton/core" import {SourceMap} from "ton-source-map" -import {ContractAbi} from "@shared/abi" - -import {HexString} from "../hex-string" -import {Base64String} from "../base64-string" - import {DeployedContract} from "./contract" export interface TransactionDetailsInfo { - readonly contractAddress: string - readonly methodName: string - readonly transactionId?: string - readonly timestamp: string - readonly status: "success" | "pending" | "failed" - readonly resultString?: string + readonly serializedResult?: string readonly deployedContracts?: readonly DeployedContract[] - readonly account?: HexString - readonly stateInit?: { - readonly code: Base64String - readonly data: Base64String - } - readonly abi?: ContractAbi } /** @@ -45,6 +29,7 @@ export interface TransactionInfo { readonly children: readonly TransactionInfo[] readonly oldStorage: Cell | undefined readonly newStorage: Cell | undefined + readonly callStack: string | undefined } export type TransactionInfoData = InternalTransactionInfoData | ExternalTransactionInfoData diff --git a/editors/code/src/extension.ts b/editors/code/src/extension.ts index 54fb042b..a5f3b66c 100644 --- a/editors/code/src/extension.ts +++ b/editors/code/src/extension.ts @@ -15,6 +15,7 @@ import { import { DocumentationAtPositionRequest, + GetWorkspaceContractsAbiResponse, GetContractAbiParams, GetContractAbiResponse, SetToolchainVersionNotification, @@ -22,6 +23,7 @@ import { TypeAtPositionParams, TypeAtPositionRequest, TypeAtPositionResponse, + WorkspaceContractInfo, } from "@shared/shared-msgtypes" import type {ClientOptions} from "@shared/config-scheme" @@ -37,14 +39,21 @@ import {BocEditorProvider} from "./providers/boc/BocEditorProvider" import {BocFileSystemProvider} from "./providers/boc/BocFileSystemProvider" import {BocDecompilerProvider} from "./providers/boc/BocDecompilerProvider" import {registerSaveBocDecompiledCommand} from "./commands/saveBocDecompiledCommand" -import {registerSandboxCommands} from "./commands/sandboxCommands" +import {registerSandboxCommands, openFileAtPosition} from "./commands/sandboxCommands" +import {parseCallStack} from "./common/call-stack-parser" + import {SandboxTreeProvider} from "./providers/sandbox/SandboxTreeProvider" import {SandboxActionsProvider} from "./providers/sandbox/SandboxActionsProvider" import {HistoryWebviewProvider} from "./providers/sandbox/HistoryWebviewProvider" import {TransactionDetailsProvider} from "./providers/sandbox/TransactionDetailsProvider" import {SandboxCodeLensProvider} from "./providers/sandbox/SandboxCodeLensProvider" +import {TestTreeProvider} from "./providers/sandbox/TestTreeProvider" +import {WebSocketServer} from "./providers/sandbox/WebSocketServer" import {configureDebugging} from "./debugging" +import {ContractData, TransactionRun} from "./providers/sandbox/test-types" +import {TransactionDetailsInfo} from "./common/types/transaction" +import {DeployedContract} from "./common/types/contract" let client: LanguageClient | undefined = undefined let cachedToolchainInfo: SetToolchainVersionParams | undefined = undefined @@ -90,6 +99,80 @@ export async function activate(context: vscode.ExtensionContext): Promise const transactionDetailsProvider = new TransactionDetailsProvider(context.extensionUri) + const testTreeProvider = new TestTreeProvider() + context.subscriptions.push( + vscode.window.createTreeView("tonTestResultsTree", { + treeDataProvider: testTreeProvider, + showCollapseAll: false, + }), + vscode.commands.registerCommand( + "ton.test.showTransactionDetails", + async (txRun: TransactionRun) => { + const normalizeName = (name: string): string => { + return name.toLowerCase().replace("-contract", "").replace(/[_-]/g, "") + } + // a bit hacky :) + const sameNameContract = ( + workspaceContract: WorkspaceContractInfo, + contract: ContractData | undefined, + ): boolean => { + const leftName = workspaceContract.name + const rightName = contract?.meta?.wrapperName ?? "" + return normalizeName(leftName) === normalizeName(rightName) + } + + const workspaceContracts = + await vscode.commands.executeCommand( + "tolk.getWorkspaceContractsAbi", + ) + + const transactionDetailsInfo: TransactionDetailsInfo = { + serializedResult: txRun.serializedResult, + deployedContracts: txRun.contracts.map((contract): DeployedContract => { + const workspaceContract = workspaceContracts.contracts.find( + workspaceContract => sameNameContract(workspaceContract, contract), + ) + return { + abi: workspaceContract?.abi, + sourceMap: undefined, + address: contract.address, + deployTime: undefined, + name: contract.meta?.wrapperName ?? "unknown", + sourceUri: workspaceContract?.path ?? "", + } + }), + } + + transactionDetailsProvider.showTransactionDetails(transactionDetailsInfo) + }, + ), + vscode.commands.registerCommand( + "ton.test.openTestSource", + (treeItem: {command?: vscode.Command}) => { + const txRun = treeItem.command?.arguments?.[0] as TransactionRun | undefined + if (!txRun) { + console.error("No txRun found in tree item arguments") + return + } + + const transactionWithCallStack = txRun.transactions.find(tx => tx.callStack) + if (!transactionWithCallStack?.callStack) return + + const parsedCallStack = parseCallStack(transactionWithCallStack.callStack) + if (parsedCallStack.length > 0) { + const lastEntry = parsedCallStack.at(-1) + if ( + lastEntry?.file && + lastEntry.line !== undefined && + lastEntry.column !== undefined + ) { + openFileAtPosition(lastEntry.file, lastEntry.line - 1, lastEntry.column - 1) + } + } + }, + ), + ) + const sandboxCodeLensProvider = new SandboxCodeLensProvider(sandboxTreeProvider) context.subscriptions.push( vscode.languages.registerCodeLensProvider({language: "tolk"}, sandboxCodeLensProvider), @@ -98,7 +181,18 @@ export async function activate(context: vscode.ExtensionContext): Promise sandboxTreeProvider.setActionsProvider(sandboxActionsProvider) sandboxTreeProvider.setCodeLensProvider(sandboxCodeLensProvider) + const websocketPort = vscode.workspace + .getConfiguration("ton.sandbox") + .get("websocketPort", 7743) + const wsServer = new WebSocketServer(testTreeProvider, websocketPort) + wsServer.start() + context.subscriptions.push( + { + dispose: () => { + wsServer.dispose() + }, + }, vscode.window.onDidChangeActiveTextEditor(editor => { if (editor) { const document = { @@ -128,6 +222,11 @@ export async function activate(context: vscode.ExtensionContext): Promise return client?.sendRequest("tolk.getContractAbi", params) }, ), + vscode.commands.registerCommand("tolk.getWorkspaceContractsAbi", async () => { + return client?.sendRequest( + "tolk.getWorkspaceContractsAbi", + ) + }), ...sandboxCommands, ) diff --git a/editors/code/src/providers/sandbox/TestTreeProvider.ts b/editors/code/src/providers/sandbox/TestTreeProvider.ts new file mode 100644 index 00000000..e868796f --- /dev/null +++ b/editors/code/src/providers/sandbox/TestTreeProvider.ts @@ -0,0 +1,203 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2025 TON Core +import * as vscode from "vscode" + +import {parseCallStack} from "../../common/call-stack-parser" +import {processTxString} from "../../common/types/raw-transaction" + +import {TestDataMessage, TransactionRun} from "./test-types" + +interface TestTreeItem { + readonly id: string + readonly label: string + readonly description?: string + readonly contextValue?: string + readonly iconPath?: vscode.ThemeIcon + readonly collapsibleState?: vscode.TreeItemCollapsibleState + readonly command?: vscode.Command + readonly type: "testName" | "testRun" | "message" +} + +export class TestTreeProvider implements vscode.TreeDataProvider { + private readonly _onDidChangeTreeData: vscode.EventEmitter = + new vscode.EventEmitter() + public readonly onDidChangeTreeData: vscode.Event = + this._onDidChangeTreeData.event + + private readonly txRunsByName: Map = new Map() + + public addTestData(data: TestDataMessage): void { + const transactions = processTxString(data.transactions) ?? [] + + const txRun: TransactionRun = { + id: `test-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`, + name: data.testName, + timestamp: Date.now(), + transactions: transactions, + contracts: data.contracts, + serializedResult: data.transactions, + } + + const existingRuns = this.txRunsByName.get(data.testName) ?? [] + const lastRun = existingRuns.at(-1) + + // Consider all transactions as a new test run after 20 seconds + if (lastRun && Date.now() - lastRun.timestamp > 20 * 1000) { + this.txRunsByName.set(data.testName, [txRun]) + } else { + existingRuns.push(txRun) + this.txRunsByName.set(data.testName, existingRuns) + } + + this._onDidChangeTreeData.fire(undefined) + } + + public getTreeItem(element: TestTreeItem): vscode.TreeItem { + return { + id: element.id, + label: element.label, + description: element.description, + contextValue: element.contextValue, + iconPath: element.iconPath, + collapsibleState: element.collapsibleState, + command: element.command, + } + } + + public getChildren(element?: TestTreeItem): Thenable { + if (!element) { + return Promise.resolve( + [...this.txRunsByName.keys()].map(testName => ({ + id: `test-group-${testName}`, + label: testName, + description: `${this.txRunsByName.get(testName)?.length ?? 0} transactions`, + contextValue: "testGroup", + iconPath: new vscode.ThemeIcon("beaker"), + collapsibleState: vscode.TreeItemCollapsibleState.Collapsed, + type: "testName" as const, + })), + ) + } + + if (element.type === "testName") { + const txRuns = this.txRunsByName.get(element.label) ?? [] + return Promise.all( + txRuns.map(async (testRun, index) => { + const extractedName = await extractTransactionName(testRun) + const exitCode = getTxRunExitCode(testRun) + const baseLabel = extractedName ?? `Transaction #${index}` + const label = exitCode === 0 ? baseLabel : `${baseLabel} (exit: ${exitCode})` + + return { + id: `${element.id}-run-${index}`, + label, + description: new Date(testRun.timestamp).toLocaleTimeString(), + contextValue: "testRun", + iconPath: + exitCode === 0 + ? new vscode.ThemeIcon( + "pass", + new vscode.ThemeColor("testing.iconPassed"), + ) + : new vscode.ThemeIcon( + "error", + new vscode.ThemeColor("testing.iconFailed"), + ), + collapsibleState: vscode.TreeItemCollapsibleState.None, + type: "testRun" as const, + command: { + command: "ton.test.showTransactionDetails", + title: "Show Test Run Details", + arguments: [testRun], + }, + } + }), + ) + } + + return Promise.resolve([]) + } +} + +async function extractTransactionName(txRun: TransactionRun): Promise { + const txWithCallStack = txRun.transactions.find(tx => tx.callStack) + if (!txWithCallStack?.callStack) { + return undefined + } + + const parsedCallStack = parseCallStack(txWithCallStack.callStack) + if (parsedCallStack.length === 0) { + return undefined + } + + const lastEntry = parsedCallStack.at(-1) + if (!lastEntry?.file || lastEntry.line === undefined) { + return undefined + } + + try { + const uri = vscode.Uri.file(lastEntry.file) + const document = await vscode.workspace.openTextDocument(uri) + const lines = document.getText().split("\n") + + const lineIndex = lastEntry.line - 1 + if (lineIndex < 0 || lineIndex >= lines.length) { + return undefined + } + + const line = lines[lineIndex].trim() + + const patterns = [ + // await object.sendMethod( + /await\s+(\w+)\.(\w+)\s*\(/, + // object.sendMethod( + /(\w+)\.(\w+)\s*\(/, + // sendMethod( + /(\w+)\s*\(/, + ] + + for (const pattern of patterns) { + const match = line.match(pattern) + if (match) { + if (match[2]) { + return `${match[1]}.${match[2]}` + } + return match[1] + } + } + + for (let i = 1; i <= 3; i++) { + const prevLineIndex = lineIndex - i + if (prevLineIndex >= 0) { + const prevLine = lines[prevLineIndex].trim() + for (const pattern of patterns) { + const match = prevLine.match(pattern) + if (match) { + if (match[2]) { + return `${match[1]}.${match[2]}` + } + return match[1] + } + } + } + } + } catch (error) { + console.error("Error reading file for transaction name:", error) + } + + return undefined +} + +function getTxRunExitCode(txRun: TransactionRun): number { + const failedTransaction = txRun.transactions.find(tx => { + if (tx.computeInfo === "skipped") return false + const exitCode = tx.computeInfo.exitCode + return exitCode !== 0 && exitCode !== 1 + }) + + if (!failedTransaction || failedTransaction.computeInfo === "skipped") { + return 0 + } + + return failedTransaction.computeInfo.exitCode +} diff --git a/editors/code/src/providers/sandbox/TransactionDetailsProvider.ts b/editors/code/src/providers/sandbox/TransactionDetailsProvider.ts index f535be7c..257a9a1b 100644 --- a/editors/code/src/providers/sandbox/TransactionDetailsProvider.ts +++ b/editors/code/src/providers/sandbox/TransactionDetailsProvider.ts @@ -68,11 +68,11 @@ export class TransactionDetailsProvider { } } - public addTransactions(resultString: string): void { + public addTransactions(serializedResult: string): void { if (this.panel) { void this.panel.webview.postMessage({ type: "addTransactions", - resultString, + serializedResult, }) } } diff --git a/editors/code/src/providers/sandbox/WebSocketServer.ts b/editors/code/src/providers/sandbox/WebSocketServer.ts new file mode 100644 index 00000000..a5cdb233 --- /dev/null +++ b/editors/code/src/providers/sandbox/WebSocketServer.ts @@ -0,0 +1,70 @@ +import * as vscode from "vscode" +import {WebSocket, Server} from "ws" + +import {TestDataMessage} from "./test-types" +import {TestTreeProvider} from "./TestTreeProvider" + +export class WebSocketServer { + private wss: Server | null = null + private disposables: vscode.Disposable[] = [] + + public constructor( + private readonly treeProvider: TestTreeProvider, + private readonly port: number, + ) {} + + public start(): void { + try { + this.wss = new Server({port: this.port}) + + this.wss.on("connection", (ws: WebSocket) => { + ws.on("message", (data: Buffer) => { + try { + const message = JSON.parse(data.toString()) as TestDataMessage + + this.handleTestData(message) + } catch (error) { + console.error("Failed to parse WebSocket message:", error) + } + }) + + ws.on("close", () => { + console.log("Blockchain WebSocket disconnected") + }) + + ws.on("error", error => { + console.error("WebSocket error:", error) + }) + }) + + this.wss.on("error", error => { + console.error("WebSocket Server error:", error) + }) + + console.log(`WebSocket server started on port ${this.port}`) + } catch (error) { + console.error("Failed to start WebSocket server:", error) + } + } + + private handleTestData(message: TestDataMessage): void { + this.treeProvider.addTestData(message) + } + + public stop(): void { + if (this.wss) { + this.wss.close() + this.wss = null + console.log("WebSocket server stopped") + } + + this.disposables.forEach(d => { + d.dispose() + }) + this.disposables = [] + } + + public dispose(): void { + this.stop() + } +} diff --git a/editors/code/src/providers/sandbox/test-types.ts b/editors/code/src/providers/sandbox/test-types.ts new file mode 100644 index 00000000..d984d662 --- /dev/null +++ b/editors/code/src/providers/sandbox/test-types.ts @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2025 TON Core + +import {ContractABI as BadContractABI} from "@ton/core" + +import {TransactionInfo} from "../../common/types/transaction" +import {HexString} from "../../common/hex-string" + +export interface ContractMeta { + readonly wrapperName?: string + readonly abi?: BadContractABI | null + readonly treasurySeed?: string +} + +export interface ContractData { + readonly address: string + readonly stateInit?: HexString + readonly account?: HexString + readonly meta?: ContractMeta +} + +export interface TestDataMessage { + readonly $: "test-data" + readonly testName: string + readonly transactions: string + readonly contracts: readonly ContractData[] +} + +export interface TransactionRun { + readonly id: string + readonly name: string + readonly timestamp: number + readonly serializedResult: string + readonly transactions: readonly TransactionInfo[] + readonly contracts: readonly ContractData[] +} diff --git a/editors/code/src/webview-ui/src/views/actions/ActionsApp.tsx b/editors/code/src/webview-ui/src/views/actions/ActionsApp.tsx index 792cc40b..434cbbfe 100644 --- a/editors/code/src/webview-ui/src/views/actions/ActionsApp.tsx +++ b/editors/code/src/webview-ui/src/views/actions/ActionsApp.tsx @@ -123,13 +123,9 @@ export default function ActionsApp({vscode}: Props): JSX.Element { }) }} handleShowTransactionDetails={tx => { + // serializedResult and deployedContracts will be loaded on demand vscode.postMessage({ type: "showTransactionDetails", - contractAddress: tx.contractAddress, - methodName: tx.methodName, - transactionId: tx.transactionId, - timestamp: tx.timestamp, - status: "success", }) }} result={ diff --git a/editors/code/src/webview-ui/src/views/details/TransactionDetails.tsx b/editors/code/src/webview-ui/src/views/details/TransactionDetails.tsx index 7c22de3f..33972364 100644 --- a/editors/code/src/webview-ui/src/views/details/TransactionDetails.tsx +++ b/editors/code/src/webview-ui/src/views/details/TransactionDetails.tsx @@ -1,13 +1,9 @@ import React, {JSX, useEffect, useMemo, useState} from "react" -import {Address, Cell, loadShardAccount, loadTransaction} from "@ton/core" +import {Address} from "@ton/core" import {TransactionDetailsInfo, TransactionInfo} from "../../../../common/types/transaction" -import { - processRawTransactions, - RawTransactionInfo, - RawTransactions, -} from "../../../../common/types/raw-transaction" +import {processTxString} from "../../../../common/types/raw-transaction" import {ContractData} from "../../../../common/types/contract" import {LoadingSpinner} from "../../components/common" @@ -25,7 +21,7 @@ interface Message { interface AddTransactionsMessage { readonly type: "addTransactions" - readonly resultString: string + readonly serializedResult: string } interface Props { @@ -36,41 +32,18 @@ export default function TransactionDetails({vscode}: Props): JSX.Element { const [transaction, setTransaction] = useState(undefined) const [transactions, setTransactions] = useState(undefined) - const parsedAccount = useMemo(() => { - if (!transaction?.account) return null - try { - return loadShardAccount(Cell.fromHex(transaction.account).asSlice()) - } catch (error) { - console.warn("Failed to parse account data:", error) - return null - } - }, [transaction?.account]) - - const parsedStateInit = useMemo(() => { - if (!transaction?.stateInit?.code || !transaction.stateInit.data) return undefined - try { - return { - code: Cell.fromBase64(transaction.stateInit.code), - data: Cell.fromBase64(transaction.stateInit.data), - } - } catch (error) { - console.warn("Failed to parse stateInit data:", error) - return undefined - } - }, [transaction?.stateInit?.code, transaction?.stateInit?.data]) - useMemo(() => { - if (!transaction || !transaction.resultString) return + if (!transaction || !transaction.serializedResult) return - const transactionInfos = processTxString(transaction.resultString) + const transactionInfos = processTxString(transaction.serializedResult) if (!transactionInfos) { return } setTransactions(transactionInfos) }, [transaction, setTransactions]) - const addTransactions = (resultString: string): void => { - const newTransactionInfos = processTxString(resultString) + const addTransactions = (serializedResult: string): void => { + const newTransactionInfos = processTxString(serializedResult) if (!newTransactionInfos) { return } @@ -97,7 +70,7 @@ export default function TransactionDetails({vscode}: Props): JSX.Element { if (message.type === "updateTransactionDetails") { setTransaction(message.transaction) } else { - addTransactions(message.resultString) + addTransactions(message.serializedResult) } } @@ -112,19 +85,16 @@ export default function TransactionDetails({vscode}: Props): JSX.Element { if (!transaction.deployedContracts) return [] return transaction.deployedContracts.flatMap((it, index) => { - if (!parsedAccount) return [] const letter = String.fromCodePoint(65 + (index % 26)) return { displayName: it.name, address: Address.parse(it.address), kind: it.name === "treasury" ? "treasury" : "user-contract", letter, - stateInit: parsedStateInit, - account: parsedAccount, abi: it.abi, } satisfies ContractData }) - }, [transaction, parsedAccount, parsedStateInit]) + }, [transaction]) if (!transaction) { return @@ -138,28 +108,3 @@ export default function TransactionDetails({vscode}: Props): JSX.Element { ) } - -function parseTransactions(data: string): RawTransactions | undefined { - try { - return JSON.parse(data) as RawTransactions - } catch { - return undefined - } -} - -function processTxString(resultString: string): TransactionInfo[] | undefined { - const rawTxs = parseTransactions(resultString) - if (!rawTxs) { - return undefined - } - - const parsedTransactions = rawTxs.transactions.map( - (it): RawTransactionInfo => ({ - ...it, - transaction: it.transaction, - parsedTransaction: loadTransaction(Cell.fromHex(it.transaction).asSlice()), - }), - ) - - return processRawTransactions(parsedTransactions) -} diff --git a/package.json b/package.json index 6165a87e..9662ce8a 100644 --- a/package.json +++ b/package.json @@ -248,6 +248,11 @@ "title": "Get Contract ABI", "category": "Tolk" }, + { + "command": "tolk.getWorkspaceContractsAbi", + "title": "Get Workspace Contracts ABI", + "category": "Tolk" + }, { "command": "tolk.executeHoverProvider", "title": "Get Documentation At Position", @@ -355,6 +360,12 @@ "category": "TON", "icon": "$(copy)" }, + { + "command": "ton.test.openTestSource", + "title": "Open Test Source", + "category": "TON", + "icon": "$(go-to-file)" + }, { "command": "ton.sandbox.installServer", "title": "Install TON Sandbox Server", @@ -399,6 +410,13 @@ "name": "History", "when": "true" } + ], + "tonTestContainer": [ + { + "id": "tonTestResultsTree", + "name": "Tests Tree", + "when": "true" + } ] }, "viewsContainers": { @@ -407,6 +425,11 @@ "id": "tonSandboxContainer", "title": "TON Sandbox", "icon": "$(rocket)" + }, + { + "id": "tonTestContainer", + "title": "TON Tests", + "icon": "./dist/icons/test-icon.svg" } ] }, @@ -432,6 +455,10 @@ { "command": "ton.sandbox.copyContractAddressFromTree", "when": "false" + }, + { + "command": "ton.test.openTestSource", + "when": "false" } ], "explorer/context": [ @@ -498,6 +525,11 @@ "command": "ton.sandbox.copyContractAddressFromTree", "when": "view == tonSandbox && viewItem == deployed-contract", "group": "inline" + }, + { + "command": "ton.test.openTestSource", + "when": "view == tonTestResultsTree && viewItem == testRun", + "group": "inline" } ] }, @@ -741,6 +773,11 @@ "type": "string", "default": "./node_modules/.bin/ton-sandbox-server", "description": "Path to the TON Sandbox server binary" + }, + "ton.sandbox.websocketPort": { + "type": "number", + "default": 7743, + "description": "Port for the WebSocket server used for test communication" } } } @@ -774,6 +811,12 @@ "type": "blueprint-build-and-test-all", "properties": {} } + ], + "viewsWelcome": [ + { + "view": "tonTestResultsTree", + "contents": "No test results yet. Run tests to see results here." + } ] }, "dependencies": { @@ -796,7 +839,8 @@ "vscode-languageserver": "^8.0.2", "vscode-languageserver-textdocument": "^1.0.7", "vscode-uri": "^3.0.7", - "web-tree-sitter": "^0.25.0" + "web-tree-sitter": "^0.25.0", + "ws": "^8.18.3" }, "devDependencies": { "@babel/core": "^7.26.0", @@ -809,6 +853,7 @@ "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", "@types/vscode": "^1.63.0", + "@types/ws": "^8", "@vscode/test-cli": "^0.0.10", "@vscode/test-electron": "^2.4.1", "@vscode/vsce": "^3.6.0", diff --git a/server/src/server.ts b/server/src/server.ts index 5f9735ef..7a624e87 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -15,7 +15,11 @@ import type {Node as SyntaxNode} from "web-tree-sitter" import {TextDocument} from "vscode-languageserver-textdocument" import {asParserPoint} from "@server/utils/position" -import {index as tolkIndex, IndexRoot as TolkIndexRoot} from "@server/languages/tolk/indexes" +import { + index as tolkIndex, + IndexKey, + IndexRoot as TolkIndexRoot, +} from "@server/languages/tolk/indexes" import {index as funcIndex, IndexRoot as FuncIndexRoot} from "@server/languages/func/indexes" import {globalVFS} from "@server/vfs/global" @@ -24,13 +28,16 @@ import type {ClientOptions} from "@shared/config-scheme" import { ContractAbiRequest, DocumentationAtPositionRequest, + GetAllContractsAbiRequest, GetContractAbiParams, GetContractAbiResponse, + GetWorkspaceContractsAbiResponse, SetToolchainVersionNotification, SetToolchainVersionParams, TypeAtPositionParams, TypeAtPositionRequest, TypeAtPositionResponse, + WorkspaceContractInfo, } from "@shared/shared-msgtypes" import {Logger} from "@server/utils/logger" import {clearDocumentSettings, getDocumentSettings, ServerSettings} from "@server/settings/settings" @@ -1130,6 +1137,48 @@ connection.onInitialize(async (initParams: lsp.InitializeParams): Promise { + const contracts: WorkspaceContractInfo[] = [] + + if (!workspaceFolders || workspaceFolders.length === 0) { + return {contracts: []} + } + + const workspaceIndexRoot = tolkIndex.roots.find(root => root.name === "workspace") + if (!workspaceIndexRoot) { + return {contracts: []} + } + + for (const [uri, fileIndex] of workspaceIndexRoot.files) { + const file = TOLK_PARSED_FILES_CACHE.get(uri) + if (!file) continue + + if (fileIndex.elementByName(IndexKey.Funcs, "onInternalMessage") === null) { + // not a root contract file + continue + } + + try { + const abi = contractAbi(file) + if (!abi) continue + + const filePath = fileURLToPath(uri) + const contractName = path.basename(filePath, path.extname(filePath)) + + contracts.push({ + name: contractName, + path: uri, + abi, + }) + } catch { + console.log(`Cannot get contract abi for ${file.uri}`) + // do nothing + } + } + + return {contracts} + }) + connection.onRequest(DocumentationAtPositionRequest, provideDocumentation) console.info("TON language server is ready!") diff --git a/shared/src/shared-msgtypes.ts b/shared/src/shared-msgtypes.ts index 37733492..97e2bdc7 100644 --- a/shared/src/shared-msgtypes.ts +++ b/shared/src/shared-msgtypes.ts @@ -8,6 +8,7 @@ export const TypeAtPositionRequest = "tolk.getTypeAtPosition" export const DocumentationAtPositionRequest = "tolk.executeHoverProvider" export const SetToolchainVersionNotification = "tolk.setToolchainVersion" export const ContractAbiRequest = "tolk.getContractAbi" +export const GetAllContractsAbiRequest = "tolk.getWorkspaceContractsAbi" export interface TypeAtPositionParams { readonly textDocument: { @@ -29,6 +30,16 @@ export interface GetContractAbiResponse { readonly abi: ContractAbi | undefined } +export interface GetWorkspaceContractsAbiResponse { + readonly contracts: WorkspaceContractInfo[] +} + +export interface WorkspaceContractInfo { + readonly name: string + readonly path: string + readonly abi: ContractAbi +} + export interface EnvironmentInfo { readonly nodeVersion?: string readonly platform: string diff --git a/yarn.lock b/yarn.lock index 4d023eab..4073b103 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2683,6 +2683,15 @@ __metadata: languageName: node linkType: hard +"@types/ws@npm:^8": + version: 8.18.1 + resolution: "@types/ws@npm:8.18.1" + dependencies: + "@types/node": "npm:*" + checksum: 10c0/61aff1129143fcc4312f083bc9e9e168aa3026b7dd6e70796276dcfb2c8211c4292603f9c4864fae702f2ed86e4abd4d38aa421831c2fd7f856c931a481afbab + languageName: node + linkType: hard + "@types/yargs-parser@npm:*": version: 21.0.3 resolution: "@types/yargs-parser@npm:21.0.3" @@ -12255,6 +12264,7 @@ __metadata: "@types/react": "npm:^18.3.3" "@types/react-dom": "npm:^18.3.0" "@types/vscode": "npm:^1.63.0" + "@types/ws": "npm:^8" "@vscode/debugadapter": "npm:^1.51.0" "@vscode/debugprotocol": "npm:^1.68.0" "@vscode/test-cli": "npm:^0.0.10" @@ -12308,6 +12318,7 @@ __metadata: web-tree-sitter: "npm:^0.25.0" webpack: "npm:^5.92.1" webpack-cli: "npm:^5.1.4" + ws: "npm:^8.18.3" peerDependencies: tree-sitter: ^0.21.1 dependenciesMeta: @@ -12641,6 +12652,21 @@ __metadata: languageName: node linkType: hard +"ws@npm:^8.18.3": + 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: 10c0/eac918213de265ef7cb3d4ca348b891a51a520d839aa51cdb8ca93d4fa7ff9f6ccb339ccee89e4075324097f0a55157c89fa3f7147bde9d8d7e90335dc087b53 + languageName: node + linkType: hard + "wsl-utils@npm:^0.1.0": version: 0.1.0 resolution: "wsl-utils@npm:0.1.0"