diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4e1594500..506691ff5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -3,29 +3,31 @@ name: Test on: push: branches: [main] - paths-ignore: - - "docs/**" - - "*.md" - # Content-checked by contract tests (vitest + cargo test) — must still run CI. - - "!docs/doctoring/release-artifact-provenance.md" - - "!docs/doctoring/tauri-content-security-policy.md" - - "!docs/doctoring/model-artifact-integrity.md" - - "!docs/doctoring/model-load-handle-binding.md" - - "!docs/development/icloud-local-eviction-batch.md" - - "!docs/architecture/goals/cloud-offload-goal.json" - - "!CHANGELOG.md" + paths: + - "**" + - "!docs/**" + - "!*.md" + # GitHub supports re-inclusion only with ordered positive patterns under `paths`. + - "docs/doctoring/release-artifact-provenance.md" + - "docs/doctoring/tauri-content-security-policy.md" + - "docs/doctoring/model-artifact-integrity.md" + - "docs/doctoring/model-load-handle-binding.md" + - "docs/development/icloud-local-eviction-batch.md" + - "docs/architecture/goals/cloud-offload-goal.json" + - "CHANGELOG.md" pull_request: - paths-ignore: - - "docs/**" - - "*.md" - # Content-checked by contract tests (vitest + cargo test) — must still run CI. - - "!docs/doctoring/release-artifact-provenance.md" - - "!docs/doctoring/tauri-content-security-policy.md" - - "!docs/doctoring/model-artifact-integrity.md" - - "!docs/doctoring/model-load-handle-binding.md" - - "!docs/development/icloud-local-eviction-batch.md" - - "!docs/architecture/goals/cloud-offload-goal.json" - - "!CHANGELOG.md" + paths: + - "**" + - "!docs/**" + - "!*.md" + # GitHub supports re-inclusion only with ordered positive patterns under `paths`. + - "docs/doctoring/release-artifact-provenance.md" + - "docs/doctoring/tauri-content-security-policy.md" + - "docs/doctoring/model-artifact-integrity.md" + - "docs/doctoring/model-load-handle-binding.md" + - "docs/development/icloud-local-eviction-batch.md" + - "docs/architecture/goals/cloud-offload-goal.json" + - "CHANGELOG.md" permissions: contents: read @@ -49,7 +51,12 @@ jobs: persist-credentials: false - name: Install Tauri system deps run: | - sudo apt-get update + for source_file in /etc/apt/sources.list.d/*; do + if [[ -f "$source_file" ]] && grep -q 'dl.google.com/linux/chrome' "$source_file"; then + sudo rm -f "$source_file" + fi + done + sudo apt-get -o Acquire::Retries=3 update sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev lsof - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 @@ -80,9 +87,34 @@ jobs: - run: npm test - run: npm run build + macos-cache-cleanup: + runs-on: macos-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + ref: ${{ github.event.pull_request.head.sha || github.sha }} + - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + with: + workspaces: src-tauri + cache-targets: false + - name: macOS cache cleanup regressions when owner source is present + env: + TMPDIR: ${{ runner.temp }} + run: | + for test_name in cache_cleanup_corepack_scope cache_cleanup_cli_permanent_gradle generated_cache_staged_activity; do + if [[ -f "src-tauri/tests/${test_name}.rs" ]]; then + cargo test --manifest-path src-tauri/Cargo.toml --test "$test_name" + else + printf 'SKIP %s: owner test source absent; no runtime regression executed\n' "$test_name" + fi + done + windows-home-resolution: runs-on: windows-latest - timeout-minutes: 10 + timeout-minutes: 30 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -95,6 +127,26 @@ jobs: New-Item -ItemType Directory -Force target | Out-Null rustc --edition=2021 --test src-tauri/tests/home_resolution_contract.rs -o target/home-resolution-contract.exe & .\target\home-resolution-contract.exe + - name: Windows agent-state regression when owner source is present + shell: pwsh + run: | + if (Test-Path 'src-tauri/src/agent_state_guard.rs') { + rustc --edition=2021 --test src-tauri/src/agent_state_guard.rs -o target/agent-state-guard.exe + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + & .\target\agent-state-guard.exe --nocapture + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + } else { + Write-Output 'SKIP agent_state_guard: owner source absent; no runtime regression executed' + } + - name: Windows provider OAuth process contract when owner source is present + shell: pwsh + run: | + if (Test-Path 'src-tauri/tests/provider_oauth_cli_process.rs') { + cargo test --manifest-path src-tauri/Cargo.toml --locked --features cloud-cli --test provider_oauth_cli_process + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + } else { + Write-Output 'SKIP provider_oauth_cli_process: owner source absent; no runtime regression executed' + } llm-engine-build: runs-on: ubuntu-latest @@ -106,7 +158,12 @@ jobs: persist-credentials: false - name: Install build deps (llama.cpp native + tauri) run: | - sudo apt-get update + for source_file in /etc/apt/sources.list.d/*; do + if [[ -f "$source_file" ]] && grep -q 'dl.google.com/linux/chrome' "$source_file"; then + sudo rm -f "$source_file" + fi + done + sudo apt-get -o Acquire::Retries=3 update sudo apt-get install -y cmake clang libclang-dev libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 @@ -114,4 +171,4 @@ jobs: workspaces: src-tauri cache-targets: false - name: Build with llm-engine (compiles real llama.cpp CPU + engine.rs FFI) - run: cargo test --manifest-path src-tauri/Cargo.toml --features llm-engine --lib --no-run + run: cargo test --manifest-path src-tauri/Cargo.toml --features llm-engine --lib --no-run \ No newline at end of file diff --git a/src/lib/testWorkflowExactHeadContract.test.ts b/src/lib/testWorkflowExactHeadContract.test.ts index 008abb15e..2acdc1e5f 100644 --- a/src/lib/testWorkflowExactHeadContract.test.ts +++ b/src/lib/testWorkflowExactHeadContract.test.ts @@ -13,7 +13,7 @@ describe("Test workflow checkout provenance", () => { line.includes("- uses: actions/checkout@") ? [index] : [], ); - expect(checkoutIndexes).toHaveLength(3); + expect(checkoutIndexes.length).toBeGreaterThanOrEqual(4); for (const checkoutIndex of checkoutIndexes) { const stepIndent = lines[checkoutIndex].match(/^(\s*)/)?.[1] ?? ""; let endIndex = checkoutIndex + 1; diff --git a/src/lib/testWorkflowPathFilterContract.test.ts b/src/lib/testWorkflowPathFilterContract.test.ts new file mode 100644 index 000000000..202856d6e --- /dev/null +++ b/src/lib/testWorkflowPathFilterContract.test.ts @@ -0,0 +1,137 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { spawnSync } from "node:child_process"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); +const workflow = readFileSync(resolve(repositoryRoot, ".github/workflows/test.yml"), "utf8"); + +function scalarValue(raw: string): string { + const value = raw.trim(); + if (value.startsWith('"')) { + const end = value.indexOf('"', 1); + return end >= 0 ? value.slice(1, end) : value.slice(1); + } + if (value.startsWith("'")) { + const end = value.indexOf("'", 1); + return end >= 0 ? value.slice(1, end) : value.slice(1); + } + return value.split(/\s+#/, 1)[0].trim(); +} + +function negativePathsIgnoreEntries(source: string): string[] { + const negatives: string[] = []; + const lines = source.split(/\r?\n/); + + for (let index = 0; index < lines.length; index += 1) { + const key = lines[index].match(/^(\s*)paths-ignore:\s*(.*)$/); + if (!key) continue; + + const keyIndent = key[1].length; + const inline = key[2].trim(); + if (inline) { + const listBody = inline.startsWith("[") && inline.endsWith("]") + ? inline.slice(1, -1) + : inline; + for (const rawItem of listBody.split(",")) { + const value = scalarValue(rawItem); + if (value.startsWith("!")) negatives.push(value); + } + continue; + } + + for (let cursor = index + 1; cursor < lines.length; cursor += 1) { + const line = lines[cursor]; + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + + const indent = line.length - line.trimStart().length; + if (indent <= keyIndent) break; + + const listItem = trimmed.match(/^-\s*(.+)$/); + if (!listItem) continue; + const value = scalarValue(listItem[1]); + if (value.startsWith("!")) negatives.push(value); + } + } + + return negatives; +} + +describe("test workflow path-filter contract", () => { + it("detects negative paths-ignore entries after comments and in inline lists", () => { + const fixtures = [ + `pull_request:\n paths-ignore:\n - "docs/**"\n # contract exception\n - "!docs/example.md"\n`, + `push:\n paths-ignore: ["docs/**", "!docs/example.md"]\n`, + ]; + + for (const fixture of fixtures) { + expect(negativePathsIgnoreEntries(fixture)).toContain("!docs/example.md"); + } + }); + + it("does not put negative globs under paths-ignore", () => { + expect(negativePathsIgnoreEntries(workflow)).toEqual([]); + }); + + it("runs the Windows agent-state regression when that owner source is present", () => { + expect(workflow).toContain("Test-Path 'src-tauri/src/agent_state_guard.rs'"); + expect(workflow).toContain( + "rustc --edition=2021 --test src-tauri/src/agent_state_guard.rs -o target/agent-state-guard.exe", + ); + expect(workflow).toContain("& .\\target\\agent-state-guard.exe --nocapture"); + }); + + it("reports absent Windows agent-state source without claiming runtime evidence", () => { + expect(workflow).toContain( + "SKIP agent_state_guard: owner source absent; no runtime regression executed", + ); + }); + + it("runs the provider OAuth Windows process contract when that owner source is present", () => { + expect(workflow).toContain("Test-Path 'src-tauri/tests/provider_oauth_cli_process.rs'"); + expect(workflow).toContain( + "cargo test --manifest-path src-tauri/Cargo.toml --locked --features cloud-cli --test provider_oauth_cli_process", + ); + }); + + it("isolates Ubuntu dependency refresh from the hosted runner Chrome repository without weakening apt verification", () => { + expect(workflow.match(/grep -q 'dl\.google\.com\/linux\/chrome'/g)).toHaveLength(2); + expect(workflow.match(/apt-get -o Acquire::Retries=3 update/g)).toHaveLength(2); + expect(workflow).not.toContain("AllowInsecureRepositories"); + expect(workflow).not.toContain("--allow-unauthenticated"); + }); +}); + +// Exercise the canonical shell admission without compiling or faking Rust test results. +it("macOS cache job executes present owner tests, reports absent source, and propagates failure", () => { + const job = workflow.split(" macos-cache-cleanup:\n")[1]?.split(" windows-home-resolution:")[0] ?? ""; + expect(job).toContain("runs-on: macos-latest"); + expect(job).toContain("ref: ${{ github.event.pull_request.head.sha || github.sha }}"); + const script = job.match(/ run: \|\n([\s\S]*)/)?.[1].replace(/^ /gm, "") ?? ""; + for (const target of ["cache_cleanup_corepack_scope", "cache_cleanup_cli_permanent_gradle", "generated_cache_staged_activity"]) { + expect(script).toContain(target); + } + const fixture = mkdtempSync(resolve(tmpdir(), "disksage-workflow-admission-")); + try { + const bin = resolve(fixture, "bin"); + mkdirSync(bin); + const log = resolve(fixture, "cargo.log"); + writeFileSync(resolve(bin, "cargo"), "#!/usr/bin/env bash\nprintf '%s\\n' \"$*\" >> \"$CARGO_LOG\"\nexit \"${CARGO_EXIT:-0}\"\n", { mode: 0o700 }); + const env = { ...process.env, PATH: `${bin}:${process.env.PATH}`, CARGO_LOG: log }; + const run = (extra = {}) => spawnSync("bash", ["-e", "-c", script], { cwd: fixture, env: { ...env, ...extra }, encoding: "utf8" }); + const absent = run(); + expect(absent.status).toBe(0); + expect(absent.stdout.match(/no runtime regression executed/g)).toHaveLength(3); + expect(existsSync(log)).toBe(false); + mkdirSync(resolve(fixture, "src-tauri/tests"), { recursive: true }); + writeFileSync(resolve(fixture, "src-tauri/tests/generated_cache_staged_activity.rs"), ""); + expect(run().status).toBe(0); + expect(readFileSync(log, "utf8")).toBe("test --manifest-path src-tauri/Cargo.toml --test generated_cache_staged_activity\n"); + expect(run({ CARGO_EXIT: "7" }).status).toBe(7); + } finally { + rmSync(fixture, { recursive: true, force: true }); + } +}); \ No newline at end of file diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 14afcd2bb..4cf729951 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -18,58 +18,93 @@ let node: api.NodeView | null = $state(null); let crumbs: string[] = $state([]); let top: api.EntryView[] = $state([]); + let operationError = $state(""); let navSeq = 0; onMount(async () => { - roots = await api.listRoots(); - selectedRoot = roots[0] ?? ""; - await api.onScanProgress((s) => (stats = s)); - await api.onScanDone(async (s) => { - stats = s; - scanning = false; - try { - crumbs = [selectedRoot]; - node = await api.getNode(selectedRoot); - top = await api.topFiles(200); - } catch (e) { - console.error("post-scan load failed:", e); - } - }); + try { + roots = await api.listRoots(); + selectedRoot = roots[0] ?? ""; + } catch { + console.error("disk root load failed"); + operationError = "디스크 목록을 불러오지 못했습니다. DiskSage를 다시 열어 주세요."; + } + + try { + await api.onScanProgress((s) => (stats = s)); + await api.onScanDone(async (s) => { + stats = s; + scanning = false; + operationError = ""; + const resultSeq = navSeq; + const scannedRoot = selectedRoot; + try { + const [nextNode, nextTop] = await Promise.all([ + api.getNode(scannedRoot), + api.topFiles(200), + ]); + if (resultSeq !== navSeq) return; + crumbs = [scannedRoot]; + node = nextNode; + top = nextTop; + } catch { + if (resultSeq !== navSeq) return; + node = null; + top = []; + console.error("post-scan result load failed"); + operationError = "스캔 결과를 불러오지 못했습니다. 같은 폴더를 다시 스캔하세요."; + } + }); + } catch { + console.error("scan event registration failed"); + operationError = "스캔을 준비하지 못했습니다. DiskSage를 다시 열어 주세요."; + } }); async function scan() { + ++navSeq; + operationError = ""; scanning = true; node = null; top = []; try { await api.startScan(selectedRoot); - } catch (e) { + } catch { scanning = false; - alert(`스캔 시작 실패: ${e}`); + console.error("scan start failed"); + operationError = "스캔을 시작하지 못했습니다. 폴더를 다시 선택한 뒤 재시도하세요."; } } async function open(path: string) { const seq = ++navSeq; + operationError = ""; try { const n = await api.getNode(path); if (seq !== navSeq) return; // 더 새로운 내비게이션이 이미 시작됨 crumbs = [...crumbs, path]; node = n; - } catch (e) { - console.error("getNode failed:", e); + } catch { + if (seq === navSeq) { + console.error("folder navigation failed"); + operationError = "폴더 내용을 불러오지 못했습니다. 상위 폴더로 돌아가 다시 여세요."; + } } } async function jump(i: number) { const seq = ++navSeq; + operationError = ""; try { const n = await api.getNode(crumbs[i]); if (seq !== navSeq) return; crumbs = crumbs.slice(0, i + 1); node = n; - } catch (e) { - console.error("getNode failed:", e); + } catch { + if (seq === navSeq) { + console.error("folder navigation failed"); + operationError = "폴더 내용을 불러오지 못했습니다. 상위 폴더로 돌아가 다시 여세요."; + } } } @@ -83,7 +118,7 @@ {#if scanning} {:else} - + {/if} {#if stats} @@ -93,6 +128,10 @@ {/if} + {#if operationError} + + {/if} + {#if node} - + 폴더 항목 탐색 시작 +
+ {#if node.entries.length === 0} +

표시할 항목이 없습니다. 상위 폴더로 이동하거나 다른 폴더를 스캔하세요.

+ {:else} +
    + {#each node.entries as e} +
  • + {#if e.is_dir} + + {:else} + 📄 {e.name} + {/if} + {fmtBytes(e.size)} +
  • + {/each} +
+ {/if} +
{/if} - {#if top.length > 0} + {#if node} {/if} @@ -136,10 +182,15 @@ main { font-family: system-ui, sans-serif; padding: 1rem; } .controls { display: flex; gap: 0.5rem; align-items: center; } .stats { color: #666; font-size: 0.9rem; } + .error { margin: 0.75rem 0; font-weight: 600; } .crumbs { margin: 0.75rem 0; display: flex; gap: 0.25rem; flex-wrap: wrap; } .crumb { background: none; border: none; color: #06c; cursor: pointer; padding: 0; } - .entries { list-style: none; padding: 0; max-height: 40vh; overflow-y: auto; } + .entry-scroll { max-height: 40vh; overflow-y: auto; } + .entry-scroll:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; } + .entry-focus { display: inline-block; margin-block-end: 0.35rem; } + .entries { list-style: none; padding: 0; margin: 0; } .entries li { display: flex; justify-content: space-between; padding: 2px 0; } .dir { background: none; border: none; cursor: pointer; font: inherit; padding: 0; } .size { color: #666; font-variant-numeric: tabular-nums; } + .empty-entries { margin: 0; color: #555; } diff --git a/src/routes/pageEntryAccessibilityContract.test.ts b/src/routes/pageEntryAccessibilityContract.test.ts new file mode 100644 index 000000000..00c35e8d2 --- /dev/null +++ b/src/routes/pageEntryAccessibilityContract.test.ts @@ -0,0 +1,38 @@ +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); + +function readSource(path: string): string { + return readFileSync(resolve(repositoryRoot, path), "utf8"); +} + +describe("canonical scan-entry accessibility surface", () => { + it("keeps the existing entry list as the single keyboard navigation surface", () => { + const page = readSource("src/routes/+page.svelte"); + + expect(page).toContain( + '폴더 항목 탐색 시작', + ); + expect(page).toContain( + '
', + ); + expect(page).toContain('
    '); + expect(page).toContain("{#each node.entries as e}"); + expect(page).toContain('onclick={() => open(e.path)}'); + expect(page).toContain("{fmtBytes(e.size)}"); + expect(page).toContain(".entry-scroll:focus-visible"); + expect(page).toContain(".entry-focus"); + }); + + it("gives an empty scan result a visible next action instead of a blank list", () => { + const page = readSource("src/routes/+page.svelte"); + + expect(page).toContain("{#if node.entries.length === 0}"); + expect(page).toContain( + '

    표시할 항목이 없습니다. 상위 폴더로 이동하거나 다른 폴더를 스캔하세요.

    ', + ); + }); +}); diff --git a/src/routes/pageErrorFeedbackContract.test.ts b/src/routes/pageErrorFeedbackContract.test.ts new file mode 100644 index 000000000..4853126fd --- /dev/null +++ b/src/routes/pageErrorFeedbackContract.test.ts @@ -0,0 +1,78 @@ +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); + +function readSource(path: string): string { + return readFileSync(resolve(repositoryRoot, path), "utf8"); +} + +function between(source: string, start: string, end: string): string { + const startIndex = source.indexOf(start); + const endIndex = source.indexOf(end, startIndex + start.length); + expect(startIndex, `missing scope start: ${start}`).toBeGreaterThanOrEqual(0); + expect(endIndex, `missing scope end: ${end}`).toBeGreaterThan(startIndex); + return source.slice(startIndex, endIndex); +} + +describe("main scan and navigation failure feedback", () => { + it("does not expose arbitrary exceptions or hide operation failures in the console", () => { + const source = readSource("src/routes/+page.svelte"); + const mountScope = between(source, "onMount(async () => {", "async function scan()"); + const scanScope = between(source, "async function scan()", "async function open("); + const openScope = between(source, "async function open(", "async function jump("); + const jumpScope = between(source, "async function jump(", ""); + + expect(source).not.toContain('alert(`스캔 시작 실패: ${e}`)'); + expect(source).not.toContain('console.error("post-scan load failed:", e)'); + expect(source).not.toContain('console.error("getNode failed:", e)'); + + expect(mountScope).toContain('console.error("disk root load failed");'); + expect(mountScope).toContain('console.error("post-scan result load failed");'); + expect(mountScope).toContain('console.error("scan event registration failed");'); + expect(scanScope).toContain('console.error("scan start failed");'); + expect(openScope).toContain('console.error("folder navigation failed");'); + expect(jumpScope).toContain('console.error("folder navigation failed");'); + + expect(mountScope).toContain("디스크 목록을 불러오지 못했습니다. DiskSage를 다시 열어 주세요."); + expect(mountScope).toContain("스캔 결과를 불러오지 못했습니다. 같은 폴더를 다시 스캔하세요."); + expect(mountScope).toContain("스캔을 준비하지 못했습니다. DiskSage를 다시 열어 주세요."); + expect(scanScope).toContain("스캔을 시작하지 못했습니다. 폴더를 다시 선택한 뒤 재시도하세요."); + expect(openScope).toContain("폴더 내용을 불러오지 못했습니다. 상위 폴더로 돌아가 다시 여세요."); + expect(jumpScope).toContain("폴더 내용을 불러오지 못했습니다. 상위 폴더로 돌아가 다시 여세요."); + }); + + it("clears stale feedback and invalidates navigation before issuing new requests", () => { + const source = readSource("src/routes/+page.svelte"); + const scanScope = between(source, "async function scan()", "async function open("); + const openScope = between(source, "async function open(", "async function jump("); + const jumpScope = between(source, "async function jump(", ""); + + expect(scanScope).toMatch(/\+\+navSeq;[\s\S]*operationError = "";[\s\S]*api\.startScan\(selectedRoot\)/); + expect(openScope).toMatch(/const seq = \+\+navSeq;[\s\S]*operationError = "";[\s\S]*api\.getNode\(path\)/); + expect(jumpScope).toMatch(/const seq = \+\+navSeq;[\s\S]*operationError = "";[\s\S]*api\.getNode\(crumbs\[i\]\)/); + expect(openScope).toContain("if (seq !== navSeq) return;"); + expect(jumpScope).toContain("if (seq !== navSeq) return;"); + }); + + it("preserves scan and navigation authority behind one accessible alert", () => { + const source = readSource("src/routes/+page.svelte"); + + expect(source).toContain('role="alert"'); + expect(source).toContain("api.listRoots()"); + expect(source).toContain("api.onScanProgress("); + expect(source).toContain("api.onScanDone("); + expect(source).toContain("api.startScan(selectedRoot)"); + expect(source).toContain("api.getNode("); + expect(source).toContain("api.topFiles(200)"); + }); + + it("does not leave the scan action enabled when no root is available", () => { + const source = readSource("src/routes/+page.svelte"); + const controls = between(source, '
    ', "{#if stats}"); + + expect(controls).toContain("disabled={scanning || !selectedRoot}"); + }); +}); diff --git a/src/routes/postScanGenerationContract.test.ts b/src/routes/postScanGenerationContract.test.ts new file mode 100644 index 000000000..d964a3861 --- /dev/null +++ b/src/routes/postScanGenerationContract.test.ts @@ -0,0 +1,25 @@ +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); + +function readSource(path: string): string { + return readFileSync(resolve(repositoryRoot, path), "utf8"); +} + +describe("post-scan result generation", () => { + it("does not let an older result load overwrite a newer scan", () => { + const source = readSource("src/routes/+page.svelte"); + const mountStart = source.indexOf("onMount(async () => {"); + const scanStart = source.indexOf("async function scan()"); + expect(mountStart).toBeGreaterThanOrEqual(0); + expect(scanStart).toBeGreaterThan(mountStart); + const mountScope = source.slice(mountStart, scanStart); + + expect(mountScope).toMatch( + /api\.onScanDone\(async \(s\) => \{[\s\S]*const resultSeq = navSeq;[\s\S]*await Promise\.all\([\s\S]*if \(resultSeq !== navSeq\) return;[\s\S]*crumbs = \[scannedRoot\]/, + ); + }); +}); diff --git a/src/routes/topFilesEmptyReachabilityContract.test.ts b/src/routes/topFilesEmptyReachabilityContract.test.ts new file mode 100644 index 000000000..f2424ca95 --- /dev/null +++ b/src/routes/topFilesEmptyReachabilityContract.test.ts @@ -0,0 +1,31 @@ +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); + +function readSource(path: string): string { + return readFileSync(resolve(repositoryRoot, path), "utf8"); +} + +describe("TopFiles completed-scan reachability", () => { + it("mounts TopFiles after a successful result load even when the result list is empty", () => { + const source = readSource("src/routes/+page.svelte"); + + expect(source).toContain("node = nextNode;\n top = nextTop;"); + expect(source).toContain("{#if node}\n \n {/if}"); + expect(source).not.toContain("{#if top.length > 0}"); + }); + + it("keeps the TopFiles surface hidden before and during a new scan", () => { + const source = readSource("src/routes/+page.svelte"); + const scanStart = source.indexOf("async function scan()"); + const openStart = source.indexOf("async function open("); + expect(scanStart).toBeGreaterThanOrEqual(0); + expect(openStart).toBeGreaterThan(scanStart); + const scanScope = source.slice(scanStart, openStart); + + expect(scanScope).toMatch(/scanning = true;[\s\S]*node = null;[\s\S]*top = \[\];/); + }); +});