11import {
2+ existsSync ,
23 mkdirSync ,
4+ readdirSync ,
5+ readFileSync ,
36 rmSync ,
47 utimesSync ,
58 writeFileSync ,
@@ -9,13 +12,15 @@ import test from "ava";
912import { ConfigSchema , TrainingConfigSchema } from "../types/index.js" ;
1013import {
1114 createDefaultConfig ,
15+ ensureBenchmarksDir ,
1216 findLatestGGUF ,
1317 formatConfigIssues ,
1418 listBenchmarks ,
1519 resolveBenchmarkPath ,
1620 findUnknownConfigKeys ,
1721 loadConfig ,
1822 resolveContextMessage ,
23+ writeFileAtomic ,
1924} from "./config.js" ;
2025
2126const TEST_DIR = join ( process . cwd ( ) , ".test-nanotune" ) ;
@@ -675,3 +680,118 @@ test.serial("loadConfig prints each unknown key once across loads", (t) => {
675680 rmSync ( WARN_TEST_DIR , { recursive : true , force : true } ) ;
676681 }
677682} ) ;
683+
684+
685+ // ── ensureBenchmarksDir ───────────────────────────────────────────────
686+ //
687+ // setupBenchTest leaves a project with no benchmarks directory — exactly the
688+ // state `git clone` produces, since .nanotune/.gitignore lists benchmarks/ and
689+ // only `nanotune init` ever creates it.
690+
691+ test . serial ( "ensureBenchmarksDir creates the directory when it is missing" , ( t ) => {
692+ setupBenchTest ( ) ;
693+ try {
694+ t . false ( existsSync ( BENCH_DIR ) ) ;
695+ const dir = ensureBenchmarksDir ( ) ;
696+ t . is ( dir , BENCH_DIR ) ;
697+ t . true ( existsSync ( dir ) ) ;
698+ } finally {
699+ teardownBenchTest ( ) ;
700+ }
701+ } ) ;
702+
703+ test . serial ( "ensureBenchmarksDir leaves an existing directory and its contents alone" , ( t ) => {
704+ setupBenchTest ( ) ;
705+ try {
706+ mkdirSync ( BENCH_DIR , { recursive : true } ) ;
707+ writeFileSync ( join ( BENCH_DIR , "tests.json" ) , "[]" ) ;
708+
709+ t . is ( ensureBenchmarksDir ( ) , BENCH_DIR ) ;
710+ t . is ( readFileSync ( join ( BENCH_DIR , "tests.json" ) , "utf-8" ) , "[]" ) ;
711+ } finally {
712+ teardownBenchTest ( ) ;
713+ }
714+ } ) ;
715+
716+ test . serial ( "results saved into a cloned project are discoverable afterwards" , ( t ) => {
717+ setupBenchTest ( ) ;
718+ try {
719+ // The tail of a real run: both reports land under a benchmarks directory
720+ // the clone never had, and `benchmark compare` still finds them there.
721+ const dir = ensureBenchmarksDir ( ) ;
722+ const resultPath = join ( dir , "benchmark-2026-01-01T00-00-00-000Z.json" ) ;
723+ writeFileAtomic ( resultPath , JSON . stringify ( { summary : { total : 2 } } ) ) ;
724+ writeFileAtomic ( resultPath . replace ( ".json" , ".md" ) , "# Benchmark Report" ) ;
725+
726+ t . deepEqual (
727+ listBenchmarks ( ) . map ( ( b ) => b . filename ) ,
728+ [ "benchmark-2026-01-01T00-00-00-000Z.json" ] ,
729+ ) ;
730+ t . is ( resolveBenchmarkPath ( "2026-01-01T00-00-00-000Z" ) , resultPath ) ;
731+ } finally {
732+ teardownBenchTest ( ) ;
733+ }
734+ } ) ;
735+
736+ // ── writeFileAtomic ───────────────────────────────────────────────────
737+
738+ test . serial ( "writeFileAtomic writes the contents and leaves no temp file behind" , ( t ) => {
739+ setupBenchTest ( ) ;
740+ try {
741+ const dir = ensureBenchmarksDir ( ) ;
742+ const path = join ( dir , "report.md" ) ;
743+ writeFileAtomic ( path , "# Benchmark Report" ) ;
744+
745+ t . is ( readFileSync ( path , "utf-8" ) , "# Benchmark Report" ) ;
746+ t . deepEqual ( readdirSync ( dir ) , [ "report.md" ] ) ;
747+ } finally {
748+ teardownBenchTest ( ) ;
749+ }
750+ } ) ;
751+
752+ test . serial ( "writeFileAtomic replaces an existing file" , ( t ) => {
753+ setupBenchTest ( ) ;
754+ try {
755+ const dir = ensureBenchmarksDir ( ) ;
756+ const path = join ( dir , "tests.json" ) ;
757+ writeFileAtomic ( path , "[1]" ) ;
758+ writeFileAtomic ( path , "[1,2]" ) ;
759+
760+ t . is ( readFileSync ( path , "utf-8" ) , "[1,2]" ) ;
761+ t . deepEqual ( readdirSync ( dir ) , [ "tests.json" ] ) ;
762+ } finally {
763+ teardownBenchTest ( ) ;
764+ }
765+ } ) ;
766+
767+ test . serial ( "writeFileAtomic cleans up its temp file when the rename fails" , ( t ) => {
768+ setupBenchTest ( ) ;
769+ try {
770+ const dir = ensureBenchmarksDir ( ) ;
771+ // Renaming onto a non-empty directory fails, so the write never lands.
772+ // The point is that it leaves no half-written sibling behind either — a
773+ // stray temp is how a partial write gets mistaken for a finished run.
774+ const blocked = join ( dir , "blocked" ) ;
775+ mkdirSync ( blocked , { recursive : true } ) ;
776+ writeFileSync ( join ( blocked , "keep.txt" ) , "keep" ) ;
777+
778+ t . throws ( ( ) => writeFileAtomic ( blocked , "should not land" ) ) ;
779+ t . deepEqual ( readdirSync ( dir ) , [ "blocked" ] ) ;
780+ t . is ( readFileSync ( join ( blocked , "keep.txt" ) , "utf-8" ) , "keep" ) ;
781+ } finally {
782+ teardownBenchTest ( ) ;
783+ }
784+ } ) ;
785+
786+ test . serial ( "writeFileAtomic surfaces a missing parent rather than inventing one" , ( t ) => {
787+ setupBenchTest ( ) ;
788+ try {
789+ // `--dataset` can point anywhere, so a typo'd path must still fail loudly
790+ // instead of quietly creating directories outside the project.
791+ const missing = join ( BENCH_TEST_DIR , "nope" , "tests.json" ) ;
792+ t . throws ( ( ) => writeFileAtomic ( missing , "[]" ) , { code : "ENOENT" } ) ;
793+ t . false ( existsSync ( join ( BENCH_TEST_DIR , "nope" ) ) ) ;
794+ } finally {
795+ teardownBenchTest ( ) ;
796+ }
797+ } ) ;
0 commit comments