diff --git a/benchmark/.gitignore b/benchmark/.gitignore index c795b054..61561495 100644 --- a/benchmark/.gitignore +++ b/benchmark/.gitignore @@ -1 +1,2 @@ -build \ No newline at end of file +build +file-sizes.json diff --git a/benchmark/__tests__/results-store.js b/benchmark/__tests__/results-store.js new file mode 100644 index 00000000..3dec9085 --- /dev/null +++ b/benchmark/__tests__/results-store.js @@ -0,0 +1,60 @@ +import fs from "fs"; +import path from "path"; + +const resultsFilePath = path.resolve("temp-all-test-results.json"); + +/** + * Reads existing test results from a JSON file if it exists. + * + * This function checks if the file specified by `resultsFilePath` exists. + * If the file is found, it reads the file's content and parses it as JSON. + * If the file does not exist, it returns an empty object. + * + * @returns {Object} The parsed JSON object from the file, or an empty object if the file does not exist. + */ +function readExistingResults() { + if (fs.existsSync(resultsFilePath)) { + try { + const fileContent = fs.readFileSync(resultsFilePath, "utf-8"); + return JSON.parse(fileContent); + } catch (error) { + console.error("Failed to read or parse results file:", error); + return {}; + } + } + return {}; +} + +/** + * Function to add test results to the report if the GENERATE_REPORT environment variable is set to "true" + * @param {string} testName - The name of the test. + * @param {Object} result - The test result object. + */ +export function addTestResults(testName, result) { + // Check if we need to generate a report + if (process.env.GENERATE_REPORT === "true") { + // Create a temporary object for the new test results + const tempResults = { + [testName]: result, + }; + + // Read existing results from the file + const existingResults = readExistingResults(); + + // Combine existing results with new test results + const combinedResults = { + ...existingResults, + ...tempResults, + }; + + try { + // Write the combined results to the file + fs.writeFileSync( + resultsFilePath, + JSON.stringify(combinedResults, null, 2) + ); + } catch (error) { + console.error("Failed to write results to file:", error); + } + } +} diff --git a/benchmark/__tests__/test-deploy-contract.ava.js b/benchmark/__tests__/test-deploy-contract.ava.js index 7f4d89e8..ae9d1309 100644 --- a/benchmark/__tests__/test-deploy-contract.ava.js +++ b/benchmark/__tests__/test-deploy-contract.ava.js @@ -1,11 +1,7 @@ import { Worker } from "near-workspaces"; import test from "ava"; -import { - formatGas, - gasBreakdown, - logGasBreakdown, - logGasDetail, -} from "./util.js"; +import { generateGasObject, logTestResults } from "./util.js"; +import { addTestResults } from "./results-store.js"; test.before(async (t) => { // Init the worker and start a Sandbox server @@ -47,8 +43,9 @@ test("JS promise batch deploy contract and call", async (t) => { let r = await bob.callRaw(callerContract, "deploy_contract", "", { gas: "300 Tgas", }); - // console.log(JSON.stringify(r, null, 2)); + let deployed = callerContract.getSubAccount("a"); + t.deepEqual(JSON.parse(Buffer.from(r.result.status.SuccessValue, "base64")), { currentAccountId: deployed.accountId, signerAccountId: bob.accountId, @@ -56,42 +53,11 @@ test("JS promise batch deploy contract and call", async (t) => { input: "abc", }); - t.log( - "Gas used to convert transaction to receipt: ", - formatGas(r.result.transaction_outcome.outcome.gas_burnt) - ); - t.log( - "Gas used to execute the receipt (actual contract call): ", - formatGas(r.result.receipts_outcome[0].outcome.gas_burnt) - ); - let map = gasBreakdown(r.result.receipts_outcome[0].outcome); - logGasBreakdown(map, t); - t.log( - "Gas used to execute the cross contract call: ", - formatGas(r.result.receipts_outcome[1].outcome.gas_burnt) - ); - map = gasBreakdown(r.result.receipts_outcome[1].outcome); - logGasBreakdown(map, t); - t.log( - "Gas used to refund unused gas for cross contract call: ", - formatGas(r.result.receipts_outcome[2].outcome.gas_burnt) - ); - t.log( - "Gas used to refund unused gas: ", - // TODO: fix after near-workspaces is updated - formatGas(r.result.receipts_outcome[3]?.outcome.gas_burnt || 0) - ); - t.log( - "Total gas used: ", - formatGas( - r.result.transaction_outcome.outcome.gas_burnt + - r.result.receipts_outcome[0].outcome.gas_burnt + - r.result.receipts_outcome[1].outcome.gas_burnt + - r.result.receipts_outcome[2].outcome.gas_burnt + - // TODO: fix after near-workspaces is updated - (r.result.receipts_outcome[3]?.outcome.gas_burnt || 0) - ) - ); + logTestResults(r); + + const gasObject = generateGasObject(r); + + addTestResults("JS_promise_batch_deploy_contract_and_call", gasObject); }); test("RS promise batch deploy contract and call", async (t) => { @@ -100,8 +66,9 @@ test("RS promise batch deploy contract and call", async (t) => { let r = await bob.callRaw(callerContractRs, "deploy_contract", "", { gas: "300 Tgas", }); - // console.log(JSON.stringify(r, null, 2)); + let deployed = callerContractRs.getSubAccount("a"); + t.deepEqual(JSON.parse(Buffer.from(r.result.status.SuccessValue, "base64")), { currentAccountId: deployed.accountId, signerAccountId: bob.accountId, @@ -109,40 +76,9 @@ test("RS promise batch deploy contract and call", async (t) => { input: "abc", }); - t.log( - "Gas used to convert transaction to receipt: ", - formatGas(r.result.transaction_outcome.outcome.gas_burnt) - ); - t.log( - "Gas used to execute the receipt (actual contract call): ", - formatGas(r.result.receipts_outcome[0].outcome.gas_burnt) - ); - let map = gasBreakdown(r.result.receipts_outcome[0].outcome); - logGasBreakdown(map, t); - t.log( - "Gas used to execute the cross contract call: ", - formatGas(r.result.receipts_outcome[1].outcome.gas_burnt) - ); - map = gasBreakdown(r.result.receipts_outcome[1].outcome); - logGasBreakdown(map, t); - t.log( - "Gas used to refund unused gas for cross contract call: ", - formatGas(r.result.receipts_outcome[2].outcome.gas_burnt) - ); - t.log( - "Gas used to refund unused gas: ", - // TODO: fix after near-workspaces is updated - formatGas(r.result.receipts_outcome[3]?.outcome.gas_burnt || 0) - ); - t.log( - "Total gas used: ", - formatGas( - r.result.transaction_outcome.outcome.gas_burnt + - r.result.receipts_outcome[0].outcome.gas_burnt + - r.result.receipts_outcome[1].outcome.gas_burnt + - r.result.receipts_outcome[2].outcome.gas_burnt + - // TODO: fix after near-workspaces is updated - (r.result.receipts_outcome[3]?.outcome.gas_burnt || 0) - ) - ); + logTestResults(r); + + const gasObject = generateGasObject(r); + + addTestResults("RS_promise_batch_deploy_contract_and_call", gasObject); }); diff --git a/benchmark/__tests__/test-expensive-calc.ava.js b/benchmark/__tests__/test-expensive-calc.ava.js index 5743227f..d3cfc96e 100644 --- a/benchmark/__tests__/test-expensive-calc.ava.js +++ b/benchmark/__tests__/test-expensive-calc.ava.js @@ -1,12 +1,13 @@ import { Worker } from "near-workspaces"; import test from "ava"; -import { logGasDetail } from "./util.js"; +import { generateGasObject, logTestResults } from "./util.js"; +import { addTestResults } from "./results-store.js"; test.before(async (t) => { // Init the worker and start a Sandbox server const worker = await Worker.init(); - // Prepare sandbox for tests, create accounts, deploy contracts, etx. + // Prepare sandbox for tests, create accounts, deploy contracts, etc. const root = worker.rootAccount; // Deploy the test contract. @@ -35,14 +36,25 @@ test("JS expensive contract, iterate 100 times", async (t) => { let r = await bob.callRaw(expensiveContract, "expensive", { n: 100 }); t.is(r.result.status.SuccessValue, "LTUw"); - logGasDetail(r, t); + + logTestResults(r); + + const gasObject = generateGasObject(r, true); + + addTestResults("JS_expensive_contract_100_times", gasObject); }); test("RS expensive contract. iterate 100 times", async (t) => { const { bob, expensiveContractRs } = t.context.accounts; let r = await bob.callRaw(expensiveContractRs, "expensive", { n: 100 }); + t.is(r.result.status.SuccessValue, "LTUw"); - logGasDetail(r, t); + + logTestResults(r); + + const gasObject = generateGasObject(r, true); + + addTestResults("RS_expensive_contract_100_times", gasObject); }); test("JS expensive contract, iterate 10000 times", async (t) => { @@ -55,14 +67,25 @@ test("JS expensive contract, iterate 10000 times", async (t) => { ); t.is(r.result.status.SuccessValue, "LTUwMDA="); - logGasDetail(r, t); + + logTestResults(r); + + const gasObject = generateGasObject(r, true); + + addTestResults("JS_expensive_contract_10000_times", gasObject); }); test("RS expensive contract. iterate 10000 times", async (t) => { const { bob, expensiveContractRs } = t.context.accounts; let r = await bob.callRaw(expensiveContractRs, "expensive", { n: 10000 }); + t.is(r.result.status.SuccessValue, "LTUwMDA="); - logGasDetail(r, t); + + logTestResults(r); + + const gasObject = generateGasObject(r, true); + + addTestResults("RS_expensive_contract_10000_times", gasObject); }); test("JS expensive contract, iterate 20000 times", async (t) => { @@ -75,12 +98,23 @@ test("JS expensive contract, iterate 20000 times", async (t) => { ); t.is(r.result.status.SuccessValue, "LTEwMDAw"); - logGasDetail(r, t); + + logTestResults(r); + + const gasObject = generateGasObject(r, true); + + addTestResults("JS_expensive_contract_20000_times", gasObject); }); test("RS expensive contract. iterate 20000 times", async (t) => { const { bob, expensiveContractRs } = t.context.accounts; let r = await bob.callRaw(expensiveContractRs, "expensive", { n: 20000 }); + t.is(r.result.status.SuccessValue, "LTEwMDAw"); - logGasDetail(r, t); + + logTestResults(r); + + const gasObject = generateGasObject(r, true); + + addTestResults("RS_expensive_contract_20000_times", gasObject); }); diff --git a/benchmark/__tests__/test-highlevel-collection.ava.js b/benchmark/__tests__/test-highlevel-collection.ava.js index 70c74d3e..3f1db9e1 100644 --- a/benchmark/__tests__/test-highlevel-collection.ava.js +++ b/benchmark/__tests__/test-highlevel-collection.ava.js @@ -1,6 +1,7 @@ import { Worker } from "near-workspaces"; import test from "ava"; -import { logGasDetail } from "./util.js"; +import { generateGasObject, logTestResults } from "./util.js"; +import { addTestResults } from "./results-store.js"; test.before(async (t) => { // Init the worker and start a Sandbox server @@ -50,7 +51,11 @@ test("JS highlevel collection contract", async (t) => { }); t.is(r.result.status.SuccessValue, ""); - logGasDetail(r, t); + logTestResults(r); + + const gasObject = generateGasObject(r, true); + + addTestResults("JS_highlevel_collection_contract", gasObject); }); test("RS highlevel collection contract", async (t) => { @@ -68,5 +73,9 @@ test("RS highlevel collection contract", async (t) => { value: "d".repeat(100), }); t.is(r.result.status.SuccessValue, ""); - logGasDetail(r, t); + logTestResults(r); + + const gasObject = generateGasObject(r, true); + + addTestResults("RS_highlevel_collection_contract", gasObject); }); diff --git a/benchmark/__tests__/test-highlevel-minimal.ava.js b/benchmark/__tests__/test-highlevel-minimal.ava.js index 474dd5d0..06712193 100644 --- a/benchmark/__tests__/test-highlevel-minimal.ava.js +++ b/benchmark/__tests__/test-highlevel-minimal.ava.js @@ -1,6 +1,7 @@ import { Worker } from "near-workspaces"; import test from "ava"; -import { logGasDetail } from "./util.js"; +import { generateGasObject, logTestResults } from "./util.js"; +import { addTestResults } from "./results-store.js"; test.before(async (t) => { // Init the worker and start a Sandbox server @@ -39,7 +40,11 @@ test("JS highlevel minimal contract", async (t) => { let r = await bob.callRaw(highlevelContract, "empty", ""); t.is(r.result.status.SuccessValue, ""); - logGasDetail(r, t); + logTestResults(r); + + const gasObject = generateGasObject(r, true); + + addTestResults("JS_highlevel_minimal_contract", gasObject); }); test("RS highlevel minimal contract", async (t) => { @@ -47,5 +52,9 @@ test("RS highlevel minimal contract", async (t) => { let r = await bob.callRaw(highlevelContractRs, "empty", ""); t.is(r.result.status.SuccessValue, ""); - logGasDetail(r, t); + logTestResults(r); + + const gasObject = generateGasObject(r, true); + + addTestResults("RS_highlevel_minimal_contract", gasObject); }); diff --git a/benchmark/__tests__/test-lowlevel-api.ava.js b/benchmark/__tests__/test-lowlevel-api.ava.js index aeffa40e..7f58fa46 100644 --- a/benchmark/__tests__/test-lowlevel-api.ava.js +++ b/benchmark/__tests__/test-lowlevel-api.ava.js @@ -1,6 +1,7 @@ import { Worker } from "near-workspaces"; import test from "ava"; -import { logGasDetail } from "./util.js"; +import { generateGasObject, logTestResults } from "./util.js"; +import { addTestResults } from "./results-store.js"; test.before(async (t) => { // Init the worker and start a Sandbox server @@ -35,7 +36,11 @@ test("JS lowlevel API contract", async (t) => { let r = await bob.callRaw(lowlevelContract, "lowlevel_storage_write", ""); t.is(r.result.status.SuccessValue, ""); - logGasDetail(r, t); + logTestResults(r); + + const gasObject = generateGasObject(r, true); + + addTestResults("JS_lowlevel_API_contract", gasObject); }); test("RS lowlevel API contract", async (t) => { @@ -43,7 +48,11 @@ test("RS lowlevel API contract", async (t) => { let r = await bob.callRaw(lowlevelContractRs, "lowlevel_storage_write", ""); t.is(r.result.status.SuccessValue, ""); - logGasDetail(r, t); + logTestResults(r); + + const gasObject = generateGasObject(r, true); + + addTestResults("RS_lowlevel_API_contract", gasObject); }); test("JS lowlevel API contract, call many", async (t) => { @@ -55,5 +64,5 @@ test("JS lowlevel API contract, call many", async (t) => { ); t.is(r.result.status.SuccessValue, ""); - logGasDetail(r, t); + logTestResults(r); }); diff --git a/benchmark/__tests__/test-lowlevel-minimal.ava.js b/benchmark/__tests__/test-lowlevel-minimal.ava.js index 3549bfa0..d533a187 100644 --- a/benchmark/__tests__/test-lowlevel-minimal.ava.js +++ b/benchmark/__tests__/test-lowlevel-minimal.ava.js @@ -1,6 +1,7 @@ import { Worker } from "near-workspaces"; import test from "ava"; -import { logGasDetail } from "./util.js"; +import { generateGasObject, logTestResults } from "./util.js"; +import { addTestResults } from "./results-store.js"; test.before(async (t) => { // Init the worker and start a Sandbox server @@ -35,13 +36,21 @@ test("JS lowlevel minimal contract", async (t) => { let r = await bob.callRaw(lowlevelContract, "empty", ""); t.is(r.result.status.SuccessValue, ""); - logGasDetail(r, t); + logTestResults(r); + + const gasObject = generateGasObject(r, true); + + addTestResults("JS_lowlevel_minimal_contract", gasObject); }); test("RS lowlevel minimal contract", async (t) => { const { bob, lowlevelContractRs } = t.context.accounts; let r = await bob.callRaw(lowlevelContractRs, "empty", ""); - + t.is(r.result.status.SuccessValue, ""); - logGasDetail(r, t); + logTestResults(r); + + const gasObject = generateGasObject(r, true); + + addTestResults("RS_lowlevel_minimal_contract", gasObject); }); diff --git a/benchmark/__tests__/util.js b/benchmark/__tests__/util.js index e91d8ea6..8daf64f1 100644 --- a/benchmark/__tests__/util.js +++ b/benchmark/__tests__/util.js @@ -1,68 +1,391 @@ -// Functions consumed by the benchmark contracts tests +import json2md from "json2md"; +import fs from "fs/promises"; export function formatGas(gas) { - if (gas < 10 ** 12) { - let tGas = gas / 10 ** 12; - let roundTGas = Math.round(tGas * 100000) / 100000; - return roundTGas + "T"; - } let tGas = gas / 10 ** 12; - let roundTGas = Math.round(tGas * 100) / 100; + let roundTGas = + gas < 10 ** 12 + ? Math.round(tGas * 100000) / 100000 + : Math.round(tGas * 100) / 100; return roundTGas + "T"; } export function gasBreakdown(outcome) { - return new Map( - outcome.metadata.gas_profile.map((g) => { - return [g.cost, Number(g.gas_used)]; - }) + return outcome.metadata.gas_profile.reduce((acc, g) => { + acc[g.cost] = formatGas(Number(g.gas_used)); + return acc; + }, {}); +} + +/** + * Converts gas breakdown object to table rows. + * + * @param {Object} gasObject - The object containing gas breakdown data. + * @returns {Array} An array of objects representing table rows. + */ +function convertGasBreakdownToRows(gasObject) { + const breakdownEntries = Object.entries(gasObject.gasBreakdownForReceipt); + const breakdownRows = breakdownEntries.map(([key, value]) => ({ + Description: key, + GasUsed: value, + })); + + return [ + { + Description: "Gas Used to Convert Transaction to Receipt", + GasUsed: gasObject.gasUsedToConvertTransactionToReceipt, + }, + { + Description: "Gas Used to Execute Receipt", + GasUsed: gasObject.gasUsedToExecuteReceipt, + }, + ...breakdownRows, + { + Description: "Gas Used to Refund Unused Gas", + GasUsed: gasObject.gasUsedToRefundUnusedGas, + }, + { Description: "Total Gas Used", GasUsed: gasObject.totalGasUsed }, + ]; +} + +/** + * Formats the data into a Markdown table and logs it. + * + * @param {Array} data - The data to be formatted into a table. + */ +function logMarkdownTable(data) { + const maxDescriptionLength = Math.max( + ...data.map((row) => row.Description.length) ); + + const descriptionWidth = maxDescriptionLength + 4; + const gasWidth = 15; // Increased width for better formatting + + const header = `| ${"Description".padEnd( + descriptionWidth + )} | ${"Gas Used".padEnd(gasWidth)} |`; + const separator = `|${"-".repeat(descriptionWidth + 2)}|${"-".repeat( + gasWidth + 2 + )}|`; + + const rows = data + .map((row) => { + return `| ${row.Description.padEnd( + descriptionWidth + )} | ${row.GasUsed.padEnd(gasWidth)} |`; + }) + .join(`\n${separator}\n`); + + console.log(""); + console.log(separator); + console.log(header); + console.log(separator); + console.log(rows); + console.log(separator); } -export function logGasBreakdown(map, t) { - map.forEach((v, k) => { - t.log(" ", k, ": ", formatGas(v)); - }); +/** + * Logs the gas usage breakdown from test results in a table format. + * + * This function determines whether the test results are minimal or full based on + * the number of receipts outcomes. It then generates a gas usage object using the + * appropriate flag, converts this object into a table-friendly format, and logs + * the data in a well-formatted table. + * + * @param {Object} testResults - The test results object containing gas usage metrics. + */ +export function logTestResults(testResults) { + const isMinimal = testResults.result.receipts_outcome.length === 2; + const gasObject = generateGasObject(testResults, isMinimal); + const data = convertGasBreakdownToRows(gasObject); + logMarkdownTable(data); } -export function logGasDetail(r, t) { - t.log( - "Gas used to convert transaction to receipt: ", - formatGas(r.result.transaction_outcome.outcome.gas_burnt) +/** + * Generates a gas usage breakdown object from a test result. + * + * @param {Object} testResult - The result of a test execution, which includes transaction and receipt outcomes. + * @param {boolean} [isMinimal=false] - If true, includes only minimal gas usage data; otherwise, includes additional cross-contract call and refund data. + * @returns {Object} An object containing formatted gas usage values, including: + * - gasUsedToConvertTransactionToReceipt: Gas used to convert the transaction to a receipt. + * - gasUsedToExecuteReceipt: Gas used to execute the receipt. + * - gasBreakdownForReceipt: Formatted gas breakdown results for the receipt. + * - gasUsedToExecuteCrossContractCall: Gas used to execute any cross-contract calls (if not minimal). + * - gasUsedToRefundUnusedGasForCrossContractCall: Gas used to refund unused gas for cross-contract calls (if not minimal). + * - gasUsedToRefundUnusedGas: Gas used to refund any unused gas (if not minimal). + * - totalGasUsed: Total gas used including all relevant transactions and receipts. + */ +export function generateGasObject(testResult, isMinimal = false) { + // Initialize gas breakdown + const gasBreakdownForReceipt = gasBreakdown( + testResult.result.receipts_outcome[0].outcome ); - t.log( - "Gas used to execute the receipt (actual contract call): ", - formatGas(r.result.receipts_outcome[0].outcome.gas_burnt) + + // Common values + const gasUsedToConvertTransactionToReceipt = formatGas( + testResult.result.transaction_outcome.outcome.gas_burnt ); - let map = gasBreakdown(r.result.receipts_outcome[0].outcome); - logGasBreakdown(map, t); - t.log( - "Gas used to refund unused gas: ", - // TODO: fix after near-workspaces is updated - formatGas(r.result.receipts_outcome[1]?.outcome.gas_burnt || 0) + + const gasUsedToExecuteReceipt = formatGas( + testResult.result.receipts_outcome[0].outcome.gas_burnt ); - t.log( - "Total gas used: ", - formatGas( - r.result.transaction_outcome.outcome.gas_burnt + - r.result.receipts_outcome[0].outcome.gas_burnt + - // TODO: fix after near-workspaces is updated - (r.result.receipts_outcome[1]?.outcome.gas_burnt || 0) - ) + + // Initialize optional values + let gasUsedToExecuteCrossContractCall = undefined; + let gasUsedToRefundUnusedGasForCrossContractCall = undefined; + + let gasUsedToRefundUnusedGas = formatGas( + testResult.result.receipts_outcome[1]?.outcome.gas_burnt ?? 0 + ); + + // If not minimal, set additional values + if (!isMinimal) { + gasUsedToExecuteCrossContractCall = formatGas( + testResult.result.receipts_outcome[1]?.outcome.gas_burnt ?? 0 + ); + gasUsedToRefundUnusedGasForCrossContractCall = formatGas( + testResult.result.receipts_outcome[2]?.outcome.gas_burnt ?? 0 + ); + gasUsedToRefundUnusedGas = formatGas( + testResult.result.receipts_outcome[3]?.outcome.gas_burnt ?? 0 + ); + } + + // Calculate total gas used + const totalGasUsed = formatGas( + testResult.result.transaction_outcome.outcome.gas_burnt + + testResult.result.receipts_outcome[0].outcome.gas_burnt + + (isMinimal + ? testResult.result.receipts_outcome[1]?.outcome.gas_burnt ?? 0 + : (testResult.result.receipts_outcome[1]?.outcome.gas_burnt ?? 0) + + (testResult.result.receipts_outcome[2]?.outcome.gas_burnt ?? 0) + + (testResult.result.receipts_outcome[3]?.outcome.gas_burnt ?? 0)) + ); + + return { + gasUsedToConvertTransactionToReceipt, + gasUsedToExecuteReceipt, + gasBreakdownForReceipt, + gasUsedToExecuteCrossContractCall, + gasUsedToRefundUnusedGasForCrossContractCall, + gasUsedToRefundUnusedGas, + totalGasUsed, + }; +} + +/** + * Retrieves an existing row with the specified description from the rows array, + * or initializes a new row if none exists. + * + * @param {Array>} rows - The array of rows where each row is an array of values. + * @param {string} description - The description for the row. + * @param {string} defaultValue - The default value to initialize the row with. + * @returns {Array} - The row array for the given description. + */ +function getOrCreateRow(rows, description, defaultValue) { + let row = rows.find((r) => r[0] === description); + if (!row) { + row = [description, defaultValue, ""]; + rows.push(row); + } + return row; +} + +/** + * Updates the value at a specific index in a row with the given description. + * If the row does not exist, it will be created using getOrCreateRow. + * + * @param {Array>} rows - The array of rows where each row is an array of values. + * @param {string} description - The description for the row to update. + * @param {number} valueIndex - The index in the row where the value should be set. + * @param {string} value - The value to set in the row. + */ +function updateRow(rows, description, valueIndex, value) { + const row = getOrCreateRow(rows, description, ""); + row[valueIndex] = value; +} + +/** + * Converts JSON data into a Markdown table with a title and writes it to a .md file. + * + * @param {string} title - The title for the Markdown document (H1). + * @param {Array} data - The JSON data to be converted into a Markdown table. + * @param {string} fileName - The name of the Markdown file to be created. + * @returns {Promise} - A promise that resolves when the file is written. + */ +export async function jsonToMarkdown(title, data, fileName) { + const sortedJsonData = sortJsonData(data); + + if (typeof sortedJsonData !== "object" || sortedJsonData === null) { + throw new Error("Expected sortedJsonData to be an object."); + } + + const markdownSections = Object.entries(sortedJsonData).map( + ([name, details]) => { + const headers = ["Metric", "Rust", "JS"]; + const rows = []; + + // Gas details for Rust + const gasDetails = { + "Convert transaction to receipt": + details.gasUsedToConvertTransactionToReceipt, + "Execute the receipt (actual contract call)": + details.gasUsedToExecuteReceipt, + }; + + Object.entries(gasDetails).forEach(([description, value]) => { + if (value) { + updateRow(rows, description, 1, value); + } + }); + + // Breakdown metrics for Rust + const breakdown = details.gasBreakdownForReceipt ?? details; + Object.entries(breakdown).forEach(([metric, value]) => { + updateRow(rows, metric, 1, value); + }); + + // Find JS entry and update rows with JS values + const jsEntry = Object.entries(sortedJsonData).find( + ([key]) => key.startsWith("JS") && key.includes(name.split("_")[1]) + ); + + if (jsEntry) { + const [, jsDetails] = jsEntry; + + const jsBreakdown = jsDetails.gasBreakdownForReceipt ?? jsDetails; + Object.entries(jsBreakdown).forEach(([metric, value]) => { + updateRow(rows, metric, 2, value); + }); + + // Update specific gas used values for JS + updateRow( + rows, + "Convert transaction to receipt", + 2, + jsDetails.gasUsedToConvertTransactionToReceipt + ); + updateRow( + rows, + "Execute the receipt (actual contract call)", + 2, + jsDetails.gasUsedToExecuteReceipt + ); + } + + // Add remaining metrics + rows.push( + [ + "Gas used to refund unused gas", + details.gasUsedToRefundUnusedGas, + jsEntry ? jsEntry[1].gasUsedToRefundUnusedGas : "", + ], + [ + "Total gas used", + details.totalGasUsed, + jsEntry ? jsEntry[1].totalGasUsed : "", + ] + ); + + const filteredRows = rows.filter(([_, rustValue, jsValue]) => { + return rustValue && jsValue; + }); + + return { + h3: name, + table: { + headers, + rows: filteredRows, + }, + }; + } ); + + const markdown = json2md([ + { h1: title }, + ...filterMarkdownSections(markdownSections), + ]); + + try { + await fs.writeFile(`${fileName}.md`, markdown); + } catch (error) { + console.error(`Failed to write the file: ${error.message}`); + throw error; + } + + return markdown; } +/** + * Sorts JSON data according to a predefined order and converts it into an object. + * + * @param {Object} data - The JSON data to be sorted. + * @returns {Object} - The sorted JSON data as an object with ordered properties. + */ +function sortJsonData(data) { + const order = [ + "RS_lowlevel_API_contract", + "JS_lowlevel_API_contract", + "RS_lowlevel_minimal_contract", + "JS_lowlevel_minimal_contract", + "RS_highlevel_collection_contract", + "JS_highlevel_collection_contract", + "RS_highlevel_minimal_contract", + "JS_highlevel_minimal_contract", + "RS_promise_batch_deploy_contract_and_call", + "JS_promise_batch_deploy_contract_and_call", + "RS_expensive_contract_100_times", + "JS_expensive_contract_100_times", + "RS_expensive_contract_10000_times", + "JS_expensive_contract_10000_times", + "RS_expensive_contract_20000_times", + "JS_expensive_contract_20000_times", + ]; + + const sortedData = order.reduce((acc, key) => { + if (data[key]) { + acc[key] = data[key]; + } + return acc; + }, {}); + + return sortedData; +} + +/** + * Filters the markdownSections containing redundant data before including them in the markdown file. + * + * @param {Array} sections - The array of markdown sections data. + * @returns {Array} - The filtered markdown sections data. + */ +function filterMarkdownSections(sections) { + const rsBaseNames = new Set(); + const filteredSections = []; -export function logTotalGas(prefix = '', r, t) { + sections.forEach((section) => { + const baseName = section.h3.replace(/^RS_/, "").replace(/^JS_/, ""); + + if (section.h3.startsWith("RS_")) { + rsBaseNames.add(baseName); + filteredSections.push({ ...section, h3: baseName }); + } else if (section.h3.startsWith("JS_") && !rsBaseNames.has(baseName)) { + filteredSections.push({ ...section, h3: baseName }); + } + }); + + return filteredSections; +} + +export function logTotalGas(prefix = "", r, t) { t.log( - prefix + ' - Total gas used: ', + prefix + " - Total gas used: ", formatGas( r.result.transaction_outcome.outcome.gas_burnt + - r.result.receipts_outcome[0].outcome.gas_burnt + - (r.result.receipts_outcome[1]?.outcome.gas_burnt || 0) + r.result.receipts_outcome[0].outcome.gas_burnt + + (r.result.receipts_outcome[1]?.outcome.gas_burnt || 0) ) ); } export function randomInt(max) { return Math.floor(Math.random() * max); -} \ No newline at end of file +} diff --git a/benchmark/package.json b/benchmark/package.json index 1ed0f72d..ddd2ed4e 100644 --- a/benchmark/package.json +++ b/benchmark/package.json @@ -18,23 +18,27 @@ "build:unordered-set": "near-sdk-js build src/unordered-set.js build/unordered-set.wasm", "build:vector": "near-sdk-js build src/vector.js build/vector.wasm", "test": "ava", + "test:with-report": "GENERATE_REPORT=true ava && node src/generate-report.js", "test:lowlevel-minimal": "ava __tests__/test-lowlevel-minimal.ava.js", "test:highlevel-minimal": "ava __tests__/test-highlevel-minimal.ava.js", "test:lowlevel-api": "ava __tests__/test-lowlevel-api.ava.js", "test:highlevel-collection": "ava __tests__/test-highlevel-collection.ava.js", "test:expensive-calc": "ava __tests__/test-expensive-calc.ava.js", "test:deploy-contract": "ava __tests__/test-deploy-contract.ava.js", - "test:collections": "ava __tests__/test-collections-performance.ava.js" + "test:collections": "ava __tests__/test-collections-performance.ava.js", + "calculate-file-sizes": "WORKING_DIR=$(pwd) pnpm --filter shared-scripts run calculate-file-sizes" }, "author": "Near Inc ", "license": "Apache-2.0", "devDependencies": { "ava": "4.3.3", "near-workspaces": "4.0.0", - "npm-run-all": "4.1.5" + "npm-run-all": "4.1.5", + "json2md": "2.0.1" }, "dependencies": { - "typescript": "4.7.4", - "near-sdk-js": "workspace:*" + "near-sdk-js": "workspace:*", + "typescript": "4.7.4" } } + diff --git a/benchmark/src/generate-report.js b/benchmark/src/generate-report.js new file mode 100644 index 00000000..6adc922e --- /dev/null +++ b/benchmark/src/generate-report.js @@ -0,0 +1,33 @@ +import path from "path"; +import fs from "fs/promises"; +import { fileURLToPath } from "url"; +import { jsonToMarkdown } from "../__tests__/util.js"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const jsonFilePath = path.resolve( + __dirname, + "..", + "temp-all-test-results.json" +); + +async function generateReport() { + try { + const jsonData = JSON.parse(await fs.readFile(jsonFilePath, "utf-8")); + + await jsonToMarkdown( + "Tests Gas Consumption Comparison", + jsonData, + "TESTS-GAS-CONSUMPTION-COMPARISON" + ); + + await fs.unlink(jsonFilePath); + console.log("Report generated and JSON file deleted successfully."); + } catch (error) { + console.error("Error generating report:", error); + process.exit(1); + } +} + +generateReport(); diff --git a/examples/.gitignore b/examples/.gitignore index 48912d24..d24d084d 100644 --- a/examples/.gitignore +++ b/examples/.gitignore @@ -1,2 +1,3 @@ build -node_modules \ No newline at end of file +node_modules +file-sizes.json diff --git a/examples/package.json b/examples/package.json index f3e82252..29efb4b1 100644 --- a/examples/package.json +++ b/examples/package.json @@ -60,7 +60,8 @@ "test:status-message-serialize-err": "ava __tests__/test-status-message-serialize-err.ava.js", "test:status-message-deserialize-err": "ava __tests__/test-status-message-deserialize-err.ava.js", "test:status-deserialize-class": "ava __tests__/test-status-deserialize-class.ava.js", - "test:basic-updates": "ava __tests__/test-basic-updates.ava.js" + "test:basic-updates": "ava __tests__/test-basic-updates.ava.js", + "calculate-file-sizes": "WORKING_DIR=$(pwd) pnpm --filter shared-scripts run calculate-file-sizes" }, "author": "Near Inc ", "license": "Apache-2.0", @@ -68,13 +69,14 @@ "lodash-es": "4.17.21", "near-contract-standards": "workspace:*", "near-sdk-js": "workspace:*", - "typescript": "4.7.4", "borsh": "1.0.0" }, "devDependencies": { "@types/lodash-es": "4.17.12", "ava": "4.3.3", "near-workspaces": "4.0.0", - "npm-run-all": "4.1.5" + "npm-run-all": "4.1.5", + "typescript": "4.7.4" } } + diff --git a/packages/near-contract-standards/.gitignore b/packages/near-contract-standards/.gitignore index 48912d24..d24d084d 100644 --- a/packages/near-contract-standards/.gitignore +++ b/packages/near-contract-standards/.gitignore @@ -1,2 +1,3 @@ build -node_modules \ No newline at end of file +node_modules +file-sizes.json diff --git a/packages/shared-scripts/README.md b/packages/shared-scripts/README.md new file mode 100644 index 00000000..fff1e5bb --- /dev/null +++ b/packages/shared-scripts/README.md @@ -0,0 +1,71 @@ +# NEAR Shared Scripts + +### calculate-file-sizes.js + +It is used to track the file sizes contained in a given folder. + +The script contains a few flags, which needs to be specified in order to work correctly. + +There is a specific command added in every `package.json` for `benchmark`, `examples` and `tests`, which makes it easier to run the script. + +Example: + +In `examples` package, the command: + +`pnpm calculate-file-sizes` will activate the script. + +Message like this will pop up in the terminal: + +`Please provide a directory path as an argument.` + +Directory path will need to be added, for example `build` folder. + +`pnpm calculate-file-sizes build` + +Message like this will pop up in the terminal: + +`Please specify either --before or --after flag.` + +Flags: + +`--before` This flag will trigger the process for calculating file sizes, before any optimization is made. It can ensure that the results before any changes made regarding that are saved into a `file-sizes.json` file, so they can be used after the optimization implementation is made. Do not remove this file if you want accurate results. + +`--after` This flag should be used only after the optimization implementations are made in the project and it has been rebuild. This flag will trigger the process which will generate the results after the changes, compare it with the results from before optimization, generate an `WASM-FILE-SIZE-COMPARISON.md` file with a table including all the files and their data, and remove the `file-sizes.json` file. + +The table generated will look something like this: + +### WASM File Size Comparison Before and After Optimization + +| File Name | Before Opt (KB) | After Opt (KB) | % Diff | +| :------------------------------------ | --------------: | -------------: | ------: | +| basic-updates-base.wasm | 500.53 | 477.35 | -4.63% | +| basic-updates-update.wasm | 562.14 | 498.71 | -11.27% | +| clean-state.wasm | 496.45 | 473.48 | -4.62% | +| counter-lowlevel.wasm | 472.62 | 468.67 | -0.84% | +| counter-ts.wasm | 496.66 | 473.56 | -4.66% | +| counter.wasm | 496.56 | 473.51 | -4.64% | +| cross-contract-call-loop.wasm | 504.86 | 480.77 | -4.77% | +| cross-contract-call-ts.wasm | 498.54 | 475.04 | -4.71% | +| cross-contract-call.wasm | 498.51 | 475.01 | -4.71% | +| fungible-token-helper.wasm | 495.88 | 472.92 | -4.63% | +| fungible-token-lockable.wasm | 505.26 | 481.73 | -4.66% | +| fungible-token.wasm | 505.12 | 481.39 | -4.69% | +| my-ft.wasm | 520.98 | 495.55 | -4.89% | +| my-nft.wasm | 534.88 | 507.94 | -5.04% | +| nested-collections.wasm | 504.90 | 481.18 | -4.71% | +| nft-approval-receiver.wasm | 504.91 | 480.82 | -4.76% | +| nft-receiver.wasm | 505.10 | 481.05 | -4.76% | +| non-fungible-token-receiver.wasm | 496.51 | 473.51 | -4.63% | +| non-fungible-token.wasm | 503.30 | 479.39 | -4.76% | +| parking-lot.wasm | 500.11 | 476.87 | -4.65% | +| programmatic-update-after.wasm | 496.49 | 473.41 | -4.65% | +| programmatic-update-before.wasm | 496.47 | 473.38 | -4.65% | +| state-migration-new.wasm | 501.13 | 477.72 | -4.67% | +| state-migration-original.wasm | 499.98 | 476.68 | -4.65% | +| status-deserialize-class.wasm | 522.52 | 498.15 | -4.66% | +| status-message-borsh.wasm | 506.13 | 482.19 | -4.73% | +| status-message-collections.wasm | 505.38 | 481.67 | -4.70% | +| status-message-deserialize-err.wasm | 496.05 | 473.03 | -4.64% | +| status-message-migrate-add-field.wasm | 498.11 | 475.02 | -4.64% | +| status-message-serialize-err.wasm | 496.05 | 473.03 | -4.64% | +| status-message.wasm | 496.19 | 473.16 | -4.65% | diff --git a/packages/shared-scripts/package.json b/packages/shared-scripts/package.json new file mode 100644 index 00000000..beb71543 --- /dev/null +++ b/packages/shared-scripts/package.json @@ -0,0 +1,12 @@ +{ + "name": "shared-scripts", + "version": "1.0.0", + "description": "Package for shared scripts between the repos", + "type": "module", + "scripts": { + "calculate-file-sizes": "node src/calculate-file-sizes.js" + }, + "devDependencies": { + "json2md": "2.0.1" + } +} diff --git a/packages/shared-scripts/src/calculate-file-sizes.js b/packages/shared-scripts/src/calculate-file-sizes.js new file mode 100644 index 00000000..4964461f --- /dev/null +++ b/packages/shared-scripts/src/calculate-file-sizes.js @@ -0,0 +1,158 @@ +import fs from "fs/promises"; +import path from "path"; +import json2md from "json2md"; + +// Get directory and flags from command line arguments +const args = process.argv.slice(2); +const relativeDir = args[0]; + +const isBefore = args.includes("--before"); +const isAfter = args.includes("--after"); +const isHelp = args.includes("--help"); + +if (!relativeDir) { + console.log("Please provide a directory path as an argument."); + process.exit(1); +} + +if (!isBefore && !isAfter) { + console.log("Please specify either --before or --after flag."); + process.exit(1); +} + +if (isBefore && isAfter) { + console.log("Please specify either --before or --after, not both."); + process.exit(1); +} + +if (isHelp) { + console.log(`Usage: node script.js <${relativeDir}> [--before|--after]`); + process.exit(0); +} + +// Get the working directory from the environment variable +const scriptDir = process.env.WORKING_DIR ?? process.cwd(); + +const dir = path.resolve(scriptDir, relativeDir); +const jsonFilePath = path.join(scriptDir, "file-sizes.json"); + +const calculateFileSizes = async () => { + try { + // Check if the directory exists + await fs.access(dir); + + const files = await fs.readdir(dir); + + let fileSizes = { beforeOptimization: {}, afterOptimization: {} }; + + // Check if the JSON file already exists and load data + try { + const data = await fs.readFile(jsonFilePath, "utf-8"); + fileSizes = JSON.parse(data); + } catch { + // If file doesn't exist, initialize fileSizes as default + } + + // If the --after flag is used, ensure beforeOptimization data exists + if (isAfter && Object.keys(fileSizes.beforeOptimization).length === 0) { + console.log( + "No data found before optimization. Please run the script with --before first." + ); + process.exit(1); + } + + // Filter .wasm files and calculate sizes + const wasmFiles = files.filter((file) => path.extname(file) === ".wasm"); + + // Wait for all file size calculations to complete + await Promise.all( + wasmFiles.map(async (file) => { + const filePath = path.join(dir, file); + const stats = await fs.stat(filePath); + + const fileSizeInKB = (stats.size / 1024).toFixed(2); + + // Add file size to the appropriate section + if (isBefore) { + fileSizes.beforeOptimization[file] = `${fileSizeInKB} KB`; + } else if (isAfter) { + fileSizes.afterOptimization[file] = `${fileSizeInKB} KB`; + } + }) + ); + + // Write the result to the JSON file + await fs.writeFile(jsonFilePath, JSON.stringify(fileSizes, null, 2)); + + console.log(`File sizes saved to ${jsonFilePath}`); + + const updatedData = await fs.readFile(jsonFilePath, "utf-8"); + + const updatedFileSizes = JSON.parse(updatedData); + + if ( + Object.keys(updatedFileSizes.beforeOptimization).length && + Object.keys(updatedFileSizes.afterOptimization).length + ) { + // Generate Markdown file + try { + await generateMarkdown(scriptDir, fileSizes); + } catch (err) { + console.error(`Error generating Markdown: ${err.message}`); + } + } + } catch (err) { + console.error(`Error: ${err.message}`); + } +}; + +// Function to generate the Markdown file +const generateMarkdown = async (outputDir, data) => { + const folderName = path.basename(outputDir).toUpperCase(); + + const mdContent = { + h1: `NEAR-SDK-JS ${folderName}`, + h3: "WASM File Size Comparison Before and After Optimization", + table: { + headers: ["File Name", "Before Opt (KB)", "After Opt (KB)", "% Diff"], + rows: Object.keys(data.beforeOptimization).map((file) => { + const beforeSize = data.beforeOptimization[file] || "N/A"; + const afterSize = data.afterOptimization[file] || "N/A"; + return [ + file, + beforeSize, + afterSize, + afterSize !== "N/A" + ? calculatePercentageDifference(beforeSize, afterSize) + : "N/A", + ]; + }), + }, + }; + + // Convert JSON to markdown + const markdown = json2md(mdContent); + + // Write markdown to a file + const filePath = path.join(outputDir, "WASM-FILE-SIZE-COMPARISON.md"); + await fs.writeFile(filePath, markdown); + + console.log(`Markdown file has been saved to ${filePath}`); + await fs.unlink(path.join(outputDir, "file-sizes.json")); +}; + +// Function to calculate percentage difference +const calculatePercentageDifference = (beforeSize, afterSize) => { + const beforeSizeNum = parseFloat(beforeSize); + const afterSizeNum = parseFloat(afterSize); + + if (isNaN(beforeSizeNum) || isNaN(afterSizeNum)) { + return "N/A"; + } + + return ( + (((beforeSizeNum - afterSizeNum) / beforeSizeNum) * 100).toFixed(2) + "%" + ); +}; + +calculateFileSizes(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8cd0f7f4..44bd7d68 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -30,6 +30,9 @@ importers: ava: specifier: 4.3.3 version: 4.3.3 + json2md: + specifier: 2.0.1 + version: 2.0.1 near-workspaces: specifier: 4.0.0 version: 4.0.0 @@ -51,9 +54,6 @@ importers: near-sdk-js: specifier: workspace:* version: link:../packages/near-sdk-js - typescript: - specifier: 4.7.4 - version: 4.7.4 devDependencies: '@types/lodash-es': specifier: 4.17.12 @@ -67,6 +67,9 @@ importers: npm-run-all: specifier: 4.1.5 version: 4.1.5 + typescript: + specifier: 4.7.4 + version: 4.7.4 packages/near-contract-standards: dependencies: @@ -187,6 +190,12 @@ importers: specifier: 2.8.8 version: 2.8.8 + packages/shared-scripts: + devDependencies: + json2md: + specifier: 2.0.1 + version: 2.0.1 + tests: dependencies: near-sdk-js: @@ -1391,6 +1400,9 @@ packages: resolution: {integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==} engines: {node: '>=12'} + indento@1.1.13: + resolution: {integrity: sha512-YZWk3mreBEM7sBPddsiQnW9Z8SGg/gNpFfscJq00HCDS7pxcQWWWMSVKJU7YkTRyDu1Zv2s8zaK8gQWKmCXHlg==} + inflight@1.0.6: resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. @@ -1573,6 +1585,9 @@ packages: json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + json2md@2.0.1: + resolution: {integrity: sha512-VbwmZ83qmUfKBS2pUOHlzNKEZFPBeJSbzEok3trMYyboZUgdHNn1XZfc1uT8UZs1GHCrmRUBXCfqw4YmmQuOhw==} + json5@2.2.3: resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} engines: {node: '>=6'} @@ -3946,6 +3961,8 @@ snapshots: indent-string@5.0.0: {} + indento@1.1.13: {} + inflight@1.0.6: dependencies: once: 1.4.0 @@ -4095,6 +4112,10 @@ snapshots: json-stable-stringify-without-jsonify@1.0.1: {} + json2md@2.0.1: + dependencies: + indento: 1.1.13 + json5@2.2.3: {} jsonc-parser@3.3.1: {} diff --git a/tests/.gitignore b/tests/.gitignore index b7dab5e9..d6bbbea5 100644 --- a/tests/.gitignore +++ b/tests/.gitignore @@ -1,2 +1,3 @@ node_modules -build \ No newline at end of file +build +file-sizes.json diff --git a/tests/package.json b/tests/package.json index f5b98efd..1dd20461 100644 --- a/tests/package.json +++ b/tests/package.json @@ -56,7 +56,8 @@ "test:migrate": "ava __tests__/decorators/migrate.ava.js", "test:middlewares": "ava __tests__/test-middlewares.ava.js", "test:abi": "ava __tests__/abi/abi.ava.js", - "test:alt-bn128-api": "ava __tests__/test_alt_bn128_api.ava.js" + "test:alt-bn128-api": "ava __tests__/test_alt_bn128_api.ava.js", + "calculate-file-sizes": "WORKING_DIR=$(pwd) pnpm --filter shared-scripts run calculate-file-sizes" }, "author": "Near Inc ", "license": "Apache-2.0", @@ -70,3 +71,4 @@ "near-sdk-js": "workspace:*" } } +