Merge branch 'master' into sq_fixes
CI / Rust Format (pull_request) Successful in 19s
CI / TypeScript Lint + Typecheck (pull_request) Successful in 28s
CI / Rust Tests + Coverage (pull_request) Successful in 41s
CI / TypeScript Tests + Coverage (pull_request) Successful in 1m2s
CI / Dependency-Track (BOM) (pull_request) Successful in 22s
CI / Clippy (SARIF) (pull_request) Successful in 1m44s
CI / SonarQube (pull_request) Successful in 43s
CI / Electron Release Build (pull_request) Successful in 3m25s
CI / E2E Tests (Playwright + Electron) (pull_request) Successful in 6m45s
CI / Rust Format (pull_request) Successful in 19s
CI / TypeScript Lint + Typecheck (pull_request) Successful in 28s
CI / Rust Tests + Coverage (pull_request) Successful in 41s
CI / TypeScript Tests + Coverage (pull_request) Successful in 1m2s
CI / Dependency-Track (BOM) (pull_request) Successful in 22s
CI / Clippy (SARIF) (pull_request) Successful in 1m44s
CI / SonarQube (pull_request) Successful in 43s
CI / Electron Release Build (pull_request) Successful in 3m25s
CI / E2E Tests (Playwright + Electron) (pull_request) Successful in 6m45s
# Conflicts: # sonar-project.properties # ui/src/components/CommandPalette/CommandPalette.tsx # ui/src/components/Dock/DockRightActions.tsx # ui/src/components/FileTree/FileTree.tsx # ui/src/components/FindReplace/FindReplace.tsx # ui/src/components/MarkdownPreview/MarkdownPreview.tsx # ui/src/components/Preferences/Preferences.tsx # ui/src/components/TitleBar/TitleBar.tsx # ui/vitest.config.ts
This commit is contained in:
+11
-1
@@ -93,7 +93,9 @@ jobs:
|
||||
--profile ci \
|
||||
--lcov --output-path target/lcov.info
|
||||
python3 scripts/junit_to_sonar_test_execution.py \
|
||||
--repo-root "${GITHUB_WORKSPACE:-.}"
|
||||
--flavor rust \
|
||||
--repo-root "${GITHUB_WORKSPACE:-.}" \
|
||||
--strict
|
||||
echo "=== sonar-test-execution.xml ==="
|
||||
cat target/nextest/ci/sonar-test-execution.xml || echo "(file not found)"
|
||||
|
||||
@@ -150,12 +152,20 @@ jobs:
|
||||
run: npm run test:ci
|
||||
working-directory: ui
|
||||
|
||||
- name: JUnit → Sonar test execution (Vitest)
|
||||
run: |
|
||||
python3 scripts/junit_to_sonar_test_execution.py \
|
||||
--flavor vitest \
|
||||
--repo-root "${GITHUB_WORKSPACE:-.}" \
|
||||
--strict
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: ts-test-reports
|
||||
path: |
|
||||
coverage/lcov.info
|
||||
test-results/junit.xml
|
||||
test-results/sonar-test-execution.xml
|
||||
if-no-files-found: warn
|
||||
|
||||
build-electron:
|
||||
|
||||
@@ -2,7 +2,12 @@
|
||||
#
|
||||
# macOS: runner must match ALL labels below (homelab: macos-14, apple_silicon).
|
||||
# If the job never schedules, add the same labels on the runner or include `self-hosted` here and on the runner.
|
||||
# Windows: optional job below is disabled until a matching runner exists.
|
||||
# Do not add Swatinem/rust-cache to this mac job unless you fix sudo (purge in its post step).
|
||||
# Do not use dtolnay/rust-toolchain on self-hosted mac act runners — its post step can hit
|
||||
# permission denied on the action's cached .git under ~/.cache/act (job then fails in finally).
|
||||
# Windows (self-hosted): avoid dtolnay/rust-toolchain — its composite steps use bash
|
||||
# in a way that breaks on some Gitea/act Windows runners; use rustup in PowerShell instead.
|
||||
# Runner should have rustup on PATH, Git, and usually VS Build Tools (MSVC) for linking.
|
||||
#
|
||||
# Artifact note: keep upload-artifact@v3 for Gitea/GHES compatibility.
|
||||
# Artifact names use <major>.<minor>.<run_number> (see scripts/artifact-suffix.mjs).
|
||||
@@ -13,6 +18,7 @@ on:
|
||||
push:
|
||||
# Keep both: CI uses `master` today; some forks use `main`.
|
||||
branches: [main, master]
|
||||
tags: ['v*']
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
@@ -20,8 +26,7 @@ env:
|
||||
|
||||
jobs:
|
||||
build-windows:
|
||||
# Enable when a Windows runner is available (or point runs-on at your label set).
|
||||
if: ${{ false }}
|
||||
if: ${{ true }}
|
||||
name: Windows Electron Build
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 60
|
||||
@@ -32,9 +37,60 @@ jobs:
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: package-lock.json
|
||||
cache-dependency-path: |
|
||||
package-lock.json
|
||||
ui/package-lock.json
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
# dtolnay/rust-toolchain composite uses bash + generated scripts; Gitea act on Windows
|
||||
# often fails with "No such file or directory" for those paths.
|
||||
# Accept either rustup-managed Rust or a preinstalled cargo/rustc toolchain.
|
||||
- name: Configure Rust toolchain
|
||||
shell: pwsh
|
||||
run: |
|
||||
$candidateBins = @(
|
||||
(Join-Path $env:USERPROFILE ".cargo\bin"),
|
||||
"C:\Program Files\Rust stable GNU 1.95\bin",
|
||||
"C:\Program Files\Rust stable GNU\bin",
|
||||
"C:\Program Files\Rust\bin"
|
||||
) | Select-Object -Unique
|
||||
|
||||
foreach ($bin in $candidateBins) {
|
||||
if (Test-Path $bin) {
|
||||
$env:PATH = "$bin;$env:PATH"
|
||||
Write-Host "Added $bin to PATH for this job."
|
||||
}
|
||||
}
|
||||
|
||||
$rustup = Get-Command rustup -ErrorAction SilentlyContinue
|
||||
if (-not $rustup) {
|
||||
Write-Warning "rustup not found; attempting user-local bootstrap via rustup-init."
|
||||
$installer = Join-Path $env:TEMP "rustup-init.exe"
|
||||
try {
|
||||
Invoke-WebRequest "https://win.rustup.rs/x86_64" -OutFile $installer
|
||||
& $installer -y --default-host x86_64-pc-windows-msvc --profile minimal --default-toolchain stable
|
||||
} catch {
|
||||
Write-Warning "rustup bootstrap failed: $($_.Exception.Message)"
|
||||
}
|
||||
$userCargoBin = Join-Path $env:USERPROFILE ".cargo\bin"
|
||||
if (Test-Path $userCargoBin) {
|
||||
$env:PATH = "$userCargoBin;$env:PATH"
|
||||
}
|
||||
$rustup = Get-Command rustup -ErrorAction SilentlyContinue
|
||||
}
|
||||
if ($rustup) {
|
||||
rustup toolchain install stable --profile minimal
|
||||
rustup default stable-x86_64-pc-windows-msvc
|
||||
rustup target add x86_64-pc-windows-msvc
|
||||
rustup show
|
||||
} else {
|
||||
$cargo = Get-Command cargo -ErrorAction SilentlyContinue
|
||||
$rustc = Get-Command rustc -ErrorAction SilentlyContinue
|
||||
if (-not $cargo -or -not $rustc) {
|
||||
Write-Error "Neither rustup nor cargo/rustc are available after PATH probing and rustup bootstrap. Checked: $($candidateBins -join ', '). Install Rust from https://rustup.rs/ for the runner user."
|
||||
exit 1
|
||||
}
|
||||
Write-Warning "rustup not found; using preinstalled cargo/rustc from PATH."
|
||||
}
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
@@ -46,13 +102,26 @@ jobs:
|
||||
run: |
|
||||
node --version
|
||||
npm --version
|
||||
if (Get-Command rustup -ErrorAction SilentlyContinue) {
|
||||
rustup --version
|
||||
} else {
|
||||
Write-Warning "rustup not found during verification; proceeding with cargo/rustc-only toolchain."
|
||||
}
|
||||
rustc --version
|
||||
cargo --version
|
||||
where.exe cl.exe
|
||||
if (Get-Command cl.exe -ErrorAction SilentlyContinue) {
|
||||
where.exe cl.exe
|
||||
} else {
|
||||
Write-Warning "MSVC cl.exe not on PATH. If cargo link fails, install Visual Studio Build Tools (Desktop development with C++)."
|
||||
}
|
||||
|
||||
- name: Install root npm dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Install UI dependencies
|
||||
run: npm ci
|
||||
working-directory: ui
|
||||
|
||||
- name: Build Electron app (Rust + UI + Forge make)
|
||||
run: npm run electron:build
|
||||
|
||||
@@ -70,10 +139,24 @@ jobs:
|
||||
GITHUB_RUN_NUMBER: ${{ github.run_number }}
|
||||
run: node scripts/artifact-suffix.mjs
|
||||
|
||||
- name: Stage clean Windows artifacts
|
||||
shell: pwsh
|
||||
env:
|
||||
ARTIFACT_SUFFIX: ${{ steps.artifact_ver.outputs.suffix }}
|
||||
run: |
|
||||
$outDir = "out/artifacts"
|
||||
New-Item -ItemType Directory -Path $outDir -Force | Out-Null
|
||||
|
||||
$setup = Get-ChildItem -Recurse "out/make" -Filter "*Setup*.exe" | Select-Object -First 1
|
||||
if (-not $setup) { throw "Could not find Windows Setup .exe under out/make" }
|
||||
Copy-Item $setup.FullName (Join-Path $outDir "bytedraft-windows-$env:ARTIFACT_SUFFIX-setup.exe")
|
||||
|
||||
Get-ChildItem -File $outDir | ForEach-Object { Write-Host "Staged artifact: $($_.Name)" }
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: bytedraft-windows-${{ steps.artifact_ver.outputs.suffix }}
|
||||
path: out/make/
|
||||
path: out/artifacts/*
|
||||
if-no-files-found: error
|
||||
|
||||
build-macos-arm64:
|
||||
@@ -87,14 +170,22 @@ jobs:
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: package-lock.json
|
||||
cache-dependency-path: |
|
||||
package-lock.json
|
||||
ui/package-lock.json
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: bytedraft-release-macos-arm64
|
||||
cache-on-failure: true
|
||||
# Same rationale as Windows: avoid dtolnay/rust-toolchain (composite + fragile post on act).
|
||||
# No Swatinem/rust-cache: post-step runs `sudo purge` on macOS and can hang without NOPASSWD sudo.
|
||||
- name: Install Rust stable (rustup)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if ! command -v rustup >/dev/null 2>&1; then
|
||||
echo '::error::rustup is not on PATH. Install Rust for the Gitea runner user (https://rustup.rs/) and restart the runner.'
|
||||
exit 1
|
||||
fi
|
||||
rustup toolchain install stable --profile minimal
|
||||
rustup default stable
|
||||
rustup show
|
||||
|
||||
- name: Verify native build tools
|
||||
run: |
|
||||
@@ -108,6 +199,11 @@ jobs:
|
||||
- name: Install root npm dependencies
|
||||
run: npm ci
|
||||
|
||||
# Required: `electron:build` runs `npm --prefix ui run build`; UI deps are not hoisted to root.
|
||||
- name: Install UI dependencies
|
||||
run: npm ci
|
||||
working-directory: ui
|
||||
|
||||
- name: Build Electron app (Rust + UI + Forge make)
|
||||
run: npm run electron:build
|
||||
|
||||
@@ -122,8 +218,118 @@ jobs:
|
||||
GITHUB_RUN_NUMBER: ${{ github.run_number }}
|
||||
run: node scripts/artifact-suffix.mjs
|
||||
|
||||
- name: Stage clean macOS artifacts
|
||||
env:
|
||||
ARTIFACT_SUFFIX: ${{ steps.artifact_ver.outputs.suffix }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p out/artifacts
|
||||
dmg_file="$(find out/make -type f -name '*.dmg' | head -n 1)"
|
||||
if [ -z "${dmg_file}" ]; then
|
||||
echo "::error::Could not find .dmg under out/make"
|
||||
exit 1
|
||||
fi
|
||||
cp "${dmg_file}" "out/artifacts/bytedraft-macos-arm64-${ARTIFACT_SUFFIX}.dmg"
|
||||
ls -lh out/artifacts
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: bytedraft-macos-arm64-${{ steps.artifact_ver.outputs.suffix }}
|
||||
path: out/make/
|
||||
path: out/artifacts/*
|
||||
if-no-files-found: error
|
||||
|
||||
publish-release:
|
||||
name: Publish Gitea Release
|
||||
needs: [build-windows, build-macos-arm64]
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Artifact name suffix (major.minor.run_number)
|
||||
id: artifact_ver
|
||||
env:
|
||||
GITHUB_RUN_NUMBER: ${{ github.run_number }}
|
||||
run: node scripts/artifact-suffix.mjs
|
||||
|
||||
- name: Download Windows artifact bundle
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: bytedraft-windows-${{ steps.artifact_ver.outputs.suffix }}
|
||||
path: release-assets/windows
|
||||
|
||||
- name: Download macOS artifact bundle
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: bytedraft-macos-arm64-${{ steps.artifact_ver.outputs.suffix }}
|
||||
path: release-assets/macos
|
||||
|
||||
- name: Publish release with attached binaries (Gitea API)
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITEA_SERVER_URL: ${{ github.server_url }}
|
||||
REPO: ${{ github.repository }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
api="${GITEA_SERVER_URL}/api/v1/repos/${REPO}"
|
||||
|
||||
echo "Preparing release for tag: ${TAG}"
|
||||
echo "Repository: ${REPO}"
|
||||
echo "API: ${api}"
|
||||
echo "Files to upload:"
|
||||
ls -lh release-assets/windows release-assets/macos
|
||||
|
||||
existing="$(curl -sS \
|
||||
-H "Authorization: token ${GITHUB_TOKEN}" \
|
||||
"${api}/releases/tags/${TAG}" || true)"
|
||||
existing_id="$(
|
||||
python3 -c 'import json,sys; s=sys.stdin.read().strip(); print((lambda d: d.get("id","") if isinstance(d,dict) else "")(json.loads(s)) if s else "")' <<< "${existing}" 2>/dev/null || true
|
||||
)"
|
||||
|
||||
if [ -n "${existing_id}" ]; then
|
||||
release_id="${existing_id}"
|
||||
echo "Using existing release id=${release_id}"
|
||||
else
|
||||
payload="{\"tag_name\":\"${TAG}\",\"name\":\"ByteDraft ${TAG}\",\"draft\":false,\"prerelease\":false}"
|
||||
created="$(curl -sS -X POST \
|
||||
-H "Authorization: token ${GITHUB_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "${payload}" \
|
||||
"${api}/releases" || true)"
|
||||
release_id="$(
|
||||
python3 -c 'import json,sys; s=sys.stdin.read().strip(); print((lambda d: d.get("id","") if isinstance(d,dict) else "")(json.loads(s)) if s else "")' <<< "${created}" 2>/dev/null || true
|
||||
)"
|
||||
if [ -z "${release_id}" ]; then
|
||||
echo "::error::Failed to create release for tag ${TAG}. API response:"
|
||||
echo "${created}"
|
||||
exit 1
|
||||
fi
|
||||
echo "Created release id=${release_id}"
|
||||
fi
|
||||
|
||||
for f in release-assets/windows/* release-assets/macos/*; do
|
||||
[ -f "${f}" ] || continue
|
||||
name="$(basename "${f}")"
|
||||
# Idempotent uploads: if asset exists for this release, remove it first.
|
||||
existing_asset_id="$(
|
||||
curl -fsS \
|
||||
-H "Authorization: token ${GITHUB_TOKEN}" \
|
||||
"${api}/releases/${release_id}/assets" \
|
||||
| python3 -c 'import json,sys; target=sys.argv[1]; data=json.load(sys.stdin); print(next((str(a.get("id","")) for a in data if isinstance(a,dict) and a.get("name")==target), "") if isinstance(data,list) else "")' "${name}" 2>/dev/null || true
|
||||
)"
|
||||
if [ -n "${existing_asset_id}" ]; then
|
||||
echo "Removing existing asset ${name} (id=${existing_asset_id})"
|
||||
curl -fsS -X DELETE \
|
||||
-H "Authorization: token ${GITHUB_TOKEN}" \
|
||||
"${api}/releases/${release_id}/assets/${existing_asset_id}" >/dev/null
|
||||
fi
|
||||
echo "Uploading ${name}"
|
||||
curl -fsS -X POST \
|
||||
-H "Authorization: token ${GITHUB_TOKEN}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary @"${f}" \
|
||||
"${api}/releases/${release_id}/assets?name=${name}" >/dev/null
|
||||
done
|
||||
|
||||
echo "Release publish complete."
|
||||
|
||||
+5
-2
@@ -48,6 +48,8 @@ if [[ "$FAST" -eq 1 ]]; then
|
||||
run "tests (fast)" cargo nextest run --workspace
|
||||
else
|
||||
run "tests + coverage" cargo llvm-cov nextest --workspace --profile ci --lcov --output-path target/lcov.info
|
||||
run "junit → Sonar test execution (Rust)" \
|
||||
python3 scripts/junit_to_sonar_test_execution.py --flavor rust --repo-root . --strict
|
||||
fi
|
||||
|
||||
# ── 4. Cargo Deny (optional) ────────────────────────────────────────────────────
|
||||
@@ -66,8 +68,9 @@ if [ -d "ui" ]; then
|
||||
(cd ui && npm run typecheck)
|
||||
|
||||
echo ""
|
||||
echo "=== TypeScript unit tests ==="
|
||||
(cd ui && npm run test:ci)
|
||||
run "TypeScript unit tests" bash -c "cd ui && npm run test:ci"
|
||||
run "junit → Sonar test execution (Vitest)" \
|
||||
python3 scripts/junit_to_sonar_test_execution.py --flavor vitest --repo-root . --strict
|
||||
fi
|
||||
|
||||
# Optional: full E2E suite (Playwright browser + Electron projects)
|
||||
|
||||
@@ -80,103 +80,101 @@ fn format_json(text: &str) -> Result<String, Vec<Diagnostic>> {
|
||||
/// Strip `//` line comments and `/* */` block comments from JSONC text.
|
||||
/// Newlines inside block comments are preserved so line numbers stay accurate.
|
||||
fn strip_jsonc_comments(text: &str) -> String {
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let bytes = text.as_bytes();
|
||||
let mut i = 0;
|
||||
let mut in_string = false;
|
||||
let mut in_line_comment = false;
|
||||
let mut in_block_comment = false;
|
||||
JsoncParser::new(text.as_bytes()).strip()
|
||||
}
|
||||
|
||||
while i < bytes.len() {
|
||||
if in_line_comment {
|
||||
i += advance_line_comment(bytes[i], &mut out, &mut in_line_comment);
|
||||
} else if in_block_comment {
|
||||
i += advance_block_comment(bytes, i, &mut out, &mut in_block_comment);
|
||||
} else if in_string {
|
||||
i += advance_in_string(bytes, i, &mut out, &mut in_string);
|
||||
} else {
|
||||
i += advance_normal(
|
||||
bytes,
|
||||
i,
|
||||
&mut out,
|
||||
&mut in_string,
|
||||
&mut in_line_comment,
|
||||
&mut in_block_comment,
|
||||
);
|
||||
struct JsoncParser<'a> {
|
||||
bytes: &'a [u8],
|
||||
in_string: bool,
|
||||
in_line_comment: bool,
|
||||
in_block_comment: bool,
|
||||
}
|
||||
|
||||
impl<'a> JsoncParser<'a> {
|
||||
fn new(bytes: &'a [u8]) -> Self {
|
||||
Self {
|
||||
bytes,
|
||||
in_string: false,
|
||||
in_line_comment: false,
|
||||
in_block_comment: false,
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn advance_line_comment(b: u8, out: &mut String, in_line_comment: &mut bool) -> usize {
|
||||
if b == b'\n' {
|
||||
*in_line_comment = false;
|
||||
out.push('\n');
|
||||
fn strip(mut self) -> String {
|
||||
let mut out = String::with_capacity(self.bytes.len());
|
||||
let mut i = 0;
|
||||
while i < self.bytes.len() {
|
||||
i += if self.in_line_comment {
|
||||
self.advance_line_comment(i, &mut out)
|
||||
} else if self.in_block_comment {
|
||||
self.advance_block_comment(i, &mut out)
|
||||
} else if self.in_string {
|
||||
self.advance_in_string(i, &mut out)
|
||||
} else {
|
||||
self.advance_normal(i, &mut out)
|
||||
};
|
||||
}
|
||||
out
|
||||
}
|
||||
1
|
||||
}
|
||||
|
||||
fn advance_block_comment(
|
||||
bytes: &[u8],
|
||||
i: usize,
|
||||
out: &mut String,
|
||||
in_block_comment: &mut bool,
|
||||
) -> usize {
|
||||
if bytes[i] == b'*' && i + 1 < bytes.len() && bytes[i + 1] == b'/' {
|
||||
*in_block_comment = false;
|
||||
2
|
||||
} else {
|
||||
if bytes[i] == b'\n' {
|
||||
fn advance_line_comment(&mut self, i: usize, out: &mut String) -> usize {
|
||||
if self.bytes[i] == b'\n' {
|
||||
self.in_line_comment = false;
|
||||
out.push('\n');
|
||||
}
|
||||
1
|
||||
}
|
||||
}
|
||||
|
||||
fn advance_in_string(bytes: &[u8], i: usize, out: &mut String, in_string: &mut bool) -> usize {
|
||||
let b = bytes[i];
|
||||
out.push(b as char);
|
||||
if b == b'\\' && i + 1 < bytes.len() {
|
||||
out.push(bytes[i + 1] as char);
|
||||
2
|
||||
} else {
|
||||
fn advance_block_comment(&mut self, i: usize, out: &mut String) -> usize {
|
||||
if self.bytes[i] == b'*' && i + 1 < self.bytes.len() && self.bytes[i + 1] == b'/' {
|
||||
self.in_block_comment = false;
|
||||
2
|
||||
} else {
|
||||
if self.bytes[i] == b'\n' {
|
||||
out.push('\n');
|
||||
}
|
||||
1
|
||||
}
|
||||
}
|
||||
|
||||
fn advance_in_string(&mut self, i: usize, out: &mut String) -> usize {
|
||||
let b = self.bytes[i];
|
||||
out.push(b as char);
|
||||
if b == b'\\' && i + 1 < self.bytes.len() {
|
||||
out.push(self.bytes[i + 1] as char);
|
||||
2
|
||||
} else {
|
||||
if b == b'"' {
|
||||
self.in_string = false;
|
||||
}
|
||||
1
|
||||
}
|
||||
}
|
||||
|
||||
fn advance_normal(&mut self, i: usize, out: &mut String) -> usize {
|
||||
let b = self.bytes[i];
|
||||
if b == b'"' {
|
||||
*in_string = false;
|
||||
self.in_string = true;
|
||||
out.push('"');
|
||||
} else if b == b'/' && i + 1 < self.bytes.len() {
|
||||
match self.bytes[i + 1] {
|
||||
b'/' => {
|
||||
self.in_line_comment = true;
|
||||
return 2;
|
||||
}
|
||||
b'*' => {
|
||||
self.in_block_comment = true;
|
||||
return 2;
|
||||
}
|
||||
_ => out.push('/'),
|
||||
}
|
||||
} else {
|
||||
out.push(b as char);
|
||||
}
|
||||
1
|
||||
}
|
||||
}
|
||||
|
||||
fn advance_normal(
|
||||
bytes: &[u8],
|
||||
i: usize,
|
||||
out: &mut String,
|
||||
in_string: &mut bool,
|
||||
in_line_comment: &mut bool,
|
||||
in_block_comment: &mut bool,
|
||||
) -> usize {
|
||||
let b = bytes[i];
|
||||
if b == b'"' {
|
||||
*in_string = true;
|
||||
out.push('"');
|
||||
} else if b == b'/' && i + 1 < bytes.len() {
|
||||
match bytes[i + 1] {
|
||||
b'/' => {
|
||||
*in_line_comment = true;
|
||||
return 2;
|
||||
}
|
||||
b'*' => {
|
||||
*in_block_comment = true;
|
||||
return 2;
|
||||
}
|
||||
_ => out.push('/'),
|
||||
}
|
||||
} else {
|
||||
out.push(b as char);
|
||||
}
|
||||
1
|
||||
}
|
||||
|
||||
fn format_yaml(text: &str) -> Result<String, Vec<Diagnostic>> {
|
||||
let v: serde_yaml::Value =
|
||||
serde_yaml::from_str(text).map_err(|e| vec![Diagnostic::new(e.to_string())])?;
|
||||
@@ -471,10 +469,14 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
fn strip(input: &str) -> String {
|
||||
JsoncParser::new(input.as_bytes()).strip()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_jsonc_comments_line_comment() {
|
||||
let input = "{ // comment\n\"k\": 1}";
|
||||
let stripped = strip_jsonc_comments(input);
|
||||
let stripped = strip(input);
|
||||
assert!(!stripped.contains("//"));
|
||||
assert!(stripped.contains('\n'), "newlines must be preserved");
|
||||
}
|
||||
@@ -482,37 +484,34 @@ mod tests {
|
||||
#[test]
|
||||
fn strip_jsonc_comments_ignores_url_in_string() {
|
||||
let input = r#"{"url": "https://example.com"}"#;
|
||||
let stripped = strip_jsonc_comments(input);
|
||||
assert_eq!(
|
||||
stripped, input,
|
||||
strip(input),
|
||||
input,
|
||||
"// inside a string value must not be stripped"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_jsonc_comments_empty_input_returns_empty() {
|
||||
assert_eq!(strip_jsonc_comments(""), "");
|
||||
assert_eq!(strip(""), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_jsonc_comments_input_with_no_comments_unchanged() {
|
||||
let input = r#"{"a":1,"b":[2,3]}"#;
|
||||
assert_eq!(strip_jsonc_comments(input), input);
|
||||
assert_eq!(strip(input), input);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_jsonc_comments_block_comment_removed() {
|
||||
let input = r#"{"a": /* remove me */ 1}"#;
|
||||
let stripped = strip_jsonc_comments(input);
|
||||
let stripped = strip(r#"{"a": /* remove me */ 1}"#);
|
||||
assert!(!stripped.contains("remove me"));
|
||||
assert!(stripped.contains('"'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_jsonc_comments_block_comment_preserves_newlines() {
|
||||
let input = "{\n/* line1\nline2 */\n\"k\":1}";
|
||||
let stripped = strip_jsonc_comments(input);
|
||||
// The two newlines inside the block comment must still be in the output
|
||||
let stripped = strip("{\n/* line1\nline2 */\n\"k\":1}");
|
||||
assert_eq!(stripped.matches('\n').count(), 3);
|
||||
assert!(!stripped.contains("line1"));
|
||||
assert!(!stripped.contains("line2"));
|
||||
@@ -520,17 +519,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn strip_jsonc_comments_line_comment_at_end_of_file_no_newline() {
|
||||
let input = "{} // trailing";
|
||||
let stripped = strip_jsonc_comments(input);
|
||||
let stripped = strip("{} // trailing");
|
||||
assert!(!stripped.contains("trailing"));
|
||||
assert!(stripped.contains('{'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_jsonc_comments_escaped_quote_in_string_not_confused() {
|
||||
// Escaped quote must not end the string, so the // after it is inside the string.
|
||||
let input = r#"{"key": "val\"ue // not a comment"}"#;
|
||||
let stripped = strip_jsonc_comments(input);
|
||||
let stripped = strip(r#"{"key": "val\"ue // not a comment"}"#);
|
||||
assert!(
|
||||
stripped.contains("// not a comment"),
|
||||
"// inside string with escaped quote should not be stripped"
|
||||
@@ -539,16 +535,13 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn strip_jsonc_comments_block_comment_inside_string_not_stripped() {
|
||||
let input = r#"{"k": "a /* not a comment */ b"}"#;
|
||||
let stripped = strip_jsonc_comments(input);
|
||||
let stripped = strip(r#"{"k": "a /* not a comment */ b"}"#);
|
||||
assert!(stripped.contains("/* not a comment */"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_jsonc_comments_multiple_line_comments() {
|
||||
// Input has 5 '\n': after '{', end of "// first", after "a:1,", end of "// second", after "b:2"
|
||||
let input = "{\n// first\n\"a\":1,\n// second\n\"b\":2\n}";
|
||||
let stripped = strip_jsonc_comments(input);
|
||||
let stripped = strip("{\n// first\n\"a\":1,\n// second\n\"b\":2\n}");
|
||||
assert!(!stripped.contains("first"));
|
||||
assert!(!stripped.contains("second"));
|
||||
assert!(stripped.contains("\"a\":1"));
|
||||
@@ -559,33 +552,206 @@ mod tests {
|
||||
#[test]
|
||||
fn strip_jsonc_comments_slash_in_value_not_treated_as_comment() {
|
||||
let input = r#"{"path": "/usr/local/bin"}"#;
|
||||
let stripped = strip_jsonc_comments(input);
|
||||
assert_eq!(stripped, input);
|
||||
assert_eq!(strip(input), input);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_jsonc_comments_adjacent_block_comments() {
|
||||
let input = r#"{"a":/*1*/2/*3*/}"#;
|
||||
let stripped = strip_jsonc_comments(input);
|
||||
let stripped = strip(r#"{"a":/*1*/2/*3*/}"#);
|
||||
assert!(!stripped.contains("/*"));
|
||||
assert!(stripped.contains('"'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_jsonc_comments_only_comment_no_json() {
|
||||
let input = "// just a comment\n";
|
||||
let stripped = strip_jsonc_comments(input);
|
||||
let stripped = strip("// just a comment\n");
|
||||
assert!(!stripped.contains("just"));
|
||||
assert!(stripped.contains('\n'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_jsonc_comments_star_slash_in_string_not_end_of_block() {
|
||||
// The closing */ in a string must not terminate a block comment
|
||||
let input = r#"{"k": "*/not end"}/* actual comment */"#;
|
||||
let stripped = strip_jsonc_comments(input);
|
||||
// The key's value must survive; the trailing comment must be gone
|
||||
let stripped = strip(r#"{"k": "*/not end"}/* actual comment */"#);
|
||||
assert!(stripped.contains("*/not end"));
|
||||
assert!(!stripped.contains("actual comment"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jsonc_parser_new_starts_in_normal_mode() {
|
||||
let mut p = JsoncParser::new(b"{}");
|
||||
let mut out = String::new();
|
||||
p.advance_normal(0, &mut out);
|
||||
assert!(!p.in_string);
|
||||
assert!(!p.in_line_comment);
|
||||
assert!(!p.in_block_comment);
|
||||
assert_eq!(out, "{");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jsonc_parser_enters_string_on_quote() {
|
||||
let mut p = JsoncParser::new(b"\"hello\"");
|
||||
let mut out = String::new();
|
||||
let consumed = p.advance_normal(0, &mut out);
|
||||
assert!(
|
||||
p.in_string,
|
||||
"parser must be in string mode after opening quote"
|
||||
);
|
||||
assert_eq!(consumed, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jsonc_parser_exits_string_on_closing_quote() {
|
||||
let input = b"\"hello\"";
|
||||
let mut p = JsoncParser::new(input);
|
||||
p.in_string = true;
|
||||
let mut out = String::new();
|
||||
// Advance through h-e-l-l-o then closing "
|
||||
for i in 0..5 {
|
||||
p.advance_in_string(i, &mut out);
|
||||
}
|
||||
p.advance_in_string(5, &mut out); // closing "
|
||||
assert!(
|
||||
!p.in_string,
|
||||
"parser must exit string mode on closing quote"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jsonc_parser_enters_line_comment_on_slash_slash() {
|
||||
let mut p = JsoncParser::new(b"// comment");
|
||||
let mut out = String::new();
|
||||
let consumed = p.advance_normal(0, &mut out);
|
||||
assert!(p.in_line_comment);
|
||||
assert_eq!(consumed, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jsonc_parser_enters_block_comment_on_slash_star() {
|
||||
let mut p = JsoncParser::new(b"/* comment */");
|
||||
let mut out = String::new();
|
||||
let consumed = p.advance_normal(0, &mut out);
|
||||
assert!(p.in_block_comment);
|
||||
assert_eq!(consumed, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jsonc_parser_exits_block_comment_on_star_slash() {
|
||||
let input = b"comment */";
|
||||
let mut p = JsoncParser::new(input);
|
||||
p.in_block_comment = true;
|
||||
let mut out = String::new();
|
||||
// Advance to the '*' at index 8
|
||||
let consumed = p.advance_block_comment(8, &mut out);
|
||||
assert!(!p.in_block_comment, "parser must exit block comment on */");
|
||||
assert_eq!(consumed, 2);
|
||||
}
|
||||
|
||||
// === YAML formatting tests ===
|
||||
|
||||
#[test]
|
||||
fn format_document_yaml_round_trips() {
|
||||
let input = "b: 2\na: 1\n";
|
||||
let result = format_document(LanguageId::Yaml, input).unwrap();
|
||||
assert!(result.contains("a:") && result.contains("b:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_document_yaml_invalid_returns_err() {
|
||||
let input = "key: [unclosed bracket";
|
||||
assert!(format_document(LanguageId::Yaml, input).is_err());
|
||||
}
|
||||
|
||||
// === TOML formatting tests ===
|
||||
|
||||
#[test]
|
||||
fn format_document_toml_round_trips() {
|
||||
let input = "[section]\nkey = \"value\"\n";
|
||||
let result = format_document(LanguageId::Toml, input).unwrap();
|
||||
assert!(result.contains("key"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_document_toml_invalid_returns_err() {
|
||||
let input = "key = {unclosed";
|
||||
assert!(format_document(LanguageId::Toml, input).is_err());
|
||||
}
|
||||
|
||||
// === XML formatting tests ===
|
||||
|
||||
#[test]
|
||||
fn format_document_xml_indents_nested_elements() {
|
||||
let input = "<root><child>text</child></root>";
|
||||
let result = format_document(LanguageId::Xml, input).unwrap();
|
||||
assert!(
|
||||
result.contains('\n'),
|
||||
"XML should be formatted with newlines"
|
||||
);
|
||||
assert!(result.contains("child"), "child element must be present");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_document_xml_self_closing_element() {
|
||||
let input = "<root><empty/></root>";
|
||||
let result = format_document(LanguageId::Xml, input).unwrap();
|
||||
assert!(result.contains("empty"), "self-closing element must appear");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_document_xml_with_attributes_preserved() {
|
||||
let input = r#"<root><child id="1" class="foo">text</child></root>"#;
|
||||
let result = format_document(LanguageId::Xml, input).unwrap();
|
||||
assert!(result.contains("id="), "attribute id must be present");
|
||||
assert!(result.contains("class="), "attribute class must be present");
|
||||
assert!(result.contains("text"), "text content must be present");
|
||||
}
|
||||
|
||||
// === lint_document routing tests ===
|
||||
|
||||
#[test]
|
||||
fn lint_document_plain_returns_empty() {
|
||||
assert!(lint_document(LanguageId::Plain, "anything").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lint_document_markdown_returns_empty() {
|
||||
assert!(lint_document(LanguageId::Markdown, "# heading").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lint_document_unknown_returns_empty() {
|
||||
assert!(lint_document(LanguageId::Unknown, "???").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lint_document_json_valid_returns_empty() {
|
||||
assert!(lint_document(LanguageId::Json, r#"{"a":1}"#).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lint_document_json_invalid_returns_diagnostics() {
|
||||
let diags = lint_document(LanguageId::Json, "{bad}");
|
||||
assert!(!diags.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lint_document_yaml_valid_returns_empty() {
|
||||
assert!(lint_document(LanguageId::Yaml, "key: value\n").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lint_document_yaml_invalid_returns_diagnostics() {
|
||||
let diags = lint_document(LanguageId::Yaml, "key: [unclosed bracket");
|
||||
assert!(!diags.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lint_document_toml_valid_returns_empty() {
|
||||
assert!(lint_document(LanguageId::Toml, "key = 1\n").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lint_document_toml_invalid_returns_diagnostics() {
|
||||
let diags = lint_document(LanguageId::Toml, "key = {unclosed");
|
||||
assert!(!diags.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -896,6 +896,122 @@ mod tests {
|
||||
assert!(!t.dirty);
|
||||
}
|
||||
|
||||
// ── save_session / load_session ───────────────────────────────────────────
|
||||
|
||||
static SESSION_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
#[test]
|
||||
fn save_session_writes_to_disk_and_load_session_reads_it_back() {
|
||||
let _guard = SESSION_ENV_LOCK.lock().unwrap();
|
||||
let dir = TempDir::new().unwrap();
|
||||
// Point HOME (and XDG_DATA_HOME on Linux) at our temp dir so app_data_dir() lands there.
|
||||
let orig_home = std::env::var("HOME").ok();
|
||||
unsafe { std::env::set_var("HOME", dir.path()) };
|
||||
#[cfg(target_os = "linux")]
|
||||
unsafe {
|
||||
std::env::remove_var("XDG_DATA_HOME");
|
||||
}
|
||||
|
||||
let session_json = r#"{"tabs":[],"focusedTabId":null,"workspacePath":null}"#;
|
||||
let save_r = dispatch(
|
||||
&req("s1", "save_session", json!({ "session": session_json })),
|
||||
&fresh_state(),
|
||||
);
|
||||
assert!(
|
||||
ok_val(&save_r).is_null(),
|
||||
"save_session must return ok:null"
|
||||
);
|
||||
|
||||
let load_r = dispatch(&req("s2", "load_session", json!({})), &fresh_state());
|
||||
let raw = ok_val(&load_r)
|
||||
.as_str()
|
||||
.expect("load_session must return the raw JSON string");
|
||||
assert!(raw.contains("tabs"), "loaded session must contain 'tabs'");
|
||||
|
||||
// Restore HOME
|
||||
if let Some(home) = orig_home {
|
||||
unsafe { std::env::set_var("HOME", home) };
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_session_returns_null_when_no_file_exists() {
|
||||
let _guard = SESSION_ENV_LOCK.lock().unwrap();
|
||||
let dir = TempDir::new().unwrap();
|
||||
let orig_home = std::env::var("HOME").ok();
|
||||
unsafe { std::env::set_var("HOME", dir.path()) };
|
||||
#[cfg(target_os = "linux")]
|
||||
unsafe {
|
||||
std::env::remove_var("XDG_DATA_HOME");
|
||||
}
|
||||
|
||||
let r = dispatch(&req("s3", "load_session", json!({})), &fresh_state());
|
||||
assert!(
|
||||
ok_val(&r).is_null(),
|
||||
"load_session must return ok:null when no session file exists"
|
||||
);
|
||||
|
||||
if let Some(home) = orig_home {
|
||||
unsafe { std::env::set_var("HOME", home) };
|
||||
}
|
||||
}
|
||||
|
||||
// ── save_ui_prefs / load_ui_prefs ─────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn save_ui_prefs_writes_and_load_ui_prefs_reads_back() {
|
||||
let _guard = SESSION_ENV_LOCK.lock().unwrap();
|
||||
let dir = TempDir::new().unwrap();
|
||||
let orig_home = std::env::var("HOME").ok();
|
||||
unsafe { std::env::set_var("HOME", dir.path()) };
|
||||
#[cfg(target_os = "linux")]
|
||||
unsafe {
|
||||
std::env::remove_var("XDG_DATA_HOME");
|
||||
}
|
||||
|
||||
let prefs_json = r#"{"theme":"dark"}"#;
|
||||
let save_r = dispatch(
|
||||
&req("p1", "save_ui_prefs", json!({ "prefs": prefs_json })),
|
||||
&fresh_state(),
|
||||
);
|
||||
assert!(
|
||||
ok_val(&save_r).is_null(),
|
||||
"save_ui_prefs must return ok:null"
|
||||
);
|
||||
|
||||
let load_r = dispatch(&req("p2", "load_ui_prefs", json!({})), &fresh_state());
|
||||
let raw = ok_val(&load_r)
|
||||
.as_str()
|
||||
.expect("load_ui_prefs must return the raw JSON string");
|
||||
assert!(raw.contains("theme"), "loaded prefs must contain 'theme'");
|
||||
|
||||
if let Some(home) = orig_home {
|
||||
unsafe { std::env::set_var("HOME", home) };
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_ui_prefs_returns_null_when_no_file_exists() {
|
||||
let _guard = SESSION_ENV_LOCK.lock().unwrap();
|
||||
let dir = TempDir::new().unwrap();
|
||||
let orig_home = std::env::var("HOME").ok();
|
||||
unsafe { std::env::set_var("HOME", dir.path()) };
|
||||
#[cfg(target_os = "linux")]
|
||||
unsafe {
|
||||
std::env::remove_var("XDG_DATA_HOME");
|
||||
}
|
||||
|
||||
let r = dispatch(&req("p3", "load_ui_prefs", json!({})), &fresh_state());
|
||||
assert!(
|
||||
ok_val(&r).is_null(),
|
||||
"load_ui_prefs must return ok:null when no prefs file exists"
|
||||
);
|
||||
|
||||
if let Some(home) = orig_home {
|
||||
unsafe { std::env::set_var("HOME", home) };
|
||||
}
|
||||
}
|
||||
|
||||
// ── unknown command ───────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -26,23 +26,23 @@ test.describe('File tree icons', () => {
|
||||
|
||||
test('Cargo.lock uses manifest icon', async ({ page }) => {
|
||||
const img = page.locator('[data-testid="file-tree"] [data-entry-name="Cargo.lock"] img');
|
||||
await expect(img).toHaveAttribute('src', '/icons/file_icons/manifest-20px.svg');
|
||||
await expect(img).toHaveAttribute('src', './icons/file_icons/manifest-20px.svg');
|
||||
await expect(img).not.toHaveClass(/file-tree-icon-tinted/);
|
||||
});
|
||||
|
||||
test('src directory uses folder-test icon', async ({ page }) => {
|
||||
const img = page.locator('[data-testid="file-tree"] [data-entry-name="src"] img');
|
||||
await expect(img).toHaveAttribute('src', '/icons/file_icons/folder-test-20px.svg');
|
||||
await expect(img).toHaveAttribute('src', './icons/file_icons/folder-test-20px.svg');
|
||||
});
|
||||
|
||||
test('svg file uses xml icon', async ({ page }) => {
|
||||
const img = page.locator('[data-testid="file-tree"] [data-entry-name="icon.svg"] img');
|
||||
await expect(img).toHaveAttribute('src', '/icons/file_icons/xml-20px.svg');
|
||||
await expect(img).toHaveAttribute('src', './icons/file_icons/xml-20px.svg');
|
||||
});
|
||||
|
||||
test('zip uses archive icon with tint class', async ({ page }) => {
|
||||
const img = page.locator('[data-testid="file-tree"] [data-entry-name="dist.zip"] img');
|
||||
await expect(img).toHaveAttribute('src', '/icons/file_icons/archive-20px.svg');
|
||||
await expect(img).toHaveAttribute('src', './icons/file_icons/archive-20px.svg');
|
||||
await expect(img).toHaveClass(/file-tree-icon-tinted/);
|
||||
});
|
||||
|
||||
|
||||
+25
-1
@@ -9,13 +9,37 @@ const rustBinary =
|
||||
module.exports = {
|
||||
packagerConfig: {
|
||||
asar: true,
|
||||
prune: true,
|
||||
icon: 'electron/assets/icons/icon',
|
||||
extraResource: ['ui/dist', rustBinary],
|
||||
// Keep distributables lean: exclude source, build outputs, and CI metadata
|
||||
// from the packaged app (Electron runtime + app code only).
|
||||
ignore: [
|
||||
/^\/\.git($|\/)/,
|
||||
/^\/\.gitea($|\/)/,
|
||||
/^\/\.act($|\/)/,
|
||||
/^\/\.code-review-graph($|\/)/,
|
||||
/^\/e2e($|\/)/,
|
||||
/^\/crates($|\/)/,
|
||||
/^\/target($|\/)/,
|
||||
/^\/scripts($|\/)/,
|
||||
/^\/test-results($|\/)/,
|
||||
/^\/ui\/src($|\/)/,
|
||||
/^\/ui\/tests($|\/)/,
|
||||
/^\/ui\/node_modules($|\/)/,
|
||||
],
|
||||
},
|
||||
makers: [
|
||||
{
|
||||
name: '@electron-forge/maker-squirrel',
|
||||
config: {},
|
||||
config: {
|
||||
authors: 'Frank Dudley',
|
||||
description: 'ByteDraft — cross-platform text editor',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: '@electron-forge/maker-dmg',
|
||||
platforms: ['darwin'],
|
||||
},
|
||||
{
|
||||
name: '@electron-forge/maker-zip',
|
||||
|
||||
Generated
+391
@@ -13,6 +13,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron-forge/cli": "^7",
|
||||
"@electron-forge/maker-dmg": "^7.11.1",
|
||||
"@electron-forge/maker-squirrel": "^7",
|
||||
"@electron-forge/maker-zip": "^7",
|
||||
"@types/node": "^22",
|
||||
@@ -157,6 +158,24 @@
|
||||
"node": ">= 16.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@electron-forge/maker-dmg": {
|
||||
"version": "7.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@electron-forge/maker-dmg/-/maker-dmg-7.11.1.tgz",
|
||||
"integrity": "sha512-7zs5/Ewz1PcOl4N1102stFgBiFGWxU18+UPFUSd/fgf9MErBl4HBWuVNMIHyeJ/56rdfkcmTxTqE+9TBEYrZcg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@electron-forge/maker-base": "7.11.1",
|
||||
"@electron-forge/shared-types": "7.11.1",
|
||||
"fs-extra": "^10.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16.4.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"electron-installer-dmg": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@electron-forge/maker-squirrel": {
|
||||
"version": "7.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@electron-forge/maker-squirrel/-/maker-squirrel-7.11.1.tgz",
|
||||
@@ -1231,6 +1250,17 @@
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/appdmg": {
|
||||
"version": "0.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/appdmg/-/appdmg-0.5.5.tgz",
|
||||
"integrity": "sha512-G+n6DgZTZFOteITE30LnWj+HRVIGr7wMlAiLWOO02uJFWVEitaPU9JVXm9wJokkgshBawb2O1OykdcsmkkZfgg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/cacheable-request": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz",
|
||||
@@ -1700,6 +1730,36 @@
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/appdmg": {
|
||||
"version": "0.6.6",
|
||||
"resolved": "https://registry.npmjs.org/appdmg/-/appdmg-0.6.6.tgz",
|
||||
"integrity": "sha512-GRmFKlCG+PWbcYF4LUNonTYmy0GjguDy6Jh9WP8mpd0T6j80XIJyXBiWlD0U+MLNhqV9Nhx49Gl9GpVToulpLg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"dependencies": {
|
||||
"async": "^1.4.2",
|
||||
"ds-store": "^0.1.5",
|
||||
"execa": "^1.0.0",
|
||||
"fs-temp": "^1.0.0",
|
||||
"fs-xattr": "^0.3.0",
|
||||
"image-size": "^0.7.4",
|
||||
"is-my-json-valid": "^2.20.0",
|
||||
"minimist": "^1.1.3",
|
||||
"parse-color": "^1.0.0",
|
||||
"path-exists": "^4.0.0",
|
||||
"repeat-string": "^1.5.4"
|
||||
},
|
||||
"bin": {
|
||||
"appdmg": "bin/appdmg.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.5"
|
||||
}
|
||||
},
|
||||
"node_modules/aproba": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz",
|
||||
@@ -1722,6 +1782,14 @@
|
||||
"node": "^12.13.0 || ^14.15.0 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/async": {
|
||||
"version": "1.5.2",
|
||||
"resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz",
|
||||
"integrity": "sha512-nSVgobk4rv61R9PUSDtYt7mPVB2olxNR5RWJcAsH676/ef11bUZwvu7+RGYrYauVdDPcO519v68wRhXQtxsV9w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
@@ -1768,6 +1836,17 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/base32-encode": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/base32-encode/-/base32-encode-1.2.0.tgz",
|
||||
"integrity": "sha512-cHFU8XeRyx0GgmoWi5qHMCVRiqU6J3MHWxVgun7jggCBUpVzm1Ir7M9dYr2whjSNc3tFeXfQ/oZjQu/4u55h9A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"to-data-view": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
@@ -1830,6 +1909,17 @@
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/bplist-creator": {
|
||||
"version": "0.0.8",
|
||||
"resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.0.8.tgz",
|
||||
"integrity": "sha512-Za9JKzD6fjLC16oX2wsXfc+qBEhJBJB1YPInoAQpMLhDuj5aVOv1baGeIQSq1Fr3OCqzvsoQcSBSwGId/Ja2PA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"stream-buffers": "~2.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
|
||||
@@ -2591,6 +2681,19 @@
|
||||
"p-limit": "^3.1.0 "
|
||||
}
|
||||
},
|
||||
"node_modules/ds-store": {
|
||||
"version": "0.1.6",
|
||||
"resolved": "https://registry.npmjs.org/ds-store/-/ds-store-0.1.6.tgz",
|
||||
"integrity": "sha512-kY21M6Lz+76OS3bnCzjdsJSF7LBpLYGCVfavW8TgQD2XkcqIZ86W0y9qUDZu6fp7SIZzqosMDW2zi7zVFfv4hw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"bplist-creator": "~0.0.3",
|
||||
"macos-alias": "~0.2.5",
|
||||
"tn1150": "^0.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
@@ -2632,6 +2735,28 @@
|
||||
"node": ">= 22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-installer-dmg": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/electron-installer-dmg/-/electron-installer-dmg-5.0.1.tgz",
|
||||
"integrity": "sha512-qOa1aAQdX57C+vzhDk3549dd/PRlNL4F8y736MTD1a43qptD+PvHY97Bo9gSf+OZ8iUWE7BrYSpk/FgLUe40EA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@types/appdmg": "^0.5.5",
|
||||
"debug": "^4.3.2",
|
||||
"minimist": "^1.2.7"
|
||||
},
|
||||
"bin": {
|
||||
"electron-installer-dmg": "dist/electron-installer-dmg-bin.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"appdmg": "^0.6.4"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-playwright-helpers": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/electron-playwright-helpers/-/electron-playwright-helpers-2.1.0.tgz",
|
||||
@@ -2807,6 +2932,14 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/encode-utf8": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/encode-utf8/-/encode-utf8-1.0.3.tgz",
|
||||
"integrity": "sha512-ucAnuBEhUK4boH2HjVYG5Q2mQyPorvv0u/ocS+zhdw0S8AlHYY+GOFhP1Gio5z4icpP2ivFSvhtFjQi8+T9ppw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/encoding": {
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
|
||||
@@ -3323,6 +3456,17 @@
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/fmix": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fmix/-/fmix-0.1.0.tgz",
|
||||
"integrity": "sha512-Y6hyofImk9JdzU8k5INtTXX1cu8LDlePWDFU5sftm9H+zKCr5SGrVjdhkvsim646cw5zD0nADj8oHyXMZmCZ9w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"imul": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||
@@ -3389,6 +3533,32 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/fs-temp": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/fs-temp/-/fs-temp-1.2.1.tgz",
|
||||
"integrity": "sha512-okTwLB7/Qsq82G6iN5zZJFsOfZtx2/pqrA7Hk/9fvy+c+eJS9CvgGXT2uNxwnI14BDY9L/jQPkaBgSvlKfSW9w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"random-path": "^0.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fs-xattr": {
|
||||
"version": "0.3.1",
|
||||
"resolved": "https://registry.npmjs.org/fs-xattr/-/fs-xattr-0.3.1.tgz",
|
||||
"integrity": "sha512-UVqkrEW0GfDabw4C3HOrFlxKfx0eeigfRne69FxSBdHIP8Qt5Sq6Pu3RM9KmMlkygtC4pPKkj5CiPO5USnj2GA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"!win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=8.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fs.realpath": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
|
||||
@@ -3481,6 +3651,28 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/generate-function": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz",
|
||||
"integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"is-property": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/generate-object-property": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/generate-object-property/-/generate-object-property-1.2.0.tgz",
|
||||
"integrity": "sha512-TuOwZWgJ2VAMEGJvAyPWvpqxSANF0LDpmyHauMjFYzaACvn+QTT/AZomvPCzVBV7yDN3OmwHQ5OvHaeLKre3JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"is-property": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/get-caller-file": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
@@ -3894,6 +4086,31 @@
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/image-size": {
|
||||
"version": "0.7.5",
|
||||
"resolved": "https://registry.npmjs.org/image-size/-/image-size-0.7.5.tgz",
|
||||
"integrity": "sha512-Hiyv+mXHfFEP7LzUL/llg9RwFxxY+o9N3JVLIeG5E7iFIFAalxvRU9UZthBdYDEVnzHMgjnKJPPpay5BWf1g9g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"bin": {
|
||||
"image-size": "bin/image-size.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/imul": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/imul/-/imul-1.0.1.tgz",
|
||||
"integrity": "sha512-WFAgfwPLAjU66EKt6vRdTlKj4nAgIDQzh29JonLa4Bqtl6D8JrIMvWjCnx7xEjVNmP3U0fM5o8ZObk7d0f62bA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/imurmurhash": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
|
||||
@@ -4046,6 +4263,29 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/is-my-ip-valid": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-my-ip-valid/-/is-my-ip-valid-1.0.1.tgz",
|
||||
"integrity": "sha512-jxc8cBcOWbNK2i2aTkCZP6i7wkHF1bqKFrwEHuN5Jtg5BSaZHUZQ/JTOJwoV41YvHnOaRyWWh72T/KvfNz9DJg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/is-my-json-valid": {
|
||||
"version": "2.20.6",
|
||||
"resolved": "https://registry.npmjs.org/is-my-json-valid/-/is-my-json-valid-2.20.6.tgz",
|
||||
"integrity": "sha512-1JQwulVNjx8UqkPE/bqDaxtH4PXCe/2VRh/y3p99heOV87HG4Id5/VfDswd+YiAfHcRTfDlWgISycnHuhZq1aw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"generate-function": "^2.0.0",
|
||||
"generate-object-property": "^1.1.0",
|
||||
"is-my-ip-valid": "^1.0.0",
|
||||
"jsonpointer": "^5.0.0",
|
||||
"xtend": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-number": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
||||
@@ -4056,6 +4296,14 @@
|
||||
"node": ">=0.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-property": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz",
|
||||
"integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/is-stream": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz",
|
||||
@@ -4194,6 +4442,17 @@
|
||||
"graceful-fs": "^4.1.6"
|
||||
}
|
||||
},
|
||||
"node_modules/jsonpointer": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-5.0.1.tgz",
|
||||
"integrity": "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/junk": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/junk/-/junk-3.1.0.tgz",
|
||||
@@ -4518,6 +4777,21 @@
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/macos-alias": {
|
||||
"version": "0.2.12",
|
||||
"resolved": "https://registry.npmjs.org/macos-alias/-/macos-alias-0.2.12.tgz",
|
||||
"integrity": "sha512-yiLHa7cfJcGRFq4FrR4tMlpNHb4Vy4mWnpajlSSIFM5k4Lv8/7BbbDLzCAVogWNl0LlLhizRp1drXv0hK9h0Yw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"dependencies": {
|
||||
"nan": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/make-fetch-happen": {
|
||||
"version": "10.2.1",
|
||||
"resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-10.2.1.tgz",
|
||||
@@ -4812,6 +5086,19 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/murmur-32": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/murmur-32/-/murmur-32-0.2.0.tgz",
|
||||
"integrity": "sha512-ZkcWZudylwF+ir3Ld1n7gL6bI2mQAzXvSobPwVtu8aYi2sbXeipeSkdcanRLzIofLcM5F53lGaKm2dk7orBi7Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"encode-utf8": "^1.0.3",
|
||||
"fmix": "^0.1.0",
|
||||
"imul": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mute-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-1.0.0.tgz",
|
||||
@@ -4822,6 +5109,14 @@
|
||||
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/nan": {
|
||||
"version": "2.26.2",
|
||||
"resolved": "https://registry.npmjs.org/nan/-/nan-2.26.2.tgz",
|
||||
"integrity": "sha512-0tTvBTYkt3tdGw22nrAy50x7gpbGCCFH3AFcyS5WiUu7Eu4vWlri1woE6qHBSfy11vksDqkiwjOnlR7WV8G1Hw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/negotiator": {
|
||||
"version": "0.6.4",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz",
|
||||
@@ -5249,6 +5544,24 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/parse-color": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/parse-color/-/parse-color-1.0.0.tgz",
|
||||
"integrity": "sha512-fuDHYgFHJGbpGMgw9skY/bj3HL/Jrn4l/5rSspy00DoT4RyLnDcRvPxdZ+r6OFwIsgAuhDh4I09tAId4mI12bw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"color-convert": "~0.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/parse-color/node_modules/color-convert": {
|
||||
"version": "0.5.3",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-0.5.3.tgz",
|
||||
"integrity": "sha512-RwBeO/B/vZR3dfKL1ye/vx8MHZ40ugzpyfeVG5GsiuGnrlMWe2o8wxBbLCpw9CsxV+wHuzYlCiWnybrIA0ling==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/parse-json": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-2.2.0.tgz",
|
||||
@@ -5517,6 +5830,18 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/random-path": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/random-path/-/random-path-0.1.2.tgz",
|
||||
"integrity": "sha512-4jY0yoEaQ5v9StCl5kZbNIQlg1QheIDBrdkDn53EynpPb9FgO6//p3X/tgMnrC45XN6QZCzU1Xz/+pSSsJBpRw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"base32-encode": "^0.1.0 || ^1.0.0",
|
||||
"murmur-32": "^0.1.0 || ^0.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/read-binary-file-arch": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz",
|
||||
@@ -5650,6 +5975,17 @@
|
||||
"node": ">= 10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/repeat-string": {
|
||||
"version": "1.6.1",
|
||||
"resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz",
|
||||
"integrity": "sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
@@ -6157,6 +6493,17 @@
|
||||
"node": "^12.13.0 || ^14.15.0 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/stream-buffers": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/stream-buffers/-/stream-buffers-2.2.0.tgz",
|
||||
"integrity": "sha512-uyQK/mx5QjHun80FLJTfaWE7JtwfRMKBLkMne6udYOmvH0CawotVa7TfgYHzAnpphn4+TweIx1QKMnRIbipmUg==",
|
||||
"dev": true,
|
||||
"license": "Unlicense",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">= 0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||
@@ -6469,6 +6816,28 @@
|
||||
"node": ">=0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tn1150": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/tn1150/-/tn1150-0.1.0.tgz",
|
||||
"integrity": "sha512-DbplOfQFkqG5IHcDyyrs/lkvSr3mPUVsFf/RbDppOshs22yTPnSJWEe6FkYd1txAwU/zcnR905ar2fi4kwF29w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"unorm": "^1.4.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
}
|
||||
},
|
||||
"node_modules/to-data-view": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/to-data-view/-/to-data-view-1.1.0.tgz",
|
||||
"integrity": "sha512-1eAdufMg6mwgmlojAx3QeMnzB/BTVp7Tbndi3U7ftcT2zCZadjxkkmLmd97zmaxWi+sgGcgWrokmpEoy0Dn0vQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/to-regex-range": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
@@ -6623,6 +6992,17 @@
|
||||
"node": ">= 10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unorm": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/unorm/-/unorm-1.6.0.tgz",
|
||||
"integrity": "sha512-b2/KCUlYZUeA7JFUuRJZPUtr4gZvBh7tavtv4fvk4+KV9pfGiR6CQAQAWl49ZpR3ts2dk4FYkP7EIgDJoiOLDA==",
|
||||
"dev": true,
|
||||
"license": "MIT or GPL-2.0",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||
@@ -6938,6 +7318,17 @@
|
||||
"node": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"name": "byte-draft",
|
||||
"version": "0.3.0",
|
||||
"description": "ByteDraft — cross-platform text editor",
|
||||
"author": "Frank Dudley",
|
||||
"main": "dist-electron/main.js",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
@@ -16,6 +17,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron-forge/cli": "^7",
|
||||
"@electron-forge/maker-dmg": "^7.11.1",
|
||||
"@electron-forge/maker-squirrel": "^7",
|
||||
"@electron-forge/maker-zip": "^7",
|
||||
"@types/node": "^22",
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Convert cargo-nextest JUnit XML to SonarQube Generic Test Execution format.
|
||||
"""Convert JUnit XML to SonarQube Generic Test Execution format.
|
||||
|
||||
Sonar expects root element <testExecutions version="1"> with <file path="...">
|
||||
containing <testCase name="..." duration="ms"/>. Nextest emits Jenkins-style JUnit
|
||||
(<testsuites>), which Sonar rejects for sonar.testExecutionReportPaths.
|
||||
containing <testCase name="..." duration="ms"/>. Nextest and Vitest emit Jenkins-style
|
||||
JUnit (<testsuites>), which Sonar rejects for sonar.testExecutionReportPaths.
|
||||
|
||||
This script maps Rust test names to repository paths so Sonar can attach execution
|
||||
data to test files under sonar.tests.
|
||||
Flavors:
|
||||
rust — cargo-nextest JUnit; paths mapped to crates/... source files.
|
||||
vitest — Vitest JUnit; classnames are relative to ui/ (e.g. tests/Foo.test.tsx).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -19,6 +20,9 @@ from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Tuple
|
||||
|
||||
CaseRow = Tuple[str, int, str | None]
|
||||
ByFile = Dict[str, List[CaseRow]]
|
||||
|
||||
|
||||
def _duration_ms(time_attr: str | None) -> int:
|
||||
if not time_attr:
|
||||
@@ -37,7 +41,6 @@ def _test_status(tc: ET.Element) -> str | None:
|
||||
tag = child.tag.split("}")[-1] if "}" in child.tag else child.tag
|
||||
if tag in ("failure", "error", "skipped"):
|
||||
return tag
|
||||
# nextest: flakyFailure still means the run had failures until pass
|
||||
if tag in ("flakyFailure", "rerunFailure"):
|
||||
return "failure"
|
||||
return None
|
||||
@@ -77,7 +80,7 @@ def _lib_or_bin_src_path(crate: str, testcase_name: str, repo: Path) -> str:
|
||||
return str((base / "lib.rs").relative_to(repo))
|
||||
|
||||
|
||||
def _resolve_file_path(classname: str, testcase_name: str, repo: Path) -> str:
|
||||
def _resolve_rust_file_path(classname: str, testcase_name: str, repo: Path) -> str:
|
||||
parts = classname.split("::")
|
||||
if len(parts) == 1:
|
||||
return _lib_or_bin_src_path(parts[0], testcase_name, repo)
|
||||
@@ -91,26 +94,61 @@ def _resolve_file_path(classname: str, testcase_name: str, repo: Path) -> str:
|
||||
return _lib_or_bin_src_path(classname, testcase_name, repo)
|
||||
|
||||
|
||||
def convert(junit_path: Path, repo: Path) -> ET.Element:
|
||||
tree = ET.parse(junit_path)
|
||||
root = tree.getroot()
|
||||
def _iter_testcases(root: ET.Element) -> List[Tuple[ET.Element, ET.Element]]:
|
||||
"""Return (testsuite, testcase) pairs."""
|
||||
tag = root.tag.split("}")[-1] if "}" in root.tag else root.tag
|
||||
testsuites = root.findall(".//testsuite") if tag == "testsuites" else [root]
|
||||
|
||||
by_file: Dict[str, List[Tuple[str, int, str | None]]] = defaultdict(list)
|
||||
|
||||
pairs: List[Tuple[ET.Element, ET.Element]] = []
|
||||
for suite in testsuites:
|
||||
for tc in suite.findall("testcase"):
|
||||
classname = tc.get("classname") or suite.get("name") or ""
|
||||
name = tc.get("name") or ""
|
||||
if not classname or not name:
|
||||
continue
|
||||
path = _resolve_file_path(classname, name, repo)
|
||||
ms = _duration_ms(tc.get("time"))
|
||||
status = _test_status(tc)
|
||||
display_name = name
|
||||
by_file[path].append((display_name, ms, status))
|
||||
pairs.append((suite, tc))
|
||||
return pairs
|
||||
|
||||
|
||||
def collect_rust(junit_path: Path, repo: Path) -> ByFile:
|
||||
tree = ET.parse(junit_path)
|
||||
root = tree.getroot()
|
||||
by_file: ByFile = defaultdict(list)
|
||||
|
||||
for suite, tc in _iter_testcases(root):
|
||||
classname = tc.get("classname") or suite.get("name") or ""
|
||||
name = tc.get("name") or ""
|
||||
if not classname or not name:
|
||||
continue
|
||||
path = _resolve_rust_file_path(classname, name, repo)
|
||||
ms = _duration_ms(tc.get("time"))
|
||||
status = _test_status(tc)
|
||||
by_file[path].append((name, ms, status))
|
||||
|
||||
return by_file
|
||||
|
||||
|
||||
def collect_vitest(junit_path: Path, repo: Path) -> ByFile:
|
||||
"""Map Vitest JUnit (paths relative to ui/) to repo-relative ui/... paths."""
|
||||
tree = ET.parse(junit_path)
|
||||
root = tree.getroot()
|
||||
by_file: ByFile = defaultdict(list)
|
||||
|
||||
for suite, tc in _iter_testcases(root):
|
||||
classname = (tc.get("classname") or suite.get("name") or "").strip()
|
||||
name = (tc.get("name") or "").strip()
|
||||
if not classname or not name:
|
||||
continue
|
||||
rel = classname.replace("\\", "/")
|
||||
path = str((repo / "ui" / rel).relative_to(repo))
|
||||
ms = _duration_ms(tc.get("time"))
|
||||
status = _test_status(tc)
|
||||
by_file[path].append((name, ms, status))
|
||||
|
||||
return by_file
|
||||
|
||||
|
||||
def _sonar_duration_ms(ms: int) -> int:
|
||||
"""SonarQube 26.x generic test execution rejects duration=0 (expects positive ms)."""
|
||||
return max(ms, 1)
|
||||
|
||||
|
||||
def by_file_to_element(by_file: ByFile) -> ET.Element:
|
||||
out = ET.Element("testExecutions")
|
||||
out.set("version", "1")
|
||||
|
||||
@@ -120,7 +158,7 @@ def convert(junit_path: Path, repo: Path) -> ET.Element:
|
||||
for display_name, ms, status in sorted(by_file[path], key=lambda x: x[0]):
|
||||
te = ET.SubElement(fe, "testCase")
|
||||
te.set("name", display_name)
|
||||
te.set("duration", str(ms))
|
||||
te.set("duration", str(_sonar_duration_ms(ms)))
|
||||
if status == "skipped":
|
||||
ET.SubElement(te, "skipped")
|
||||
elif status == "failure":
|
||||
@@ -131,48 +169,111 @@ def convert(junit_path: Path, repo: Path) -> ET.Element:
|
||||
return out
|
||||
|
||||
|
||||
def convert_rust(junit_path: Path, repo: Path) -> ET.Element:
|
||||
return by_file_to_element(collect_rust(junit_path, repo))
|
||||
|
||||
|
||||
def convert_vitest(junit_path: Path, repo: Path) -> ET.Element:
|
||||
return by_file_to_element(collect_vitest(junit_path, repo))
|
||||
|
||||
|
||||
def validate_paths(elem: ET.Element, repo: Path) -> List[str]:
|
||||
"""Return a list of human-readable errors for missing <file path=...> targets."""
|
||||
errors: List[str] = []
|
||||
for fe in elem.findall("file"):
|
||||
rel = fe.get("path")
|
||||
if not rel:
|
||||
errors.append("<file> missing path attribute")
|
||||
continue
|
||||
p = repo / rel
|
||||
if not p.is_file():
|
||||
errors.append(f"missing file: {rel}")
|
||||
return errors
|
||||
|
||||
|
||||
def _write_xml(elem: ET.Element, out_path: Path) -> None:
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tree = ET.ElementTree(elem)
|
||||
ET.indent(tree, space=" ")
|
||||
# Avoid Python's single-quoted XML declaration; keep UTF-8 header aligned with JUnit producers.
|
||||
tree.write(
|
||||
out_path,
|
||||
encoding="utf-8",
|
||||
xml_declaration=True,
|
||||
xml_declaration=False,
|
||||
default_namespace=None,
|
||||
)
|
||||
raw = out_path.read_bytes()
|
||||
out_path.write_bytes(
|
||||
b'<?xml version="1.0" encoding="UTF-8"?>\n' + raw.lstrip()
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
p = argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument(
|
||||
"--flavor",
|
||||
choices=("rust", "vitest"),
|
||||
default="rust",
|
||||
help="JUnit producer (default: rust / nextest)",
|
||||
)
|
||||
p.add_argument(
|
||||
"--input",
|
||||
"-i",
|
||||
type=Path,
|
||||
default=Path("target/nextest/ci/junit.xml"),
|
||||
help="Path to nextest JUnit report",
|
||||
default=None,
|
||||
help="Path to JUnit report (default depends on --flavor)",
|
||||
)
|
||||
p.add_argument(
|
||||
"--output",
|
||||
"-o",
|
||||
type=Path,
|
||||
default=Path("target/nextest/ci/sonar-test-execution.xml"),
|
||||
default=None,
|
||||
help="Output path for Sonar Generic Test Execution XML",
|
||||
)
|
||||
p.add_argument(
|
||||
"--repo-root",
|
||||
type=Path,
|
||||
default=Path("."),
|
||||
help="Repository root (for resolving crates/... paths)",
|
||||
help="Repository root (for resolving source paths)",
|
||||
)
|
||||
p.add_argument(
|
||||
"--strict",
|
||||
action="store_true",
|
||||
help="Fail if any <file path> does not exist on disk (catches stale path mapping)",
|
||||
)
|
||||
args = p.parse_args()
|
||||
repo = args.repo_root.resolve()
|
||||
if not args.input.is_file():
|
||||
print(f"error: input not found: {args.input}", file=sys.stderr)
|
||||
|
||||
if args.flavor == "rust":
|
||||
default_in = Path("target/nextest/ci/junit.xml")
|
||||
default_out = Path("target/nextest/ci/sonar-test-execution.xml")
|
||||
else:
|
||||
default_in = Path("test-results/junit.xml")
|
||||
default_out = Path("test-results/sonar-test-execution.xml")
|
||||
|
||||
in_path = (args.input or default_in).resolve()
|
||||
out_path = (args.output or default_out).resolve()
|
||||
|
||||
if not in_path.is_file():
|
||||
print(f"error: input not found: {in_path}", file=sys.stderr)
|
||||
return 1
|
||||
elem = convert(args.input.resolve(), repo)
|
||||
_write_xml(elem, args.output.resolve())
|
||||
print(f"Wrote {args.output}")
|
||||
|
||||
if args.flavor == "rust":
|
||||
elem = convert_rust(in_path, repo)
|
||||
else:
|
||||
elem = convert_vitest(in_path, repo)
|
||||
|
||||
if args.strict:
|
||||
bad = validate_paths(elem, repo)
|
||||
if bad:
|
||||
for line in bad[:50]:
|
||||
print(f"error: {line}", file=sys.stderr)
|
||||
if len(bad) > 50:
|
||||
print(f"error: ... and {len(bad) - 50} more", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
_write_xml(elem, out_path)
|
||||
print(f"Wrote {out_path}")
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Convert LLVM LCOV (from cargo-llvm-cov) to SonarQube Generic Coverage XML.
|
||||
|
||||
Sonar's sonar.coverageReportPaths expects the generic format (<coverage version="1">),
|
||||
not raw LCOV. TypeScript keeps using sonar.javascript.lcov.reportPaths separately.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import xml.etree.ElementTree as ET
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from typing import DefaultDict, Dict, List, Tuple
|
||||
|
||||
LineHits = Dict[int, int]
|
||||
|
||||
|
||||
def _parse_lcov_records(path: Path) -> DefaultDict[str, LineHits]:
|
||||
"""Parse LCOV; return mapping repo-style relative path -> {line_number: hit_count}."""
|
||||
by_file: DefaultDict[str, LineHits] = defaultdict(dict)
|
||||
current_key: str | None = None
|
||||
|
||||
with path.open(encoding="utf-8", errors="replace") as f:
|
||||
for raw in f:
|
||||
line = raw.strip()
|
||||
if line == "end_of_record":
|
||||
current_key = None
|
||||
continue
|
||||
if line.startswith("SF:"):
|
||||
current_key = line[3:].strip()
|
||||
continue
|
||||
if current_key and line.startswith("DA:"):
|
||||
body = line[3:]
|
||||
parts = body.split(",")
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
try:
|
||||
ln = int(parts[0])
|
||||
hits = int(parts[1])
|
||||
except ValueError:
|
||||
continue
|
||||
prev = by_file[current_key].get(ln)
|
||||
if prev is None:
|
||||
by_file[current_key][ln] = hits
|
||||
else:
|
||||
by_file[current_key][ln] = max(prev, hits)
|
||||
|
||||
return by_file
|
||||
|
||||
|
||||
def _to_repo_relative(source_path: str, repo: Path) -> str | None:
|
||||
p = Path(source_path)
|
||||
if p.is_absolute():
|
||||
resolved = p.resolve()
|
||||
else:
|
||||
resolved = (repo / p).resolve()
|
||||
try:
|
||||
return str(resolved.relative_to(repo)).replace("\\", "/")
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_files(
|
||||
by_file: DefaultDict[str, LineHits], repo: Path
|
||||
) -> Dict[str, LineHits]:
|
||||
out: Dict[str, LineHits] = {}
|
||||
for sf, lines in by_file.items():
|
||||
rel = _to_repo_relative(sf, repo)
|
||||
if rel is None:
|
||||
continue
|
||||
merged = out.setdefault(rel, {})
|
||||
for ln, hits in lines.items():
|
||||
prev = merged.get(ln)
|
||||
if prev is None:
|
||||
merged[ln] = hits
|
||||
else:
|
||||
merged[ln] = max(prev, hits)
|
||||
return out
|
||||
|
||||
|
||||
def _validate_paths(files: Dict[str, LineHits], repo: Path) -> List[str]:
|
||||
errors: List[str] = []
|
||||
for rel in sorted(files):
|
||||
p = repo / rel
|
||||
if not p.is_file():
|
||||
errors.append(f"missing file: {rel}")
|
||||
return errors
|
||||
|
||||
|
||||
def build_coverage_element(files: Dict[str, LineHits]) -> ET.Element:
|
||||
root = ET.Element("coverage")
|
||||
root.set("version", "1")
|
||||
for path in sorted(files.keys()):
|
||||
fe = ET.SubElement(root, "file")
|
||||
fe.set("path", path)
|
||||
for line_no in sorted(files[path]):
|
||||
hits = files[path][line_no]
|
||||
le = ET.SubElement(fe, "lineToCover")
|
||||
le.set("lineNumber", str(line_no))
|
||||
le.set("covered", "true" if hits > 0 else "false")
|
||||
return root
|
||||
|
||||
|
||||
def _write_xml(elem: ET.Element, out_path: Path) -> None:
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tree = ET.ElementTree(elem)
|
||||
ET.indent(tree, space=" ")
|
||||
tree.write(
|
||||
out_path,
|
||||
encoding="utf-8",
|
||||
xml_declaration=True,
|
||||
default_namespace=None,
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument(
|
||||
"--input",
|
||||
"-i",
|
||||
type=Path,
|
||||
default=Path("target/lcov.info"),
|
||||
help="LCOV input (default: target/lcov.info)",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--output",
|
||||
"-o",
|
||||
type=Path,
|
||||
default=Path("target/sonar-rust-coverage.xml"),
|
||||
help="Output Generic Coverage XML (default: target/sonar-rust-coverage.xml)",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--repo-root",
|
||||
type=Path,
|
||||
default=Path("."),
|
||||
help="Repository root for relative paths",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--strict",
|
||||
action="store_true",
|
||||
help="Fail if any <file path> does not exist under repo-root",
|
||||
)
|
||||
args = ap.parse_args()
|
||||
repo = args.repo_root.resolve()
|
||||
in_path = args.input.resolve()
|
||||
out_path = args.output.resolve()
|
||||
|
||||
if not in_path.is_file():
|
||||
print(f"error: LCOV not found: {in_path}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
raw = _parse_lcov_records(in_path)
|
||||
files = _normalize_files(raw, repo)
|
||||
if not files:
|
||||
print("error: no coverage records after normalization", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if args.strict:
|
||||
bad = _validate_paths(files, repo)
|
||||
if bad:
|
||||
for line in bad[:50]:
|
||||
print(f"error: {line}", file=sys.stderr)
|
||||
if len(bad) > 50:
|
||||
print(f"error: ... and {len(bad) - 50} more", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
elem = build_coverage_element(files)
|
||||
_write_xml(elem, out_path)
|
||||
print(f"Wrote {out_path} ({len(files)} files)")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -165,7 +165,7 @@ function MoveToWindowMenu({ tabId, onClose, itemClass }: Readonly<{ tabId: strin
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={subItemClass}
|
||||
onClick={() => void moveToWindow(label)}
|
||||
onClick={() => { void moveToWindow(label); }}
|
||||
>
|
||||
{label === 'main' ? 'Main Window' : label}
|
||||
</button>
|
||||
@@ -178,7 +178,7 @@ function MoveToWindowMenu({ tabId, onClose, itemClass }: Readonly<{ tabId: strin
|
||||
|
||||
// ── DockTab ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export function DockTab({ api, params }: IDockviewPanelHeaderProps<{ tabId: string }>) {
|
||||
export function DockTab({ api, params }: Readonly<IDockviewPanelHeaderProps<{ tabId: string }>>) {
|
||||
const tab = useEditorStore((s) => s.tabs.find((t) => t.id === params.tabId));
|
||||
const isActive = api.isActive;
|
||||
|
||||
|
||||
@@ -107,6 +107,9 @@ function SortableTab({ tab, isActive, onActivate, onClose, onRequestContextMenu,
|
||||
isActive ? 'border-b-2 border-[var(--accent)]' : ''
|
||||
}`}
|
||||
onContextMenu={(e) => onRequestContextMenu(e, tab)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onActivate(); }
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@@ -563,7 +566,7 @@ export function TabStrip() {
|
||||
onDragCancel={handleDragCancel}
|
||||
>
|
||||
<SortableContext items={tabIds} strategy={horizontalListSortingStrategy}>
|
||||
<div className="flex items-stretch h-9 gap-0.5 w-max min-w-full">
|
||||
<div role="tablist" aria-label="Open tabs" className="flex items-stretch h-9 gap-0.5 w-max min-w-full">
|
||||
{tabs.map((tab, index) => (
|
||||
<Fragment key={tab.id}>
|
||||
{dragOverIndex === index && <DropIndicator />}
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
* glyphs; there is no icon IPC from the Rust bridge.
|
||||
*/
|
||||
|
||||
export const ICON_BASE = '/icons/file_icons';
|
||||
// Relative URL works for both dev server and packaged file:// builds.
|
||||
export const ICON_BASE = './icons/file_icons';
|
||||
|
||||
function ic(file: string): string {
|
||||
return `${ICON_BASE}/${file}`;
|
||||
|
||||
@@ -54,6 +54,8 @@ export interface SessionData {
|
||||
accentColor?: 'blue' | 'amber' | 'green' | 'vi';
|
||||
}
|
||||
|
||||
export type ViewMode = 'source' | 'split' | 'preview';
|
||||
|
||||
export interface DirEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* Keyboard accessibility tests for TabStrip (Phase 5D).
|
||||
*
|
||||
* Coverage targets:
|
||||
* - Tab keyboard navigation with Arrow keys
|
||||
* - Enter/Space key activation
|
||||
* - role="tablist" container with proper ARIA attributes
|
||||
*
|
||||
* NOTE: aria-selected and roving tabindex (tabIndex=-1 for inactive tabs)
|
||||
* are part of Phase 3's accessibility work but not yet implemented.
|
||||
* These tests verify current behavior and will need updating once Phase 3 is complete.
|
||||
*/
|
||||
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { TitleBar } from '../src/components/TitleBar/TitleBar';
|
||||
import { useEditorStore } from '../src/state/editorStore';
|
||||
import type { Tab } from '../src/types';
|
||||
|
||||
function makeTab(overrides: Partial<Tab> = {}): Tab {
|
||||
return {
|
||||
id: `tab-${Math.random().toString(36).slice(2)}`,
|
||||
path: '/tmp/test.txt',
|
||||
content: 'hello',
|
||||
encoding: 'UTF-8',
|
||||
language: 'plaintext',
|
||||
languageOverride: null,
|
||||
isDirty: false,
|
||||
viewKind: 'text',
|
||||
lineEnding: 'LF',
|
||||
cursorLine: 1,
|
||||
cursorCol: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function resetStores() {
|
||||
useEditorStore.setState({ tabs: [], activeTabId: null });
|
||||
}
|
||||
|
||||
describe('TabStrip keyboard accessibility', () => {
|
||||
beforeEach(resetStores);
|
||||
|
||||
describe('ARIA attributes and roles', () => {
|
||||
it('tab container has role="tablist"', () => {
|
||||
const tab1 = makeTab({ id: 'tab-1', path: '/tmp/a.txt' });
|
||||
useEditorStore.setState({ tabs: [tab1], activeTabId: 'tab-1' });
|
||||
|
||||
render(<TitleBar onMenuOpen={() => {}} />);
|
||||
|
||||
const tablist = screen.getByRole('tablist');
|
||||
expect(tablist).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('each tab has role="tab"', () => {
|
||||
const tab1 = makeTab({ id: 'tab-1', path: '/tmp/a.txt' });
|
||||
const tab2 = makeTab({ id: 'tab-2', path: '/tmp/b.txt' });
|
||||
useEditorStore.setState({ tabs: [tab1, tab2], activeTabId: 'tab-1' });
|
||||
|
||||
render(<TitleBar onMenuOpen={() => {}} />);
|
||||
|
||||
const tabs = screen.getAllByRole('tab');
|
||||
expect(tabs).toHaveLength(2);
|
||||
});
|
||||
|
||||
// NOTE: aria-selected is part of Phase 3 accessibility work, not yet implemented
|
||||
it.todo('active tab has aria-selected="true"', () => {
|
||||
const tab1 = makeTab({ id: 'tab-active', path: '/tmp/active.txt' });
|
||||
const tab2 = makeTab({ id: 'tab-inactive', path: '/tmp/inactive.txt' });
|
||||
useEditorStore.setState({ tabs: [tab1, tab2], activeTabId: 'tab-active' });
|
||||
|
||||
render(<TitleBar onMenuOpen={() => {}} />);
|
||||
|
||||
const activeTab = screen.getByTestId('tab-btn-tab-active');
|
||||
const inactiveTab = screen.getByTestId('tab-btn-tab-inactive');
|
||||
|
||||
expect(activeTab).toHaveAttribute('aria-selected', 'true');
|
||||
expect(inactiveTab).toHaveAttribute('aria-selected', 'false');
|
||||
});
|
||||
|
||||
it.todo('updates aria-selected when active tab changes', () => {
|
||||
const tab1 = makeTab({ id: 'tab-1', path: '/tmp/a.txt' });
|
||||
const tab2 = makeTab({ id: 'tab-2', path: '/tmp/b.txt' });
|
||||
useEditorStore.setState({ tabs: [tab1, tab2], activeTabId: 'tab-1' });
|
||||
|
||||
const { rerender } = render(<TitleBar onMenuOpen={() => {}} />);
|
||||
|
||||
const tab1Btn = screen.getByTestId('tab-btn-tab-1');
|
||||
const tab2Btn = screen.getByTestId('tab-btn-tab-2');
|
||||
|
||||
expect(tab1Btn).toHaveAttribute('aria-selected', 'true');
|
||||
expect(tab2Btn).toHaveAttribute('aria-selected', 'false');
|
||||
|
||||
// Switch active tab
|
||||
useEditorStore.setState({ tabs: [tab1, tab2], activeTabId: 'tab-2' });
|
||||
rerender(<TitleBar onMenuOpen={() => {}} />);
|
||||
|
||||
expect(tab1Btn).toHaveAttribute('aria-selected', 'false');
|
||||
expect(tab2Btn).toHaveAttribute('aria-selected', 'true');
|
||||
});
|
||||
});
|
||||
|
||||
describe('tabIndex management (roving tabindex)', () => {
|
||||
// NOTE: Roving tabindex (tabIndex=-1 for inactive tabs) is part of Phase 3, not yet implemented
|
||||
// Currently all tabs have tabIndex=0
|
||||
|
||||
it.todo('active tab has tabIndex={0}, inactive tabs have tabIndex={-1}', () => {
|
||||
const tab1 = makeTab({ id: 'tab-active', path: '/tmp/active.txt' });
|
||||
const tab2 = makeTab({ id: 'tab-inactive', path: '/tmp/inactive.txt' });
|
||||
useEditorStore.setState({ tabs: [tab1, tab2], activeTabId: 'tab-active' });
|
||||
|
||||
render(<TitleBar onMenuOpen={() => {}} />);
|
||||
|
||||
const activeTab = screen.getByTestId('tab-btn-tab-active');
|
||||
const inactiveTab = screen.getByTestId('tab-btn-tab-inactive');
|
||||
|
||||
expect(activeTab).toHaveAttribute('tabIndex', '0');
|
||||
expect(inactiveTab).toHaveAttribute('tabIndex', '-1');
|
||||
});
|
||||
|
||||
it.todo('updates tabIndex when active tab changes', () => {
|
||||
const tab1 = makeTab({ id: 'tab-1', path: '/tmp/a.txt' });
|
||||
const tab2 = makeTab({ id: 'tab-2', path: '/tmp/b.txt' });
|
||||
useEditorStore.setState({ tabs: [tab1, tab2], activeTabId: 'tab-1' });
|
||||
|
||||
const { rerender } = render(<TitleBar onMenuOpen={() => {}} />);
|
||||
|
||||
const tab1Btn = screen.getByTestId('tab-btn-tab-1');
|
||||
const tab2Btn = screen.getByTestId('tab-btn-tab-2');
|
||||
|
||||
expect(tab1Btn).toHaveAttribute('tabIndex', '0');
|
||||
expect(tab2Btn).toHaveAttribute('tabIndex', '-1');
|
||||
|
||||
useEditorStore.setState({ tabs: [tab1, tab2], activeTabId: 'tab-2' });
|
||||
rerender(<TitleBar onMenuOpen={() => {}} />);
|
||||
|
||||
expect(tab1Btn).toHaveAttribute('tabIndex', '-1');
|
||||
expect(tab2Btn).toHaveAttribute('tabIndex', '0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Keyboard navigation', () => {
|
||||
it('activates tab on click', () => {
|
||||
const tab1 = makeTab({ id: 'tab-1', path: '/tmp/a.txt' });
|
||||
const tab2 = makeTab({ id: 'tab-2', path: '/tmp/b.txt' });
|
||||
useEditorStore.setState({ tabs: [tab1, tab2], activeTabId: 'tab-1' });
|
||||
|
||||
render(<TitleBar onMenuOpen={() => {}} />);
|
||||
|
||||
const tab2Btn = screen.getByTestId('tab-btn-tab-2');
|
||||
fireEvent.click(tab2Btn);
|
||||
|
||||
expect(useEditorStore.getState().activeTabId).toBe('tab-2');
|
||||
});
|
||||
|
||||
it('activates tab on Enter key', () => {
|
||||
const tab1 = makeTab({ id: 'tab-1', path: '/tmp/a.txt' });
|
||||
const tab2 = makeTab({ id: 'tab-2', path: '/tmp/b.txt' });
|
||||
useEditorStore.setState({ tabs: [tab1, tab2], activeTabId: 'tab-1' });
|
||||
|
||||
render(<TitleBar onMenuOpen={() => {}} />);
|
||||
|
||||
const tab2Btn = screen.getByTestId('tab-btn-tab-2');
|
||||
fireEvent.keyDown(tab2Btn, { key: 'Enter' });
|
||||
|
||||
expect(useEditorStore.getState().activeTabId).toBe('tab-2');
|
||||
});
|
||||
|
||||
it('activates tab on Space key', () => {
|
||||
const tab1 = makeTab({ id: 'tab-1', path: '/tmp/a.txt' });
|
||||
const tab2 = makeTab({ id: 'tab-2', path: '/tmp/b.txt' });
|
||||
useEditorStore.setState({ tabs: [tab1, tab2], activeTabId: 'tab-1' });
|
||||
|
||||
render(<TitleBar onMenuOpen={() => {}} />);
|
||||
|
||||
const tab2Btn = screen.getByTestId('tab-btn-tab-2');
|
||||
fireEvent.keyDown(tab2Btn, { key: ' ' });
|
||||
|
||||
expect(useEditorStore.getState().activeTabId).toBe('tab-2');
|
||||
});
|
||||
|
||||
it('does NOT activate tab on other keys (e.g., ArrowRight)', () => {
|
||||
const tab1 = makeTab({ id: 'tab-1', path: '/tmp/a.txt' });
|
||||
const tab2 = makeTab({ id: 'tab-2', path: '/tmp/b.txt' });
|
||||
useEditorStore.setState({ tabs: [tab1, tab2], activeTabId: 'tab-1' });
|
||||
|
||||
render(<TitleBar onMenuOpen={() => {}} />);
|
||||
|
||||
const tab2Btn = screen.getByTestId('tab-btn-tab-2');
|
||||
fireEvent.keyDown(tab2Btn, { key: 'ArrowRight' });
|
||||
|
||||
// Should still be tab-1, not tab-2
|
||||
expect(useEditorStore.getState().activeTabId).toBe('tab-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Focus management', () => {
|
||||
it.todo('clicking a tab gives it focus', () => {
|
||||
const tab1 = makeTab({ id: 'tab-1', path: '/tmp/a.txt' });
|
||||
const tab2 = makeTab({ id: 'tab-2', path: '/tmp/b.txt' });
|
||||
useEditorStore.setState({ tabs: [tab1, tab2], activeTabId: 'tab-1' });
|
||||
|
||||
render(<TitleBar onMenuOpen={() => {}} />);
|
||||
|
||||
const tab2Btn = screen.getByTestId('tab-btn-tab-2');
|
||||
fireEvent.click(tab2Btn);
|
||||
|
||||
expect(document.activeElement).toBe(tab2Btn);
|
||||
});
|
||||
|
||||
it('active tab can receive keyboard focus via Tab key', () => {
|
||||
const tab1 = makeTab({ id: 'tab-active', path: '/tmp/active.txt' });
|
||||
useEditorStore.setState({ tabs: [tab1], activeTabId: 'tab-active' });
|
||||
|
||||
render(<TitleBar onMenuOpen={() => {}} />);
|
||||
|
||||
const activeTab = screen.getByTestId('tab-btn-tab-active');
|
||||
|
||||
// Simulate tabbing to the tab
|
||||
activeTab.focus();
|
||||
expect(document.activeElement).toBe(activeTab);
|
||||
});
|
||||
|
||||
it.todo('inactive tabs are not in tab order (tabIndex={-1})', () => {
|
||||
const tab1 = makeTab({ id: 'tab-active', path: '/tmp/active.txt' });
|
||||
const tab2 = makeTab({ id: 'tab-inactive', path: '/tmp/inactive.txt' });
|
||||
useEditorStore.setState({ tabs: [tab1, tab2], activeTabId: 'tab-active' });
|
||||
|
||||
render(<TitleBar onMenuOpen={() => {}} />);
|
||||
|
||||
const inactiveTab = screen.getByTestId('tab-btn-tab-inactive');
|
||||
expect(inactiveTab).toHaveAttribute('tabIndex', '-1');
|
||||
|
||||
// Trying to focus it directly should still work (e.g., programmatic focus)
|
||||
inactiveTab.focus();
|
||||
expect(document.activeElement).toBe(inactiveTab);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Multiple tabs behavior', () => {
|
||||
it.todo('handles many tabs with correct ARIA state', () => {
|
||||
const tabs = [
|
||||
makeTab({ id: 'tab-1', path: '/tmp/1.txt' }),
|
||||
makeTab({ id: 'tab-2', path: '/tmp/2.txt' }),
|
||||
makeTab({ id: 'tab-3', path: '/tmp/3.txt' }),
|
||||
makeTab({ id: 'tab-4', path: '/tmp/4.txt' }),
|
||||
];
|
||||
useEditorStore.setState({ tabs, activeTabId: 'tab-2' });
|
||||
|
||||
render(<TitleBar onMenuOpen={() => {}} />);
|
||||
|
||||
const tab1 = screen.getByTestId('tab-btn-tab-1');
|
||||
const tab2 = screen.getByTestId('tab-btn-tab-2');
|
||||
const tab3 = screen.getByTestId('tab-btn-tab-3');
|
||||
const tab4 = screen.getByTestId('tab-btn-tab-4');
|
||||
|
||||
expect(tab1).toHaveAttribute('aria-selected', 'false');
|
||||
expect(tab1).toHaveAttribute('tabIndex', '-1');
|
||||
|
||||
expect(tab2).toHaveAttribute('aria-selected', 'true');
|
||||
expect(tab2).toHaveAttribute('tabIndex', '0');
|
||||
|
||||
expect(tab3).toHaveAttribute('aria-selected', 'false');
|
||||
expect(tab3).toHaveAttribute('tabIndex', '-1');
|
||||
|
||||
expect(tab4).toHaveAttribute('aria-selected', 'false');
|
||||
expect(tab4).toHaveAttribute('tabIndex', '-1');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* Unit tests for api.ts Proxy and window.api merging behavior.
|
||||
*
|
||||
* Coverage targets (Phase 5B):
|
||||
* - Proxy lazy merge on each access
|
||||
* - window.api partial override of mockApi
|
||||
* - Fallback to mockApi when no window.api
|
||||
* - Function binding for proper this context
|
||||
* - FALLBACK_LANGUAGE_OPTIONS and FALLBACK_ENCODING_LABELS constants
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { api, FALLBACK_LANGUAGE_OPTIONS, FALLBACK_ENCODING_LABELS } from '../src/api';
|
||||
import type { WindowApi } from '../src/api';
|
||||
|
||||
// Save original window state to restore after tests
|
||||
const originalWindow = globalThis.window;
|
||||
|
||||
describe('api.ts bridge', () => {
|
||||
beforeEach(() => {
|
||||
// Reset window.api before each test
|
||||
if (globalThis.window) {
|
||||
(globalThis.window as unknown as { api?: Partial<WindowApi> }).api = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original window
|
||||
if (!originalWindow && globalThis.window) {
|
||||
// @ts-expect-error: cleanup
|
||||
delete globalThis.window;
|
||||
}
|
||||
});
|
||||
|
||||
describe('Fallback constants', () => {
|
||||
it('exports FALLBACK_LANGUAGE_OPTIONS with expected languages', () => {
|
||||
expect(FALLBACK_LANGUAGE_OPTIONS).toContainEqual({ id: 'plaintext', label: 'Plain Text' });
|
||||
expect(FALLBACK_LANGUAGE_OPTIONS).toContainEqual({ id: 'json', label: 'JSON' });
|
||||
expect(FALLBACK_LANGUAGE_OPTIONS).toContainEqual({ id: 'rust', label: 'Rust' });
|
||||
expect(FALLBACK_LANGUAGE_OPTIONS).toContainEqual({ id: 'typescript', label: 'TypeScript' });
|
||||
expect(FALLBACK_LANGUAGE_OPTIONS.length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
it('exports FALLBACK_ENCODING_LABELS with common encodings', () => {
|
||||
expect(FALLBACK_ENCODING_LABELS).toContain('UTF-8');
|
||||
expect(FALLBACK_ENCODING_LABELS).toContain('UTF-16 LE');
|
||||
expect(FALLBACK_ENCODING_LABELS).toContain('ASCII');
|
||||
expect(FALLBACK_ENCODING_LABELS).toContain('ISO-8859-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Proxy without window.api (mockApi fallback)', () => {
|
||||
it('returns safe fallback values for read operations', async () => {
|
||||
expect(await api.loadSession()).toBeNull();
|
||||
expect(await api.listLanguages()).toEqual([...FALLBACK_LANGUAGE_OPTIONS]);
|
||||
expect(await api.listEncodings()).toEqual([...FALLBACK_ENCODING_LABELS]);
|
||||
expect(await api.detectLanguage('', '')).toBe('plaintext');
|
||||
expect(await api.listWorkspaceFiles('')).toEqual([]);
|
||||
expect(await api.readDirectory('')).toEqual([]);
|
||||
expect(await api.lintDocument('', 'json')).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns identity for formatDocument when no backend', async () => {
|
||||
const input = 'unformatted code';
|
||||
expect(await api.formatDocument(input, 'json')).toBe(input);
|
||||
});
|
||||
|
||||
it('returns null for dialog operations', async () => {
|
||||
expect(await api.openFolder()).toBeNull();
|
||||
expect(await api.showOpenDialog({})).toBeNull();
|
||||
expect(await api.saveFileAs('')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects openFile with "No backend available" error', async () => {
|
||||
await expect(api.openFile('/tmp/test.txt')).rejects.toThrow('No backend available');
|
||||
});
|
||||
|
||||
it('rejects readFileWithEncoding with error', async () => {
|
||||
await expect(api.readFileWithEncoding('/tmp/test.txt', 'UTF-8')).rejects.toThrow('No backend available');
|
||||
});
|
||||
|
||||
it('provides no-op functions for window management', () => {
|
||||
expect(() => api.minimize()).not.toThrow();
|
||||
expect(() => api.toggleMaximize()).not.toThrow();
|
||||
expect(() => api.close()).not.toThrow();
|
||||
expect(() => api.tearOffTab('{}')).not.toThrow();
|
||||
});
|
||||
|
||||
it('provides no-op event handlers that return cleanup function', () => {
|
||||
const cleanup = api.onFilesDropped(() => {});
|
||||
expect(cleanup).toBeInstanceOf(Function);
|
||||
expect(() => cleanup()).not.toThrow();
|
||||
|
||||
const cleanup2 = api.onRestoreTornTab(() => {});
|
||||
expect(cleanup2).toBeInstanceOf(Function);
|
||||
expect(() => cleanup2()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Proxy with partial window.api override', () => {
|
||||
it('merges injected window.api methods with mockApi', async () => {
|
||||
const customOpenFile = vi.fn().mockResolvedValue({
|
||||
path: '/custom.txt',
|
||||
content: 'injected',
|
||||
encoding: 'UTF-8',
|
||||
language: 'plaintext',
|
||||
viewKind: 'text',
|
||||
lineEnding: 'LF',
|
||||
imageMime: null,
|
||||
});
|
||||
|
||||
(globalThis.window as unknown as { api: Partial<WindowApi> }).api = { openFile: customOpenFile };
|
||||
|
||||
const result = await api.openFile('/custom.txt');
|
||||
|
||||
expect(customOpenFile).toHaveBeenCalledWith('/custom.txt');
|
||||
expect(result.content).toBe('injected');
|
||||
});
|
||||
|
||||
it('uses mockApi for methods not provided by window.api', async () => {
|
||||
const customOpenFile = vi.fn().mockResolvedValue({
|
||||
path: '/test.txt',
|
||||
content: 'test',
|
||||
encoding: 'UTF-8',
|
||||
language: 'plaintext',
|
||||
viewKind: 'text',
|
||||
lineEnding: 'LF',
|
||||
imageMime: null,
|
||||
});
|
||||
|
||||
(globalThis.window as unknown as { api: Partial<WindowApi> }).api = { openFile: customOpenFile };
|
||||
|
||||
// openFile is overridden, but loadSession should fall back to mockApi
|
||||
const result = await api.loadSession();
|
||||
expect(result).toBeNull();
|
||||
expect(customOpenFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('binds injected functions to the merged API object', async () => {
|
||||
const customSaveFile = vi.fn(function (this: unknown) {
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
(globalThis.window as unknown as { api: Partial<WindowApi> }).api = { saveFile: customSaveFile };
|
||||
|
||||
await api.saveFile('/test.txt', 'content', 'UTF-8');
|
||||
|
||||
expect(customSaveFile).toHaveBeenCalled();
|
||||
const callThis = customSaveFile.mock.contexts[0];
|
||||
expect(callThis).toBeDefined();
|
||||
expect(callThis).not.toBe(api); // bound to merged object, not proxy
|
||||
});
|
||||
});
|
||||
|
||||
describe('Lazy merge behavior', () => {
|
||||
it('re-evaluates window.api on every property access', async () => {
|
||||
// Start with no window.api
|
||||
expect(await api.loadSession()).toBeNull();
|
||||
|
||||
// Inject window.api between accesses
|
||||
const customLoadSession = vi.fn().mockResolvedValue('injected-session');
|
||||
(globalThis.window as unknown as { api: Partial<WindowApi> }).api = { loadSession: customLoadSession };
|
||||
|
||||
// Next access should use the injected version
|
||||
const result = await api.loadSession();
|
||||
expect(result).toBe('injected-session');
|
||||
expect(customLoadSession).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('respects window.api changes between multiple calls', async () => {
|
||||
const firstMock = vi.fn().mockResolvedValue('first');
|
||||
(globalThis.window as unknown as { api: Partial<WindowApi> }).api = { detectLanguage: firstMock };
|
||||
|
||||
await api.detectLanguage('', '');
|
||||
expect(firstMock).toHaveBeenCalled();
|
||||
|
||||
// Replace window.api
|
||||
const secondMock = vi.fn().mockResolvedValue('second');
|
||||
(globalThis.window as unknown as { api: Partial<WindowApi> }).api = { detectLanguage: secondMock };
|
||||
|
||||
await api.detectLanguage('', '');
|
||||
expect(secondMock).toHaveBeenCalled();
|
||||
expect(firstMock).toHaveBeenCalledTimes(1); // still only once
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error handling in mockApi', () => {
|
||||
it('resolves showMessageBox without throwing', async () => {
|
||||
await expect(
|
||||
api.showMessageBox({ title: 'Test', message: 'Message', kind: 'info' }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('resolves saveFile without throwing', async () => {
|
||||
await expect(api.saveFile('/test.txt', 'content', 'UTF-8')).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('resolves saveSession without throwing', async () => {
|
||||
await expect(api.saveSession('{}')).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('resolves updateTabContent without throwing', async () => {
|
||||
await expect(api.updateTabContent('tab-1', 'new content')).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Type safety and interface compliance', () => {
|
||||
it('exposes all WindowApi methods', () => {
|
||||
const methods: Array<keyof WindowApi> = [
|
||||
'openFile',
|
||||
'saveFile',
|
||||
'saveFileAs',
|
||||
'revertFile',
|
||||
'readFileWithEncoding',
|
||||
'readBinary',
|
||||
'loadSession',
|
||||
'saveSession',
|
||||
'updateTabContent',
|
||||
'openFolder',
|
||||
'readDirectory',
|
||||
'listWorkspaceFiles',
|
||||
'detectLanguage',
|
||||
'lintDocument',
|
||||
'formatDocument',
|
||||
'detectEncoding',
|
||||
'listLanguages',
|
||||
'listEncodings',
|
||||
'minimize',
|
||||
'toggleMaximize',
|
||||
'close',
|
||||
'showMessageBox',
|
||||
'showOpenDialog',
|
||||
'onFilesDropped',
|
||||
'tearOffTab',
|
||||
'onRestoreTornTab',
|
||||
];
|
||||
|
||||
methods.forEach((method) => {
|
||||
expect(api[method]).toBeDefined();
|
||||
if (typeof api[method] === 'function') {
|
||||
expect(api[method]).toBeInstanceOf(Function);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { fileIconInfo, folderIconPath, treeIconForEntry } from '../src/lib/fileIcons';
|
||||
|
||||
const ic = (f: string) => `/icons/file_icons/${f}`;
|
||||
const ic = (f: string) => `./icons/file_icons/${f}`;
|
||||
|
||||
describe('folderIconPath', () => {
|
||||
it('maps dev / test folders', () => {
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
/**
|
||||
* Supplemental tests for sessionStore hydration edge cases (Phase 5C).
|
||||
*
|
||||
* Coverage targets:
|
||||
* - Session hydration with missing/null fields
|
||||
* - Corrupt/invalid JSON handling
|
||||
* - formatOnSave migration path variations
|
||||
* - dockLayout persistence
|
||||
* - Empty session fallback (seed blank tab)
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { loadSession, useSessionStore } from '../src/state/sessionStore';
|
||||
import { useEditorStore } from '../src/state/editorStore';
|
||||
import { useUiStore } from '../src/state/uiStore';
|
||||
import { __setDesktopBridgeForTests } from '../src/desktop-bridge';
|
||||
import type { ByteDraftDesktop } from '../src/desktop-bridge';
|
||||
|
||||
function stubBridge(overrides: Record<string, unknown> = {}): ByteDraftDesktop {
|
||||
return {
|
||||
windowLabel: 'main',
|
||||
isMainWindow: true,
|
||||
invoke: vi.fn().mockResolvedValue(undefined),
|
||||
minimize: vi.fn(),
|
||||
toggleMaximize: vi.fn(),
|
||||
closeWindow: vi.fn(),
|
||||
emit: vi.fn(),
|
||||
subscribe: () => () => {},
|
||||
...overrides,
|
||||
} as unknown as ByteDraftDesktop;
|
||||
}
|
||||
|
||||
function resetStores() {
|
||||
useSessionStore.setState({ workspaceRoot: null, themeName: 'Notepads Dark', dockLayout: null });
|
||||
useUiStore.setState({
|
||||
wordWrap: false,
|
||||
showLineEndings: false,
|
||||
layoutMode: 'classic',
|
||||
accentColor: 'blue',
|
||||
formatOnSave: false,
|
||||
formatOnPaste: false,
|
||||
});
|
||||
useEditorStore.getState().hydrateFromSession([], null);
|
||||
}
|
||||
|
||||
describe('sessionStore hydration edge cases', () => {
|
||||
beforeEach(() => {
|
||||
resetStores();
|
||||
__setDesktopBridgeForTests(undefined);
|
||||
});
|
||||
|
||||
describe('loadSession with missing or corrupt data', () => {
|
||||
it('seeds blank tab when bridge returns null session', async () => {
|
||||
const invoke = vi.fn().mockResolvedValue(null);
|
||||
__setDesktopBridgeForTests(stubBridge({ invoke }));
|
||||
|
||||
await loadSession();
|
||||
|
||||
const { tabs } = useEditorStore.getState();
|
||||
expect(tabs).toHaveLength(1);
|
||||
expect(tabs[0]?.path).toBeNull();
|
||||
expect(tabs[0]?.content).toBe('');
|
||||
expect(invoke).toHaveBeenCalledWith('load_session');
|
||||
});
|
||||
|
||||
it('seeds blank tab when bridge throws an error', async () => {
|
||||
const invoke = vi.fn().mockRejectedValue(new Error('Disk read failed'));
|
||||
__setDesktopBridgeForTests(stubBridge({ invoke }));
|
||||
|
||||
await loadSession();
|
||||
|
||||
const { tabs } = useEditorStore.getState();
|
||||
expect(tabs).toHaveLength(1);
|
||||
expect(tabs[0]?.path).toBeNull();
|
||||
expect(tabs[0]?.content).toBe('');
|
||||
});
|
||||
|
||||
it('seeds blank tab when no bridge is available (browser dev)', async () => {
|
||||
__setDesktopBridgeForTests(null);
|
||||
|
||||
await loadSession();
|
||||
|
||||
const { tabs } = useEditorStore.getState();
|
||||
expect(tabs).toHaveLength(1);
|
||||
expect(tabs[0]?.path).toBeNull();
|
||||
expect(tabs[0]?.content).toBe('');
|
||||
});
|
||||
|
||||
it('gracefully handles malformed JSON in session data', async () => {
|
||||
const invoke = vi.fn(async (cmd: string) => {
|
||||
if (cmd === 'load_session') return 'not valid json{';
|
||||
return null;
|
||||
});
|
||||
__setDesktopBridgeForTests(stubBridge({ invoke }));
|
||||
|
||||
await loadSession();
|
||||
|
||||
const { tabs } = useEditorStore.getState();
|
||||
expect(tabs).toHaveLength(1);
|
||||
expect(tabs[0]?.path).toBeNull();
|
||||
expect(tabs[0]?.content).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('dockLayout persistence', () => {
|
||||
it('restores dockLayout from session data', async () => {
|
||||
const layout = { type: 'grid', panels: [] };
|
||||
const sessionJson = JSON.stringify({
|
||||
version: 1,
|
||||
tabs: [],
|
||||
activeTabId: null,
|
||||
workspaceRoot: null,
|
||||
themeName: 'Notepads Dark',
|
||||
dockLayout: layout,
|
||||
});
|
||||
const invoke = vi.fn(async (cmd: string) => {
|
||||
if (cmd === 'load_session') return sessionJson;
|
||||
if (cmd === 'load_ui_prefs') return null;
|
||||
return null;
|
||||
});
|
||||
__setDesktopBridgeForTests(stubBridge({ invoke }));
|
||||
|
||||
await loadSession();
|
||||
|
||||
const { dockLayout } = useSessionStore.getState();
|
||||
expect(dockLayout).toEqual(layout);
|
||||
});
|
||||
|
||||
it('sets dockLayout to null when not present in session', async () => {
|
||||
useSessionStore.setState({ dockLayout: { existing: true } });
|
||||
const sessionJson = JSON.stringify({
|
||||
version: 1,
|
||||
tabs: [],
|
||||
activeTabId: null,
|
||||
workspaceRoot: null,
|
||||
themeName: 'Notepads Dark',
|
||||
});
|
||||
const invoke = vi.fn(async (cmd: string) => {
|
||||
if (cmd === 'load_session') return sessionJson;
|
||||
if (cmd === 'load_ui_prefs') return null;
|
||||
return null;
|
||||
});
|
||||
__setDesktopBridgeForTests(stubBridge({ invoke }));
|
||||
|
||||
await loadSession();
|
||||
|
||||
const { dockLayout } = useSessionStore.getState();
|
||||
expect(dockLayout).toEqual({ existing: true }); // unchanged when field absent
|
||||
});
|
||||
|
||||
it('sets dockLayout to null when explicitly null in session', async () => {
|
||||
useSessionStore.setState({ dockLayout: { existing: true } });
|
||||
const sessionJson = JSON.stringify({
|
||||
version: 1,
|
||||
tabs: [],
|
||||
activeTabId: null,
|
||||
workspaceRoot: null,
|
||||
themeName: 'Notepads Dark',
|
||||
dockLayout: null,
|
||||
});
|
||||
const invoke = vi.fn(async (cmd: string) => {
|
||||
if (cmd === 'load_session') return sessionJson;
|
||||
if (cmd === 'load_ui_prefs') return null;
|
||||
return null;
|
||||
});
|
||||
__setDesktopBridgeForTests(stubBridge({ invoke }));
|
||||
|
||||
await loadSession();
|
||||
|
||||
const { dockLayout } = useSessionStore.getState();
|
||||
expect(dockLayout).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatOnSave migration path variations', () => {
|
||||
it('applies formatOnSave from ui-prefs when both session and prefs present', async () => {
|
||||
const sessionJson = JSON.stringify({
|
||||
version: 1,
|
||||
tabs: [],
|
||||
activeTabId: null,
|
||||
workspaceRoot: null,
|
||||
themeName: 'Notepads Dark',
|
||||
formatOnSave: false, // legacy field
|
||||
formatOnPaste: false,
|
||||
wordWrap: false,
|
||||
showLineEndings: false,
|
||||
layoutMode: 'classic',
|
||||
accentColor: 'blue',
|
||||
});
|
||||
const prefsJson = JSON.stringify({
|
||||
version: 1,
|
||||
formatOnSave: true, // ui-prefs takes precedence
|
||||
formatOnPaste: false,
|
||||
wordWrap: false,
|
||||
showLineEndings: false,
|
||||
layoutMode: 'classic',
|
||||
accentColor: 'blue',
|
||||
});
|
||||
const invoke = vi.fn(async (cmd: string) => {
|
||||
if (cmd === 'load_session') return sessionJson;
|
||||
if (cmd === 'load_ui_prefs') return prefsJson;
|
||||
return null;
|
||||
});
|
||||
__setDesktopBridgeForTests(stubBridge({ invoke }));
|
||||
|
||||
await loadSession();
|
||||
|
||||
expect(useUiStore.getState().formatOnSave).toBe(true);
|
||||
});
|
||||
|
||||
it('migrates formatOnSave from legacy session when ui-prefs is null', async () => {
|
||||
const sessionJson = JSON.stringify({
|
||||
version: 1,
|
||||
tabs: [],
|
||||
activeTabId: null,
|
||||
workspaceRoot: null,
|
||||
themeName: 'Notepads Dark',
|
||||
formatOnSave: true, // legacy field should be migrated
|
||||
formatOnPaste: false,
|
||||
wordWrap: false,
|
||||
showLineEndings: false,
|
||||
layoutMode: 'classic',
|
||||
accentColor: 'blue',
|
||||
});
|
||||
const invoke = vi.fn(async (cmd: string) => {
|
||||
if (cmd === 'load_session') return sessionJson;
|
||||
if (cmd === 'load_ui_prefs') return null;
|
||||
return null;
|
||||
});
|
||||
__setDesktopBridgeForTests(stubBridge({ invoke }));
|
||||
|
||||
await loadSession();
|
||||
|
||||
expect(useUiStore.getState().formatOnSave).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores malformed ui-prefs and falls back to legacy session fields', async () => {
|
||||
const sessionJson = JSON.stringify({
|
||||
version: 1,
|
||||
tabs: [],
|
||||
activeTabId: null,
|
||||
workspaceRoot: null,
|
||||
themeName: 'Notepads Dark',
|
||||
formatOnSave: true,
|
||||
formatOnPaste: false,
|
||||
wordWrap: false,
|
||||
showLineEndings: false,
|
||||
layoutMode: 'classic',
|
||||
accentColor: 'blue',
|
||||
});
|
||||
const invoke = vi.fn(async (cmd: string) => {
|
||||
if (cmd === 'load_session') return sessionJson;
|
||||
if (cmd === 'load_ui_prefs') return 'invalid json{';
|
||||
return null;
|
||||
});
|
||||
__setDesktopBridgeForTests(stubBridge({ invoke }));
|
||||
|
||||
await loadSession();
|
||||
|
||||
expect(useUiStore.getState().formatOnSave).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores ui-prefs with wrong version and uses legacy fields', async () => {
|
||||
const sessionJson = JSON.stringify({
|
||||
version: 1,
|
||||
tabs: [],
|
||||
activeTabId: null,
|
||||
workspaceRoot: null,
|
||||
themeName: 'Notepads Dark',
|
||||
formatOnSave: true,
|
||||
formatOnPaste: false,
|
||||
wordWrap: false,
|
||||
showLineEndings: false,
|
||||
layoutMode: 'classic',
|
||||
accentColor: 'blue',
|
||||
});
|
||||
const prefsJson = JSON.stringify({
|
||||
version: 999, // wrong version
|
||||
formatOnSave: false,
|
||||
formatOnPaste: false,
|
||||
wordWrap: false,
|
||||
showLineEndings: false,
|
||||
layoutMode: 'classic',
|
||||
accentColor: 'blue',
|
||||
});
|
||||
const invoke = vi.fn(async (cmd: string) => {
|
||||
if (cmd === 'load_session') return sessionJson;
|
||||
if (cmd === 'load_ui_prefs') return prefsJson;
|
||||
return null;
|
||||
});
|
||||
__setDesktopBridgeForTests(stubBridge({ invoke }));
|
||||
|
||||
await loadSession();
|
||||
|
||||
expect(useUiStore.getState().formatOnSave).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tab restoration', () => {
|
||||
it('restores tabs with all fields from session', async () => {
|
||||
const tab = {
|
||||
id: 'tab-1',
|
||||
path: '/tmp/test.txt',
|
||||
content: 'hello',
|
||||
encoding: 'UTF-8',
|
||||
language: 'plaintext',
|
||||
languageOverride: null,
|
||||
isDirty: false,
|
||||
viewKind: 'text' as const,
|
||||
lineEnding: 'LF' as const,
|
||||
cursorLine: 5,
|
||||
cursorCol: 10,
|
||||
};
|
||||
const sessionJson = JSON.stringify({
|
||||
version: 1,
|
||||
tabs: [tab],
|
||||
activeTabId: 'tab-1',
|
||||
workspaceRoot: null,
|
||||
themeName: 'Notepads Dark',
|
||||
});
|
||||
const invoke = vi.fn(async (cmd: string) => {
|
||||
if (cmd === 'load_session') return sessionJson;
|
||||
if (cmd === 'load_ui_prefs') return null;
|
||||
return null;
|
||||
});
|
||||
__setDesktopBridgeForTests(stubBridge({ invoke }));
|
||||
|
||||
await loadSession();
|
||||
|
||||
const { tabs, activeTabId } = useEditorStore.getState();
|
||||
expect(tabs).toHaveLength(1);
|
||||
expect(tabs[0]).toMatchObject(tab);
|
||||
expect(activeTabId).toBe('tab-1');
|
||||
});
|
||||
|
||||
it('restores empty tabs array when session has no tabs', async () => {
|
||||
const sessionJson = JSON.stringify({
|
||||
version: 1,
|
||||
tabs: [],
|
||||
activeTabId: null,
|
||||
workspaceRoot: null,
|
||||
themeName: 'Notepads Dark',
|
||||
});
|
||||
const invoke = vi.fn(async (cmd: string) => {
|
||||
if (cmd === 'load_session') return sessionJson;
|
||||
if (cmd === 'load_ui_prefs') return null;
|
||||
return null;
|
||||
});
|
||||
__setDesktopBridgeForTests(stubBridge({ invoke }));
|
||||
|
||||
await loadSession();
|
||||
|
||||
const { tabs } = useEditorStore.getState();
|
||||
// hydrateFromSession([]) creates a blank tab
|
||||
expect(tabs).toHaveLength(1);
|
||||
expect(tabs[0]?.path).toBeNull();
|
||||
expect(tabs[0]?.content).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('UI prefs without session', () => {
|
||||
it('applies ui-prefs even when session is null', async () => {
|
||||
const prefsJson = JSON.stringify({
|
||||
version: 1,
|
||||
formatOnSave: true,
|
||||
formatOnPaste: true,
|
||||
wordWrap: true,
|
||||
showLineEndings: true,
|
||||
layoutMode: 'minimal',
|
||||
accentColor: 'amber',
|
||||
});
|
||||
const invoke = vi.fn(async (cmd: string) => {
|
||||
if (cmd === 'load_session') return null;
|
||||
if (cmd === 'load_ui_prefs') return prefsJson;
|
||||
return null;
|
||||
});
|
||||
__setDesktopBridgeForTests(stubBridge({ invoke }));
|
||||
|
||||
await loadSession();
|
||||
|
||||
const ui = useUiStore.getState();
|
||||
expect(ui.formatOnSave).toBe(true);
|
||||
expect(ui.formatOnPaste).toBe(true);
|
||||
expect(ui.wordWrap).toBe(true);
|
||||
expect(ui.showLineEndings).toBe(true);
|
||||
expect(ui.layoutMode).toBe('minimal');
|
||||
expect(ui.accentColor).toBe('amber');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* Unit tests for useFileSystem openFile / openFolder dialog flows.
|
||||
*
|
||||
* Coverage targets (Phase 5A):
|
||||
* - openFile: with dialog (happy path, cancel, error)
|
||||
* - openFolder: with dialog (happy path, cancel)
|
||||
* - openFileByPath: already open file activation, multi-window conflict
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { api } from '../src/api';
|
||||
import { openFile, openFolder, openFileByPath } from '../src/hooks/useFileSystem';
|
||||
import { useEditorStore } from '../src/state/editorStore';
|
||||
import { useSessionStore } from '../src/state/sessionStore';
|
||||
import type { Tab } from '../src/types';
|
||||
import * as windowBus from '../src/state/windowBus';
|
||||
|
||||
// ── Mocks ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock('../src/api', () => ({
|
||||
api: {
|
||||
openFile: vi.fn(),
|
||||
detectLanguage: vi.fn(),
|
||||
readDirectory: vi.fn(),
|
||||
showMessageBox: vi.fn(),
|
||||
saveFile: vi.fn(),
|
||||
saveFileAs: vi.fn(),
|
||||
revertFile: vi.fn(),
|
||||
readFileWithEncoding: vi.fn(),
|
||||
readBinary: vi.fn(),
|
||||
loadSession: vi.fn(),
|
||||
saveSession: vi.fn(),
|
||||
updateTabContent: vi.fn(),
|
||||
openFolder: vi.fn(),
|
||||
listWorkspaceFiles: vi.fn(),
|
||||
lintDocument: vi.fn(),
|
||||
formatDocument: vi.fn((content: string) => Promise.resolve(content)),
|
||||
detectEncoding: vi.fn(),
|
||||
listLanguages: vi.fn(),
|
||||
listEncodings: vi.fn(),
|
||||
minimize: vi.fn(),
|
||||
toggleMaximize: vi.fn(),
|
||||
close: vi.fn(),
|
||||
showOpenDialog: vi.fn(),
|
||||
onFilesDropped: vi.fn(() => () => {}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../src/state/windowBus', () => ({
|
||||
findFileInOtherWindows: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeTab(overrides: Partial<Tab> = {}): Tab {
|
||||
return {
|
||||
id: `tab-${Math.random().toString(36).slice(2)}`,
|
||||
path: '/tmp/test.txt',
|
||||
content: 'hello',
|
||||
encoding: 'UTF-8',
|
||||
language: 'plaintext',
|
||||
languageOverride: null,
|
||||
isDirty: false,
|
||||
viewKind: 'text',
|
||||
lineEnding: 'LF',
|
||||
cursorLine: 1,
|
||||
cursorCol: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function resetStores() {
|
||||
// hydrateFromSession([]) seeds a blank tab, so we need to clear it
|
||||
useEditorStore.getState().hydrateFromSession([], null);
|
||||
useEditorStore.setState({ tabs: [], activeTabId: null });
|
||||
useSessionStore.setState({ workspaceRoot: null, themeName: 'Notepads Dark', dockLayout: null });
|
||||
}
|
||||
|
||||
function addTab(tab: Tab) {
|
||||
useEditorStore.getState().hydrateFromSession([tab], tab.id);
|
||||
}
|
||||
|
||||
// ── openFile (with dialog) ────────────────────────────────────────────────────
|
||||
|
||||
describe('openFile', () => {
|
||||
beforeEach(() => {
|
||||
resetStores();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('shows file picker when no path is provided', async () => {
|
||||
vi.mocked(api.showOpenDialog).mockResolvedValue('/tmp/picked.txt');
|
||||
vi.mocked(api.openFile).mockResolvedValue({
|
||||
path: '/tmp/picked.txt',
|
||||
content: 'content',
|
||||
encoding: 'UTF-8',
|
||||
language: 'plaintext',
|
||||
viewKind: 'text',
|
||||
lineEnding: 'LF',
|
||||
imageMime: null,
|
||||
});
|
||||
|
||||
await openFile();
|
||||
|
||||
expect(api.showOpenDialog).toHaveBeenCalledWith({ multiple: false, title: 'Open File' });
|
||||
expect(api.openFile).toHaveBeenCalledWith('/tmp/picked.txt');
|
||||
const tabs = useEditorStore.getState().tabs;
|
||||
expect(tabs).toHaveLength(1);
|
||||
expect(tabs[0]?.path).toBe('/tmp/picked.txt');
|
||||
});
|
||||
|
||||
it('does nothing when dialog is cancelled (null returned)', async () => {
|
||||
vi.mocked(api.showOpenDialog).mockResolvedValue(null);
|
||||
|
||||
await openFile();
|
||||
|
||||
expect(api.showOpenDialog).toHaveBeenCalled();
|
||||
expect(api.openFile).not.toHaveBeenCalled();
|
||||
const tabs = useEditorStore.getState().tabs;
|
||||
expect(tabs).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('opens file directly when path is provided', async () => {
|
||||
vi.mocked(api.openFile).mockResolvedValue({
|
||||
path: '/direct/path.md',
|
||||
content: '# Direct',
|
||||
encoding: 'UTF-8',
|
||||
language: 'markdown',
|
||||
viewKind: 'text',
|
||||
lineEnding: 'LF',
|
||||
imageMime: null,
|
||||
});
|
||||
|
||||
await openFile('/direct/path.md');
|
||||
|
||||
expect(api.showOpenDialog).not.toHaveBeenCalled();
|
||||
expect(api.openFile).toHaveBeenCalledWith('/direct/path.md');
|
||||
const tabs = useEditorStore.getState().tabs;
|
||||
expect(tabs).toHaveLength(1);
|
||||
expect(tabs[0]?.content).toBe('# Direct');
|
||||
});
|
||||
|
||||
it('handles api.openFile errors gracefully', async () => {
|
||||
vi.mocked(api.showOpenDialog).mockResolvedValue('/tmp/missing.txt');
|
||||
vi.mocked(api.openFile).mockRejectedValue(new Error('File not found'));
|
||||
|
||||
await expect(openFile()).rejects.toThrow('File not found');
|
||||
const tabs = useEditorStore.getState().tabs;
|
||||
expect(tabs).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── openFolder (with dialog) ──────────────────────────────────────────────────
|
||||
|
||||
describe('openFolder', () => {
|
||||
beforeEach(() => {
|
||||
resetStores();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('shows folder picker and sets workspace root on success', async () => {
|
||||
vi.mocked(api.openFolder).mockResolvedValue('/home/user/projects');
|
||||
|
||||
await openFolder();
|
||||
|
||||
expect(api.openFolder).toHaveBeenCalled();
|
||||
const { workspaceRoot } = useSessionStore.getState();
|
||||
expect(workspaceRoot).toBe('/home/user/projects');
|
||||
});
|
||||
|
||||
it('does nothing when dialog is cancelled (null returned)', async () => {
|
||||
vi.mocked(api.openFolder).mockResolvedValue(null);
|
||||
|
||||
await openFolder();
|
||||
|
||||
expect(api.openFolder).toHaveBeenCalled();
|
||||
const { workspaceRoot } = useSessionStore.getState();
|
||||
expect(workspaceRoot).toBeNull();
|
||||
});
|
||||
|
||||
it('handles api.openFolder errors gracefully', async () => {
|
||||
vi.mocked(api.openFolder).mockRejectedValue(new Error('Permission denied'));
|
||||
|
||||
await expect(openFolder()).rejects.toThrow('Permission denied');
|
||||
const { workspaceRoot } = useSessionStore.getState();
|
||||
expect(workspaceRoot).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── openFileByPath (extended coverage) ────────────────────────────────────────
|
||||
|
||||
describe('openFileByPath', () => {
|
||||
beforeEach(() => {
|
||||
resetStores();
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(windowBus.findFileInOtherWindows).mockReturnValue(null);
|
||||
});
|
||||
|
||||
it('activates existing tab when file is already open', async () => {
|
||||
const tab = makeTab({ path: '/tmp/existing.txt' });
|
||||
addTab(tab);
|
||||
|
||||
await openFileByPath('/tmp/existing.txt');
|
||||
|
||||
expect(api.openFile).not.toHaveBeenCalled();
|
||||
const { activeTabId, tabs } = useEditorStore.getState();
|
||||
expect(activeTabId).toBe(tab.id);
|
||||
expect(tabs).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('shows multi-window conflict warning when file is open elsewhere', async () => {
|
||||
vi.mocked(windowBus.findFileInOtherWindows).mockReturnValue('window-2');
|
||||
vi.spyOn(globalThis, 'confirm').mockReturnValue(false);
|
||||
|
||||
await openFileByPath('/tmp/conflicted.txt');
|
||||
|
||||
expect(globalThis.confirm).toHaveBeenCalledWith(
|
||||
expect.stringContaining('"conflicted.txt" is already open in window-2'),
|
||||
);
|
||||
expect(api.openFile).not.toHaveBeenCalled();
|
||||
const tabs = useEditorStore.getState().tabs;
|
||||
expect(tabs).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('proceeds with opening when user confirms multi-window conflict', async () => {
|
||||
vi.mocked(windowBus.findFileInOtherWindows).mockReturnValue('main');
|
||||
vi.spyOn(globalThis, 'confirm').mockReturnValue(true);
|
||||
vi.mocked(api.openFile).mockResolvedValue({
|
||||
path: '/tmp/conflicted.txt',
|
||||
content: 'risky',
|
||||
encoding: 'UTF-8',
|
||||
language: 'plaintext',
|
||||
viewKind: 'text',
|
||||
lineEnding: 'LF',
|
||||
imageMime: null,
|
||||
});
|
||||
|
||||
await openFileByPath('/tmp/conflicted.txt');
|
||||
|
||||
expect(globalThis.confirm).toHaveBeenCalledWith(
|
||||
expect.stringContaining('"conflicted.txt" is already open in the main window'),
|
||||
);
|
||||
expect(api.openFile).toHaveBeenCalledWith('/tmp/conflicted.txt');
|
||||
const tabs = useEditorStore.getState().tabs;
|
||||
expect(tabs).toHaveLength(1);
|
||||
expect(tabs[0]?.content).toBe('risky');
|
||||
});
|
||||
|
||||
it('opens image files with viewKind=image', async () => {
|
||||
vi.mocked(api.openFile).mockResolvedValue({
|
||||
path: '/tmp/photo.png',
|
||||
content: '',
|
||||
encoding: 'Binary',
|
||||
language: 'plaintext',
|
||||
viewKind: 'image',
|
||||
lineEnding: 'LF',
|
||||
imageMime: 'image/png',
|
||||
});
|
||||
|
||||
await openFileByPath('/tmp/photo.png');
|
||||
|
||||
const tabs = useEditorStore.getState().tabs;
|
||||
expect(tabs).toHaveLength(1);
|
||||
expect(tabs[0]?.viewKind).toBe('image');
|
||||
expect(tabs[0]?.imageMime).toBe('image/png');
|
||||
expect(tabs[0]?.savedContent).toBeUndefined();
|
||||
});
|
||||
|
||||
it('sets savedContent for text files', async () => {
|
||||
vi.mocked(api.openFile).mockResolvedValue({
|
||||
path: '/tmp/doc.txt',
|
||||
content: 'original text',
|
||||
encoding: 'UTF-8',
|
||||
language: 'plaintext',
|
||||
viewKind: 'text',
|
||||
lineEnding: 'LF',
|
||||
imageMime: null,
|
||||
});
|
||||
|
||||
await openFileByPath('/tmp/doc.txt');
|
||||
|
||||
const tabs = useEditorStore.getState().tabs;
|
||||
expect(tabs).toHaveLength(1);
|
||||
expect(tabs[0]?.savedContent).toBe('original text');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user