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"