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

# 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:
2026-05-10 15:02:07 -04:00
20 changed files with 2564 additions and 171 deletions
+11 -1
View File
@@ -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:
+221 -15
View File
@@ -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
View File
@@ -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)
+276 -110
View File
@@ -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());
}
}
+116
View File
@@ -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]
+4 -4
View File
@@ -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
View File
@@ -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',
+391
View File
@@ -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
View File
@@ -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",
+134 -33
View File
@@ -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
+176
View File
@@ -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())
+2 -2
View File
@@ -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;
+4 -1
View File
@@ -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 />}
+2 -1
View File
@@ -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}`;
+2
View 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;
+270
View File
@@ -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');
});
});
});
+246
View File
@@ -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 -1
View File
@@ -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', () => {
+390
View File
@@ -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');
});
});
});
+286
View File
@@ -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');
});
});