From 7a4a5d53ce1ea7f8c34a5a3943fa62f6e226fadc Mon Sep 17 00:00:00 2001 From: Tim B <79199034+timbrinded@users.noreply.github.com> Date: Thu, 15 Feb 2024 18:11:25 +0000 Subject: [PATCH] Adding TestIdDerive function (#375) * progress * integrated into mainmenu * incr ver --- .changeset/tame-mice-deliver.md | 6 + .gitignore | 1 + packages/cli/moonwall.mjs | 1 + packages/cli/src/cmds/entrypoint.ts | 17 +- packages/cli/src/cmds/main.ts | 45 ++++- .../cli/src/internal/cmdFunctions/index.ts | 4 + packages/cli/src/internal/deriveTestIds.ts | 134 +++++++++++++++ .../internal/foundations/chopsticksHelpers.ts | 6 +- .../internal/foundations/devModeHelpers.ts | 6 +- .../cli/src/internal/foundations/index.ts | 3 + packages/cli/src/internal/index.ts | 10 ++ packages/cli/src/lib/governanceProcedures.ts | 28 +-- pnpm-lock.yaml | 23 +++ test/package.json | 5 + test/scripts/derive-test-ids.ts | 160 ++++++++++++++++++ 15 files changed, 422 insertions(+), 27 deletions(-) create mode 100644 .changeset/tame-mice-deliver.md create mode 100644 packages/cli/src/internal/cmdFunctions/index.ts create mode 100644 packages/cli/src/internal/deriveTestIds.ts create mode 100644 packages/cli/src/internal/foundations/index.ts create mode 100644 packages/cli/src/internal/index.ts create mode 100644 test/scripts/derive-test-ids.ts diff --git a/.changeset/tame-mice-deliver.md b/.changeset/tame-mice-deliver.md new file mode 100644 index 00000000..1f9fea14 --- /dev/null +++ b/.changeset/tame-mice-deliver.md @@ -0,0 +1,6 @@ +--- +"@moonwall/cli": patch +"@moonwall/tests": patch +--- + +Added Derive TestId feature diff --git a/.gitignore b/.gitignore index f1140a9c..a611ead3 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ coverage coverage.json typechain typechain-types +target/ cache artifacts diff --git a/packages/cli/moonwall.mjs b/packages/cli/moonwall.mjs index 098af5f5..539d623a 100755 --- a/packages/cli/moonwall.mjs +++ b/packages/cli/moonwall.mjs @@ -1,3 +1,4 @@ #!/usr/bin/env -S node --no-warnings +import './dist/internal/logging.js' import './dist/cmds/entrypoint.js' diff --git a/packages/cli/src/cmds/entrypoint.ts b/packages/cli/src/cmds/entrypoint.ts index 43092701..d0a34955 100755 --- a/packages/cli/src/cmds/entrypoint.ts +++ b/packages/cli/src/cmds/entrypoint.ts @@ -1,10 +1,8 @@ -import "../internal/logging"; import "@moonbeam-network/api-augment"; import dotenv from "dotenv"; import yargs from "yargs"; import { hideBin } from "yargs/helpers"; -import { fetchArtifact } from "../internal/cmdFunctions/fetchArtifact"; -import { generateConfig } from "../internal/cmdFunctions/initialisation"; +import { fetchArtifact, deriveTestIds, generateConfig } from "../internal"; import { main } from "./main"; import { runNetworkCmd } from "./runNetwork"; import { testCmd } from "./runTests"; @@ -123,6 +121,19 @@ yargs(hideBin(process.argv)) await runNetworkCmd(argv as any); } ) + .command<{ suitesRootDir: string }>( + "derive ", + "Derive test IDs based on positional order in the directory tree", + (yargs) => { + return yargs.positional("suitesRootDir", { + describe: "Root directory of the suites", + type: "string", + }); + }, + async (argv) => { + await deriveTestIds(argv.suitesRootDir); + } + ) .demandCommand(1) .fail(async (msg) => { console.log(msg); diff --git a/packages/cli/src/cmds/main.ts b/packages/cli/src/cmds/main.ts index e673698d..8ae5bc6e 100644 --- a/packages/cli/src/cmds/main.ts +++ b/packages/cli/src/cmds/main.ts @@ -2,20 +2,25 @@ import { MoonwallConfig } from "@moonwall/types"; import chalk from "chalk"; import clear from "clear"; import colors from "colors"; +import fs from "fs"; import inquirer from "inquirer"; import PressToContinuePrompt from "inquirer-press-to-continue"; import fetch from "node-fetch"; +import path from "path"; import { SemVer, lt } from "semver"; import pkg from "../../package.json" assert { type: "json" }; -import { fetchArtifact, getVersions } from "../internal/cmdFunctions/fetchArtifact"; -import { createFolders, generateConfig } from "../internal/cmdFunctions/initialisation"; +import { + createFolders, + deriveTestIds, + executeScript, + fetchArtifact, + generateConfig, + getVersions, +} from "../internal"; import { importAsyncConfig } from "../lib/configReader"; import { allReposAsync } from "../lib/repoDefinitions"; import { runNetworkCmd } from "./runNetwork"; import { testCmd } from "./runTests"; -import fs from "fs"; -import { executeScript } from "../internal/launcherCommon"; -import path from "path"; inquirer.registerPrompt("press-to-continue", PressToContinuePrompt); @@ -80,8 +85,14 @@ async function mainMenu(config?: MoonwallConfig) { name: "4) Artifact Downloader: Fetch artifacts (x86) from GitHub repos", value: "download", }, + + { + name: "5) Rename TestIDs: Rename test id prefixes based on position in the directory tree", + value: "derive", + }, + { - name: "5) Quit Application", + name: "6) Quit Application", value: "quit", }, ], @@ -143,6 +154,28 @@ async function mainMenu(config?: MoonwallConfig) { return await resolveExecChoice(config); } + case "derive": { + clear(); + const { rootDir } = await inquirer.prompt({ + name: "rootDir", + type: "input", + message: "Enter the root testSuites directory to process:", + default: "suites", + }); + await deriveTestIds(rootDir); + + await inquirer.prompt({ + name: "test complete", + type: "press-to-continue", + anyKey: true, + pressToContinueMessage: `ℹī¸ Renaming task for ${chalk.bold( + `/${rootDir}` + )} has been completed. Press any key to continue...\n`, + }); + + return false; + } + default: throw new Error("Invalid choice"); } diff --git a/packages/cli/src/internal/cmdFunctions/index.ts b/packages/cli/src/internal/cmdFunctions/index.ts new file mode 100644 index 00000000..e7c226a2 --- /dev/null +++ b/packages/cli/src/internal/cmdFunctions/index.ts @@ -0,0 +1,4 @@ +export * from "./downloader"; +export * from "./fetchArtifact"; +export * from "./initialisation"; +export * from "./tempLogs"; diff --git a/packages/cli/src/internal/deriveTestIds.ts b/packages/cli/src/internal/deriveTestIds.ts new file mode 100644 index 00000000..5900e9dc --- /dev/null +++ b/packages/cli/src/internal/deriveTestIds.ts @@ -0,0 +1,134 @@ +import chalk from "chalk"; +import fs from "fs"; +import inquirer from "inquirer"; +import path from "path"; + +export async function deriveTestIds(rootDir: string) { + const usedPrefixes: Set = new Set(); + + try { + await fs.promises.access(rootDir, fs.constants.R_OK); + } catch (error) { + console.error( + `🔴 Error accessing directory ${chalk.bold(`/${rootDir}`)}, please sure this exists` + ); + process.exitCode = 1; + return; + } + console.log(`đŸŸĸ Processing ${rootDir} ...`); + const topLevelDirs = getTopLevelDirs(rootDir); + + const foldersToRename: { prefix: string; dir: string }[] = []; + + for (const dir of topLevelDirs) { + const prefix = generatePrefix(dir, usedPrefixes); + foldersToRename.push({ prefix, dir }); + } + + const result = await inquirer.prompt({ + type: "confirm", + name: "confirm", + message: `This will rename ${foldersToRename.length} suites IDs in ${rootDir}, continue?`, + }); + + if (!result.confirm) { + console.log("🔴 Aborted"); + return; + } + + for (const folder of foldersToRename) { + const { prefix, dir } = folder; + process.stdout.write( + `đŸŸĸ Changing suite ${dir} to use prefix ${chalk.bold(`(${prefix})`)} ....` + ); + + generateId(path.join(rootDir, dir), rootDir, prefix); + process.stdout.write(" Done ✅\n"); + } + + console.log(`🏁 Finished renaming rootdir ${chalk.bold(`/${rootDir}`)}`); +} + +function getTopLevelDirs(rootDir: string): string[] { + return fs + .readdirSync(rootDir) + .filter((dir) => fs.statSync(path.join(rootDir, dir)).isDirectory()); +} + +function generatePrefix(directory: string, usedPrefixes: Set): string { + let prefix = directory[0].toUpperCase(); + + if (usedPrefixes.has(prefix)) { + const match = directory.match(/[-_](\w)/); + if (match) { + // if directory name has a '-' or '_' + prefix += match[1].toUpperCase(); + } else { + prefix = directory[1].toUpperCase(); + } + } + + while (usedPrefixes.has(prefix)) { + const charCode = prefix.charCodeAt(1); + if (charCode >= 90) { + // If it's Z, wrap around to A + prefix = `${String.fromCharCode(prefix.charCodeAt(0) + 1)}A`; + } else { + prefix = prefix[0] + String.fromCharCode(charCode + 1); + } + } + + usedPrefixes.add(prefix); + return prefix; +} + +function generateId(directory: string, rootDir: string, prefix: string): void { + const contents = fs.readdirSync(directory); + + contents.sort((a, b) => { + const aIsDir = fs.statSync(path.join(directory, a)).isDirectory(); + const bIsDir = fs.statSync(path.join(directory, b)).isDirectory(); + + if (aIsDir && !bIsDir) return -1; + if (!aIsDir && bIsDir) return 1; + return customFileSort(a, b); + }); + + let fileCount = 1; + let subDirCount = 1; + + for (const item of contents) { + const fullPath = path.join(directory, item); + + if (fs.statSync(fullPath).isDirectory()) { + const subDirPrefix = `0${subDirCount}`.slice(-2); + generateId(fullPath, rootDir, prefix + subDirPrefix); + subDirCount++; + } else { + const fileContent = fs.readFileSync(fullPath, "utf-8"); + if (fileContent.includes("describeSuite")) { + const newId = prefix + `0${fileCount}`.slice(-2); + const updatedContent = fileContent.replace( + /(describeSuite\s*?\(\s*?\{\s*?id\s*?:\s*?['"])[^'"]+(['"])/, + `$1${newId}$2` + ); + fs.writeFileSync(fullPath, updatedContent); + } + fileCount++; + } + } +} + +function hasSpecialCharacters(filename: string): boolean { + return /[ \t!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]+/.test(filename); +} + +function customFileSort(a: string, b: string): number { + const aHasSpecialChars = hasSpecialCharacters(a); + const bHasSpecialChars = hasSpecialCharacters(b); + + if (aHasSpecialChars && !bHasSpecialChars) return -1; + if (!aHasSpecialChars && bHasSpecialChars) return 1; + + return a.localeCompare(b, undefined, { sensitivity: "accent" }); +} diff --git a/packages/cli/src/internal/foundations/chopsticksHelpers.ts b/packages/cli/src/internal/foundations/chopsticksHelpers.ts index c2e68330..3cd2a4f7 100644 --- a/packages/cli/src/internal/foundations/chopsticksHelpers.ts +++ b/packages/cli/src/internal/foundations/chopsticksHelpers.ts @@ -5,7 +5,6 @@ import { ApiTypes, AugmentedEvent } from "@polkadot/api/types"; import { FrameSystemEventRecord } from "@polkadot/types/lookup"; import chalk from "chalk"; import { setTimeout } from "timers/promises"; -import { assert } from "vitest"; import { MoonwallContext } from "../../lib/globalContext"; export async function getWsFromConfig(providerName?: string): Promise { @@ -93,7 +92,10 @@ export async function createChopsticksBlock( } return found; }); - assert(match, "Expected events not present in block"); + + if (!match) { + throw new Error("Expected events not present in block"); + } } if (options && options.allowFailures === true) { diff --git a/packages/cli/src/internal/foundations/devModeHelpers.ts b/packages/cli/src/internal/foundations/devModeHelpers.ts index 3c1f21a5..f93894e6 100644 --- a/packages/cli/src/internal/foundations/devModeHelpers.ts +++ b/packages/cli/src/internal/foundations/devModeHelpers.ts @@ -13,7 +13,6 @@ import { EventRecord } from "@polkadot/types/interfaces"; import chalk from "chalk"; import Debug from "debug"; import { setTimeout } from "timers/promises"; -import { assert } from "vitest"; import { getEnvironmentFromConfig, importAsyncConfig, @@ -204,7 +203,10 @@ export async function createDevBlock< } return found; }); - assert(match, "Expected events not present in block"); + + if (!match) { + throw new Error("Expected events not present in block"); + } } if (!options.allowFailures) { diff --git a/packages/cli/src/internal/foundations/index.ts b/packages/cli/src/internal/foundations/index.ts new file mode 100644 index 00000000..633e0969 --- /dev/null +++ b/packages/cli/src/internal/foundations/index.ts @@ -0,0 +1,3 @@ +export * from "./chopsticksHelpers"; +export * from "./devModeHelpers"; +export * from "./zombieHelpers"; diff --git a/packages/cli/src/internal/index.ts b/packages/cli/src/internal/index.ts new file mode 100644 index 00000000..ff493ede --- /dev/null +++ b/packages/cli/src/internal/index.ts @@ -0,0 +1,10 @@ +export * from "./logging"; +export * from "./cmdFunctions"; +export * from "./commandParsers"; +export * from "./deriveTestIds"; +export * from "./fileCheckers"; +export * from "./foundations"; +export * from "./launcherCommon"; +export * from "./localNode"; +export * from "./processHelpers"; +export * from "./providerFactories"; diff --git a/packages/cli/src/lib/governanceProcedures.ts b/packages/cli/src/lib/governanceProcedures.ts index f1fe434a..1218fa11 100644 --- a/packages/cli/src/lib/governanceProcedures.ts +++ b/packages/cli/src/lib/governanceProcedures.ts @@ -1,13 +1,5 @@ import "@moonbeam-network/api-augment"; -import { expect } from "vitest"; -import type { ApiPromise } from "@polkadot/api"; -import { ApiTypes, SubmittableExtrinsic } from "@polkadot/api/types"; -import { KeyringPair } from "@polkadot/keyring/types"; -import { - PalletDemocracyReferendumInfo, - PalletReferendaReferendumInfo, -} from "@polkadot/types/lookup"; -import { blake2AsHex } from "@polkadot/util-crypto"; +import { DevModeContext } from "@moonwall/types"; import { GLMR, alith, @@ -19,9 +11,15 @@ import { filterAndApply, signAndSend, } from "@moonwall/util"; -import { DevModeContext } from "@moonwall/types"; -import { fastFowardToNextEvent } from "../internal/foundations/devModeHelpers"; -import { ISubmittableResult } from "@polkadot/types/types"; +import type { ApiPromise } from "@polkadot/api"; +import { ApiTypes, SubmittableExtrinsic } from "@polkadot/api/types"; +import { KeyringPair } from "@polkadot/keyring/types"; +import { + PalletDemocracyReferendumInfo, + PalletReferendaReferendumInfo, +} from "@polkadot/types/lookup"; +import { blake2AsHex } from "@polkadot/util-crypto"; +import { fastFowardToNextEvent } from "../internal"; export const COUNCIL_MEMBERS: KeyringPair[] = [baltathar, charleth, dorothy]; export const COUNCIL_THRESHOLD = Math.ceil((COUNCIL_MEMBERS.length * 2) / 3); @@ -445,8 +443,10 @@ export const execTechnicalCommitteeProposal = async < return proposalResult; } - expect(proposalResult.successful, `Council proposal refused: ${proposalResult?.error?.name}`).to - .be.true; + if (!proposalResult.successful) { + throw `Council proposal refused: ${proposalResult?.error?.name}`; + } + const proposalHash = proposalResult.events .find(({ event: { method } }) => method.toString() === "Proposed") ?.event.data[2].toHex(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6050fc2c..797bda10 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -344,6 +344,10 @@ importers: version: 5.3.3 test: + dependencies: + inquirer: + specifier: 9.2.13 + version: 9.2.13 devDependencies: '@acala-network/chopsticks': specifier: 0.9.7 @@ -372,9 +376,15 @@ importers: '@polkadot/util': specifier: 12.6.2 version: 12.6.2 + '@types/inquirer': + specifier: ^9.0.7 + version: 9.0.7 '@types/node': specifier: 20.11.5 version: 20.11.5 + '@types/yargs': + specifier: 17.0.32 + version: 17.0.32 '@vitest/ui': specifier: 1.2.2 version: 1.2.2(vitest@1.2.2) @@ -2105,6 +2115,13 @@ packages: /@types/estree@1.0.5: resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==} + /@types/inquirer@9.0.7: + resolution: {integrity: sha512-Q0zyBupO6NxGRZut/JdmqYKOnN95Eg5V8Csg3PGKkP+FnvsUZx1jAyK7fztIszxxMuoBA6E3KXWvdZVXIpx60g==} + dependencies: + '@types/through': 0.0.33 + rxjs: 7.8.1 + dev: true + /@types/json-schema@7.0.15: resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} dev: true @@ -2165,6 +2182,12 @@ packages: resolution: {integrity: sha512-dn1l8LaMea/IjDoHNd9J52uBbInB796CDffS6VdIxvqYCPSG0V0DzHp76GpaWnlhg88uYyPbXCDIowa86ybd5A==} dev: true + /@types/through@0.0.33: + resolution: {integrity: sha512-HsJ+z3QuETzP3cswwtzt2vEIiHBk/dCcHGhbmG5X3ecnwFD/lPrMpliGXxSCg03L9AhrdwA4Oz/qfspkDW+xGQ==} + dependencies: + '@types/node': 20.11.13 + dev: true + /@types/web-bluetooth@0.0.20: resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==} dev: true diff --git a/test/package.json b/test/package.json index 834fb6ed..4ad04fc0 100644 --- a/test/package.json +++ b/test/package.json @@ -26,7 +26,9 @@ "@openzeppelin/contracts": "4.9.3", "@polkadot/api": "10.11.2", "@polkadot/util": "12.6.2", + "@types/inquirer": "^9.0.7", "@types/node": "20.11.5", + "@types/yargs": "17.0.32", "@vitest/ui": "1.2.2", "chai": "5.0.0", "chalk": "5.3.0", @@ -39,5 +41,8 @@ "vitest": "1.2.2", "web3": "4.4.0", "yargs": "17.7.2" + }, + "dependencies": { + "inquirer": "9.2.13" } } diff --git a/test/scripts/derive-test-ids.ts b/test/scripts/derive-test-ids.ts new file mode 100644 index 00000000..8765f379 --- /dev/null +++ b/test/scripts/derive-test-ids.ts @@ -0,0 +1,160 @@ +/** + * This script is designed to update test suite IDs within a directory structure, + * mimicking the default behavior of Visual Studio Code's file explorer. It reads + * through a directory, finds files with a specific function call (`describeSuite`), + * and updates the suite's ID based on the file's position within the directory tree. + * + * The naming convention for suite IDs follows these rules: + * 1. A prefix derived from the directory name. + * 2. Directories are represented by a 2-digit number. + * 3. Files are represented by a 2-digit number. + * + * Note: The script's sorting logic prioritizes, to match VSC's default behavior: + * 1. Files with special characters or spaces. + * 2. Files in a case-insensitive lexicographical order. + */ +import chalk from "chalk"; +import fs from "fs"; +import inquirer from "inquirer"; +import path from "path"; +import yargs from "yargs"; +import { hideBin } from "yargs/helpers"; + +yargs(hideBin(process.argv)) + .usage("Usage: $0") + .version("2.0.0") + .command<{ rootDir: string }>( + "process ", + "Changes the testsuite IDs based on positional order in the directory tree.", + (yargs) => { + return yargs.positional("rootDir", { + describe: "Input path for plainSpecFile to modify", + type: "string", + }); + }, + async (argv: { rootDir: string }) => { + deriveTestIds(argv.rootDir); + } + ) + .help() + .parse(); + +export async function deriveTestIds(rootDir: string) { + const usedPrefixes: Set = new Set(); + console.log(`đŸŸĸ Processing ${rootDir} ...`); + const topLevelDirs = getTopLevelDirs(rootDir); + + const foldersToRename: { prefix: string; dir: string }[] = []; + + for (const dir of topLevelDirs) { + const prefix = generatePrefix(dir, usedPrefixes); + foldersToRename.push({ prefix, dir }); + } + + const result = await inquirer.prompt({ + type: "confirm", + name: "confirm", + message: `This will rename ${foldersToRename.length} suites IDs in ${rootDir}, continue?`, + }); + + if (!result.confirm) { + console.log("🔴 Aborted"); + return; + } + + for (const folder of foldersToRename) { + const { prefix, dir } = folder; + process.stdout.write( + `đŸŸĸ Changing suite ${dir} to use prefix ${chalk.bold(`(${prefix})`)} ....` + ); + + generateId(path.join(rootDir, dir), rootDir, prefix); + process.stdout.write(" Done ✅\n"); + } + + console.log(`🏁 Finished renaming rootdir ${chalk.bold(`/${rootDir}`)}`); +} + +function getTopLevelDirs(rootDir: string): string[] { + return fs + .readdirSync(rootDir) + .filter((dir) => fs.statSync(path.join(rootDir, dir)).isDirectory()); +} + +function generatePrefix(directory: string, usedPrefixes: Set): string { + let prefix = directory[0].toUpperCase(); + + if (usedPrefixes.has(prefix)) { + const match = directory.match(/[-_](\w)/); + if (match) { + // if directory name has a '-' or '_' + prefix += match[1].toUpperCase(); + } else { + prefix = directory[1].toUpperCase(); + } + } + + while (usedPrefixes.has(prefix)) { + const charCode = prefix.charCodeAt(1); + if (charCode >= 90) { + // If it's Z, wrap around to A + prefix = `${String.fromCharCode(prefix.charCodeAt(0) + 1)}A`; + } else { + prefix = prefix[0] + String.fromCharCode(charCode + 1); + } + } + + usedPrefixes.add(prefix); + return prefix; +} + +function generateId(directory: string, rootDir: string, prefix: string): void { + const contents = fs.readdirSync(directory); + + contents.sort((a, b) => { + const aIsDir = fs.statSync(path.join(directory, a)).isDirectory(); + const bIsDir = fs.statSync(path.join(directory, b)).isDirectory(); + + if (aIsDir && !bIsDir) return -1; + if (!aIsDir && bIsDir) return 1; + return customFileSort(a, b); + }); + + let fileCount = 1; + let subDirCount = 1; + + for (const item of contents) { + const fullPath = path.join(directory, item); + + if (fs.statSync(fullPath).isDirectory()) { + const subDirPrefix = `0${subDirCount}`.slice(-2); + generateId(fullPath, rootDir, prefix + subDirPrefix); + subDirCount++; + } else { + const fileContent = fs.readFileSync(fullPath, "utf-8"); + if (fileContent.includes("describeSuite")) { + const newId = prefix + `0${fileCount}`.slice(-2); + const updatedContent = fileContent.replace( + /(describeSuite\s*?\(\s*?\{\s*?id\s*?:\s*?['"])[^'"]+(['"])/, + `$1${newId}$2` + ); + fs.writeFileSync(fullPath, updatedContent); + } + fileCount++; + } + } +} + +function hasSpecialCharacters(filename: string): boolean { + return /[ \t!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]+/.test(filename); +} + +function customFileSort(a: string, b: string): number { + const aHasSpecialChars = hasSpecialCharacters(a); + const bHasSpecialChars = hasSpecialCharacters(b); + + if (aHasSpecialChars && !bHasSpecialChars) return -1; + if (!aHasSpecialChars && bHasSpecialChars) return 1; + + return a.localeCompare(b, undefined, { sensitivity: "accent" }); +}