@@ -13,6 +13,7 @@ import (
1313 "strings"
1414 "sync/atomic"
1515 "testing"
16+ "time"
1617)
1718
1819func TestRunMultiURLWritesPerURLOutputsAndSummary (t * testing.T ) {
@@ -345,6 +346,223 @@ func TestRunSummaryIncludesOutputErrorType(t *testing.T) {
345346 }
346347}
347348
349+ func TestRunFailFastStopsAfterFirstFailure (t * testing.T ) {
350+ contentServer := httptest .NewServer (http .HandlerFunc (func (w http.ResponseWriter , r * http.Request ) {
351+ switch r .URL .Path {
352+ case "/bad" :
353+ http .Error (w , "broken" , http .StatusInternalServerError )
354+ case "/ok" :
355+ w .Header ().Set ("Content-Type" , "text/html; charset=utf-8" )
356+ _ , _ = w .Write ([]byte (sampleArticle ("OK" , "good content" )))
357+ default :
358+ http .NotFound (w , r )
359+ }
360+ }))
361+ t .Cleanup (contentServer .Close )
362+
363+ openAIServer := httptest .NewServer (http .HandlerFunc (func (w http.ResponseWriter , r * http.Request ) {
364+ if r .URL .Path != "/v1/responses" {
365+ http .NotFound (w , r )
366+ return
367+ }
368+ w .Header ().Set ("Content-Type" , "application/json" )
369+ _ , _ = io .WriteString (w , `{"output_text":"译文内容"}` )
370+ }))
371+ t .Cleanup (openAIServer .Close )
372+
373+ tmpDir := useTempWorkingDir (t )
374+ t .Setenv ("OPENAI_API_KEY" , "test-key" )
375+ t .Setenv ("OPENAI_BASE_URL" , openAIServer .URL )
376+
377+ badURL := contentServer .URL + "/bad"
378+ okURL := contentServer .URL + "/ok"
379+
380+ var stdout bytes.Buffer
381+ var stderr bytes.Buffer
382+ err := Run ([]string {"--fail-fast" , badURL , okURL }, & stdout , & stderr )
383+ if err == nil {
384+ t .Fatalf ("Run() error = nil, want fail-fast error" )
385+ }
386+
387+ summaryPath := filepath .Join (tmpDir , "out" , "_summary.json" )
388+ rawSummary , err := os .ReadFile (summaryPath )
389+ if err != nil {
390+ t .Fatalf ("read summary file: %v" , err )
391+ }
392+
393+ var summary taskSummary
394+ if err := json .Unmarshal (rawSummary , & summary ); err != nil {
395+ t .Fatalf ("unmarshal summary JSON: %v" , err )
396+ }
397+
398+ if len (summary .Results ) != 1 {
399+ t .Fatalf ("summary result len=%d, want 1 due to fail-fast stop" , len (summary .Results ))
400+ }
401+ if summary .Results [0 ].SourceURL != badURL {
402+ t .Fatalf ("summary first source_url=%q, want %q" , summary .Results [0 ].SourceURL , badURL )
403+ }
404+ if ! strings .Contains (stderr .String (), "Fail-fast enabled: stop after first failure." ) {
405+ t .Fatalf ("stderr missing fail-fast message: %s" , stderr .String ())
406+ }
407+ if ! strings .Contains (stdout .String (), "Done: 0 succeeded, 1 failed" ) {
408+ t .Fatalf ("stdout missing final summary: %s" , stdout .String ())
409+ }
410+ }
411+
412+ func TestRunMaxRetriesFlagControlsOpenAIRetry (t * testing.T ) {
413+ contentServer := httptest .NewServer (http .HandlerFunc (func (w http.ResponseWriter , r * http.Request ) {
414+ if r .URL .Path != "/retry" {
415+ http .NotFound (w , r )
416+ return
417+ }
418+ w .Header ().Set ("Content-Type" , "text/html; charset=utf-8" )
419+ _ , _ = w .Write ([]byte (sampleArticle ("Retry" , "retry content" )))
420+ }))
421+ t .Cleanup (contentServer .Close )
422+
423+ var callCount int32
424+ openAIServer := httptest .NewServer (http .HandlerFunc (func (w http.ResponseWriter , r * http.Request ) {
425+ if r .URL .Path != "/v1/responses" {
426+ http .NotFound (w , r )
427+ return
428+ }
429+
430+ n := atomic .AddInt32 (& callCount , 1 )
431+ if n == 1 {
432+ w .WriteHeader (http .StatusInternalServerError )
433+ _ , _ = io .WriteString (w , `{"error":{"message":"temporary upstream failure"}}` )
434+ return
435+ }
436+
437+ w .Header ().Set ("Content-Type" , "application/json" )
438+ _ , _ = io .WriteString (w , `{"output_text":"译文内容"}` )
439+ }))
440+ t .Cleanup (openAIServer .Close )
441+
442+ t .Setenv ("OPENAI_API_KEY" , "test-key" )
443+ t .Setenv ("OPENAI_BASE_URL" , openAIServer .URL )
444+ sourceURL := contentServer .URL + "/retry"
445+
446+ dirNoRetry := t .TempDir ()
447+ runInWorkingDir (t , dirNoRetry , func () string {
448+ var stdout bytes.Buffer
449+ var stderr bytes.Buffer
450+ atomic .StoreInt32 (& callCount , 0 )
451+
452+ err := Run ([]string {"--chunk-size" , "10000" , "--max-retries" , "0" , sourceURL }, & stdout , & stderr )
453+ if err == nil {
454+ t .Fatalf ("Run() error = nil, want failure when retries are disabled" )
455+ }
456+ if got := atomic .LoadInt32 (& callCount ); got != 1 {
457+ t .Fatalf ("OpenAI call count=%d, want 1 with --max-retries=0" , got )
458+ }
459+ return ""
460+ })
461+
462+ dirWithRetry := t .TempDir ()
463+ runInWorkingDir (t , dirWithRetry , func () string {
464+ var stdout bytes.Buffer
465+ var stderr bytes.Buffer
466+ atomic .StoreInt32 (& callCount , 0 )
467+
468+ if err := Run ([]string {"--chunk-size" , "10000" , "--max-retries" , "1" , sourceURL }, & stdout , & stderr ); err != nil {
469+ t .Fatalf ("Run() error = %v; stderr=%s" , err , stderr .String ())
470+ }
471+ if got := atomic .LoadInt32 (& callCount ); got != 2 {
472+ t .Fatalf ("OpenAI call count=%d, want 2 with --max-retries=1" , got )
473+ }
474+ return ""
475+ })
476+ }
477+
478+ func TestRunWorkersFlagChangesConcurrency (t * testing.T ) {
479+ contentServer := httptest .NewServer (http .HandlerFunc (func (w http.ResponseWriter , r * http.Request ) {
480+ if r .URL .Path != "/parallel" {
481+ http .NotFound (w , r )
482+ return
483+ }
484+ w .Header ().Set ("Content-Type" , "text/html; charset=utf-8" )
485+ _ , _ = w .Write ([]byte (sampleLongArticle ("Parallel" )))
486+ }))
487+ t .Cleanup (contentServer .Close )
488+
489+ var inFlight int32
490+ var maxInFlight int32
491+ openAIServer := httptest .NewServer (http .HandlerFunc (func (w http.ResponseWriter , r * http.Request ) {
492+ if r .URL .Path != "/v1/responses" {
493+ http .NotFound (w , r )
494+ return
495+ }
496+
497+ current := atomic .AddInt32 (& inFlight , 1 )
498+ for {
499+ prev := atomic .LoadInt32 (& maxInFlight )
500+ if current <= prev {
501+ break
502+ }
503+ if atomic .CompareAndSwapInt32 (& maxInFlight , prev , current ) {
504+ break
505+ }
506+ }
507+ time .Sleep (25 * time .Millisecond )
508+ atomic .AddInt32 (& inFlight , - 1 )
509+
510+ w .Header ().Set ("Content-Type" , "application/json" )
511+ _ , _ = io .WriteString (w , `{"output_text":"译文内容"}` )
512+ }))
513+ t .Cleanup (openAIServer .Close )
514+
515+ t .Setenv ("OPENAI_API_KEY" , "test-key" )
516+ t .Setenv ("OPENAI_BASE_URL" , openAIServer .URL )
517+ sourceURL := contentServer .URL + "/parallel"
518+
519+ singleWorkerDir := t .TempDir ()
520+ runInWorkingDir (t , singleWorkerDir , func () string {
521+ var stdout bytes.Buffer
522+ var stderr bytes.Buffer
523+ atomic .StoreInt32 (& inFlight , 0 )
524+ atomic .StoreInt32 (& maxInFlight , 0 )
525+
526+ if err := Run ([]string {"--chunk-size" , "80" , "--workers" , "1" , sourceURL }, & stdout , & stderr ); err != nil {
527+ t .Fatalf ("Run() with --workers=1 error = %v; stderr=%s" , err , stderr .String ())
528+ }
529+ if got := atomic .LoadInt32 (& maxInFlight ); got != 1 {
530+ t .Fatalf ("max in-flight=%d, want 1 when workers=1" , got )
531+ }
532+ return ""
533+ })
534+
535+ multiWorkerDir := t .TempDir ()
536+ runInWorkingDir (t , multiWorkerDir , func () string {
537+ var stdout bytes.Buffer
538+ var stderr bytes.Buffer
539+ atomic .StoreInt32 (& inFlight , 0 )
540+ atomic .StoreInt32 (& maxInFlight , 0 )
541+
542+ if err := Run ([]string {"--chunk-size" , "80" , "--workers" , "4" , sourceURL }, & stdout , & stderr ); err != nil {
543+ t .Fatalf ("Run() with --workers=4 error = %v; stderr=%s" , err , stderr .String ())
544+ }
545+ if got := atomic .LoadInt32 (& maxInFlight ); got <= 1 {
546+ t .Fatalf ("max in-flight=%d, want >1 when workers=4" , got )
547+ }
548+ return ""
549+ })
550+ }
551+
552+ func TestParseFlagsRejectsInvalidWorkers (t * testing.T ) {
553+ _ , err := parseFlags ([]string {"--workers" , "0" , "https://example.com" }, io .Discard )
554+ if err == nil || ! strings .Contains (err .Error (), "--workers must be greater than 0" ) {
555+ t .Fatalf ("parseFlags error=%v, want workers validation error" , err )
556+ }
557+ }
558+
559+ func TestParseFlagsRejectsInvalidMaxRetries (t * testing.T ) {
560+ _ , err := parseFlags ([]string {"--max-retries" , "-1" , "https://example.com" }, io .Discard )
561+ if err == nil || ! strings .Contains (err .Error (), "--max-retries must be 0 or greater" ) {
562+ t .Fatalf ("parseFlags error=%v, want max-retries validation error" , err )
563+ }
564+ }
565+
348566func TestRunResumeReusesSavedChunksAndMatchesSingleRun (t * testing.T ) {
349567 contentServer := httptest .NewServer (http .HandlerFunc (func (w http.ResponseWriter , r * http.Request ) {
350568 if r .URL .Path != "/long" {
0 commit comments