Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions Assets/Tests/Editor/GetCompileStatusResponseContractTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ public void GetCompileStatusResponse_WhenSerialized_MatchesSharedContractFieldSh
{
// Verifies C# does not add, remove, or rename get-compile-status fields without updating the
// shared CLI contract. Result must come from a real CompileResponse serialization because that
// is what CompileStatusBridgeCommand restores from ResultJson for Go compileResultStatus.Success.
// is what CompileStatusBridgeCommand restores from ResultJson for the Go CLI, which reads
// Result.Success as the compile's exit code.
JObject expected = ReadSharedContractFieldShape();
JObject compileResultJson = SerializeCompileResponseResult();
GetCompileStatusResponse response = new()
Expand All @@ -47,8 +48,8 @@ public void GetCompileStatusResponse_WhenSerialized_MatchesSharedContractFieldSh
[Test]
public void CompileResponse_WhenSerializedForCompileStatusResult_IncludesSuccessProperty()
{
// Verifies the wire Result payload still exposes Success under the name Go unmarshals into
// compileResultStatus — a rename on CompileResponse must fail this test.
// Verifies the wire Result payload still exposes Success under the name the Go CLI reads for
// the exit code (toolEnvelopeExitCode) — a rename on CompileResponse must fail this test.
JObject compileResultJson = SerializeCompileResponseResult();
Assert.That(compileResultJson.Property("Success"), Is.Not.Null);
Assert.That(compileResultJson["Success"]!.Type, Is.EqualTo(JTokenType.Boolean));
Expand Down
21 changes: 7 additions & 14 deletions cli/project-runner/internal/projectrunner/compile_attach.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ func tryAttachToPendingCompile(
clearCompilePendingRecord(connection.ProjectRoot)
return false, compileExecutionResult{}
}
return true, returnAttachedStoredCompileResult(ctx, connection, record, status.Result, stderr)
return true, returnAttachedStoredCompileResult(connection, record, status.Result, stderr)
}

if !status.Ready {
Expand Down Expand Up @@ -219,7 +219,7 @@ func attachWaitForPendingCompile(
spinner.Stop()
return false, compileExecutionResult{}
}
return true, completeCompileResult(ctx, connection, result, stderr, spinner, startedAt, unityipc.UnitySendOutcome{})
return true, completeCompileResult(result, stderr, spinner, startedAt, unityipc.UnitySendOutcome{})
default:
spinner.Stop()
logCompileAttachResult(connection, record.RequestID, "error", false)
Expand Down Expand Up @@ -289,7 +289,6 @@ func waitForAttachedCompileCompletion(
}

func returnAttachedStoredCompileResult(
ctx context.Context,
connection unityipc.Connection,
record compilePendingRecord,
result json.RawMessage,
Expand All @@ -300,26 +299,20 @@ func returnAttachedStoredCompileResult(
spinner := clicore.NewToolSpinner(stderr, clicore.CompileCommandName)
clearCompilePendingRecord(connection.ProjectRoot)
logCompileAttachResult(connection, record.RequestID, "stored_result", true)
return completeCompileResult(ctx, connection, result, stderr, spinner, startedAt, unityipc.UnitySendOutcome{})
return completeCompileResult(result, stderr, spinner, startedAt, unityipc.UnitySendOutcome{})
}

// completeCompileResult turns the compile answer into the command's result.
// Why nothing is sent after the answer: the answer came from the Editor that finished the compile,
// and a readiness probe here would hold the Editor's single-flight slot for seconds that only the
// next execute-dynamic-code would have gained.
func completeCompileResult(
ctx context.Context,
connection unityipc.Connection,
result json.RawMessage,
stderr io.Writer,
spinner *ui.TerminalSpinner,
startedAt time.Time,
outcome unityipc.UnitySendOutcome,
) compileExecutionResult {
switch compileResultReadinessWaitMode(result) {
case compileReadinessWaitWarmup:
spinner.Update("Warming execute-dynamic-code after compile...")
if err := clicore.WaitForToolReadiness(ctx, connection.ProjectRoot); err != nil {
spinner.Stop()
writePostCompileWarmupWarning(stderr, err)
}
}
spinner.Stop()
writeDebugTiming(stderr, clicore.CompileCommandName, time.Since(startedAt), outcome)
return compileExecutionResult{result: result, exitCode: toolEnvelopeExitCode(result)}
Expand Down
91 changes: 82 additions & 9 deletions cli/project-runner/internal/projectrunner/compile_attach_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,83 @@ func TestRunCompileAttachReturnsStoredResultAndClearsRecord(t *testing.T) {
}
}

// Verifies a successful stored result is returned as soon as it is read, with exit code 0 and
// without a post-compile warm-up.
func TestRunCompileAttachReturnsAStoredSuccessAtOnce(t *testing.T) {
projectRoot := t.TempDir()
if err := writeCompilePendingRecord(projectRoot, compilePendingRecord{
RequestID: "compile_attach_stored_success",
TimedOutAtUtc: time.Now().UTC(),
}); err != nil {
t.Fatalf("write pending record failed: %v", err)
}
deps := compileWaitTestDeps(func(context.Context, unityipc.Connection, string) (compileStatusResponse, error) {
return compileStatusResponse{
Ready: true,
HasResult: true,
Result: json.RawMessage(`{"Success":true,"ErrorCount":0}`),
}, nil
})

assertAttachedSuccessReturnsAtOnce(t, projectRoot, deps)
}

// Verifies a successful result that an attached wait sees once the in-flight compile finishes is
// returned as soon as it arrives, with exit code 0 and without a post-compile warm-up.
func TestRunCompileAttachReturnsAWaitedSuccessAtOnce(t *testing.T) {
projectRoot := t.TempDir()
if err := writeCompilePendingRecord(projectRoot, compilePendingRecord{
RequestID: "compile_attach_waited_success",
TimedOutAtUtc: time.Now().UTC().Add(-time.Minute),
}); err != nil {
t.Fatalf("write pending record failed: %v", err)
}
callCount := 0
deps := compileWaitTestDeps(func(context.Context, unityipc.Connection, string) (compileStatusResponse, error) {
callCount++
if callCount == 1 {
return compileStatusResponse{Ready: false, IsCompiling: true}, nil
}
return compileStatusResponse{
Ready: true,
HasResult: true,
Result: json.RawMessage(`{"Success":true,"ErrorCount":0}`),
}, nil
})

assertAttachedSuccessReturnsAtOnce(t, projectRoot, deps)
}

// assertAttachedSuccessReturnsAtOnce runs compile against a pending record whose result is a
// success and checks the result is written to stdout with exit code 0 well within the time a
// readiness probe would take.
func assertAttachedSuccessReturnsAtOnce(t *testing.T, projectRoot string, deps compileWaitDeps) {
t.Helper()
connection := unityipc.Connection{
Endpoint: unityipc.Endpoint{Network: "tcp", Address: "127.0.0.1:1"},
ProjectRoot: projectRoot,
}
var stdout, stderr bytes.Buffer
startedAt := time.Now()

code := runCompileWithDomainReloadWaitWithDeps(context.Background(), connection, map[string]any{}, &stdout, &stderr, deps)

// Why a time limit: the post-compile warm-up this command no longer runs would wait 180 s for a
// readiness a temp project never reaches.
if elapsed := time.Since(startedAt); elapsed >= 30*time.Second {
t.Fatalf("compile took %s after a successful answer, want under 30s", elapsed)
}
if code != 0 {
t.Fatalf("exit code = %d, want 0\nstderr:\n%s", code, stderr.String())
}
if !strings.Contains(stdout.String(), `"Success": true`) && !strings.Contains(stdout.String(), `"Success":true`) {
t.Fatalf("the successful result missing from stdout: %s", stdout.String())
}
if strings.Contains(stderr.String(), "warning") {
t.Fatalf("a successful compile must not warn:\n%s", stderr.String())
}
}

// Verifies ForceRecompile clears the pending record and starts a new compile.
func TestRunCompileAttachForceRecompileClearsRecordAndStartsNewCompile(t *testing.T) {
if runtime.GOOS == "windows" {
Expand Down Expand Up @@ -717,17 +794,13 @@ func TestRunCompileAttachReportsCancellationDuringWait(t *testing.T) {
}
}

// Verifies a successful compile whose post-compile warmup fails still returns the compile
// result and only warns about the skipped warmup.
func TestCompleteCompileResultWarnsWhenWarmupFails(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
// Verifies a successful compile answer becomes the command's result as it is, with exit code 0
// and no warning.
func TestCompleteCompileResultReturnsTheAnswerAsIs(t *testing.T) {
var stderr bytes.Buffer
result := json.RawMessage(`{"Success":true,"ErrorCount":0}`)

execution := completeCompileResult(
ctx,
unityipc.Connection{ProjectRoot: t.TempDir()},
result,
&stderr,
clicore.NewToolSpinner(&stderr, clicore.CompileCommandName),
Expand All @@ -738,8 +811,8 @@ func TestCompleteCompileResultWarnsWhenWarmupFails(t *testing.T) {
if execution.exitCode != 0 || string(execution.result) != string(result) {
t.Fatalf("unexpected execution: %#v", execution)
}
if !strings.Contains(stderr.String(), "warning: post-compile warmup skipped: context canceled") {
t.Fatalf("stderr must warn about the skipped warmup:\n%s", stderr.String())
if strings.Contains(stderr.String(), "warning") {
t.Fatalf("a successful compile must not warn:\n%s", stderr.String())
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,7 @@ import (
"github.com/hatayama/unity-cli-loop/common/unityipc"
)

// Why Success:false in both: a successful result triggers the post-compile warmup, which waits for
// a tool readiness that a temp project never reaches. The error counts tell the two results apart.
// The error counts tell the two results apart.
const (
earlierCompileResult = `{"Success":false,"ErrorCount":9}`
currentCompileResult = `{"Success":false,"ErrorCount":1}`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import (
const (
// Stops a wait that never ends instead of letting it poll through the whole default wait.
compileRecoveryQueryLimit = 200
// A compile error: definitive, and it does not start the post-compile warmup.
// A compile error: definitive.
compileRecoveryDefinitiveResult = `{"Success":false,"ErrorCount":1,"WarningCount":0,"ErrorCode":null}`
compileRecoveryAlreadyInProgressResult = `{"Success":false,"ErrorCode":"COMPILE_ALREADY_IN_PROGRESS","ErrorCount":1}`
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import (
const compileStatusResponseContractPath = "tests/contracts/compile_status_response_contract.json"

// Verifies the Go compileStatusResponse DTO preserves every field in the shared Unity
// get-compile-status response contract, including nested Result.Success used by compileResultStatus.
// get-compile-status response contract, including a nested Result whose Success the CLI reads as the exit code.
func TestCompileStatusResponseMatchesSharedContract(t *testing.T) {
fixturePath := findRepoRelativeFile(t, compileStatusResponseContractPath)
fixture, err := os.ReadFile(fixturePath)
Expand All @@ -25,12 +25,8 @@ func TestCompileStatusResponseMatchesSharedContract(t *testing.T) {

assertCompileStatusResponseFieldsPopulated(t, response)

var resultStatus compileResultStatus
if err := json.Unmarshal(response.Result, &resultStatus); err != nil {
t.Fatalf("failed to unmarshal Result into compileResultStatus: %v", err)
}
if resultStatus.Success == nil {
t.Fatal("compileResultStatus.Success must be non-nil after unmarshaling the shared contract")
if toolEnvelopeExitCode(response.Result) != 0 {
t.Fatalf("the shared contract's Result must read as a successful compile: %s", response.Result)
}

roundTripped, err := json.Marshal(response)
Expand Down
31 changes: 1 addition & 30 deletions cli/project-runner/internal/projectrunner/compile_wait_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -564,8 +564,7 @@ func TestRunCompileWithDomainReloadWaitWarnsWhenTimeoutExceedsRetention(t *testi
}

endpoint, serverErr := startCompileAcceptOnceServer(t)
// Why Success:false: Success:true triggers post-compile warmup against a live Editor
// and would hang this unit test. The warning is emitted before send/wait.
// The warning is emitted before send/wait.
deps := compileWaitTestDeps(func(context.Context, unityipc.Connection, string) (compileStatusResponse, error) {
return compileStatusResponse{
Ready: true,
Expand Down Expand Up @@ -669,34 +668,6 @@ func TestShouldWaitForCompileStatusAllowsAcceptedFinalResponseTimeout(t *testing
}
}

// Verifies compile readiness warmup only runs after confirmed successful results.
func TestCompileResultReadinessWaitMode(t *testing.T) {
cases := map[string]compileReadinessWaitMode{
`{"Success":true}`: compileReadinessWaitWarmup,
`{"Success":false,"Errors":[{"Message":"boom"}]}`: compileReadinessWaitNone,
`{"Success":false,"Message":"indeterminate"}`: compileReadinessWaitNone,
`{"Message":"indeterminate"}`: compileReadinessWaitNone,
}

for result, expected := range cases {
actual := compileResultReadinessWaitMode([]byte(result))
if actual != expected {
t.Fatalf("readiness wait mode mismatch for %s: %v", result, actual)
}
}
}

// Verifies that failed best-effort warmup reports a warning without taking over compile output.
func TestWritePostCompileWarmupWarningReportsNonFatalFailure(t *testing.T) {
var stderr bytes.Buffer

writePostCompileWarmupWarning(&stderr, fmt.Errorf("probe failed"))

if !strings.Contains(stderr.String(), "warning: post-compile warmup skipped: probe failed") {
t.Fatalf("warning mismatch: %s", stderr.String())
}
}

// Verifies TimeoutSeconds is parsed from tool params with the default
// kept when absent and non-positive or non-integer values rejected.
func TestCompileWaitTimeoutFromParams(t *testing.T) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -732,8 +732,7 @@ func TestSendCompileWithBusyRetryStopsRetrying(t *testing.T) {
})
}

// A successful compile result. It starts the post-compile warmup, which waits for a Unity project
// these tests do not have, so a test that returns it cancels the command at that answer.
// A successful compile result.
const pausePointRecoveryCompileSuccess = `{"Success":true}`

// stubPausePointRecoveryBusyRetryWaits makes the wait between server_busy sends return at once and
Expand Down Expand Up @@ -767,17 +766,20 @@ func runPausePointRecoveryCompile(
// Verifies the recovery compile sends a request Unity lost again under a new request ID instead of
// waiting out the whole timeout, and that the successful compile leaves stdout empty.
func TestPausePointRecoveryCompileResendsWhenUnityLostTheRequest(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
scenario := newCompileRecoveryScenario(t,
[]compileRecoverySend{recoverySendDisconnected(), recoverySendAnswered()},
[]compileRecoveryAnswer{recoveryMissing()},
[]compileRecoveryAnswer{recoveryDone(pausePointRecoveryCompileSuccess)},
)
scenario.cancelWhen(cancel, 1, 1)
startedAt := time.Now()

code, stdout, stderr := runPausePointRecoveryCompile(t, ctx, map[string]any{}, scenario.deps())
code, stdout, stderr := runPausePointRecoveryCompile(t, context.Background(), map[string]any{}, scenario.deps())

// Why a time limit: the post-compile warm-up this command no longer runs would wait 180 s for a
// readiness a temp project never reaches.
if elapsed := time.Since(startedAt); elapsed >= 30*time.Second {
t.Fatalf("the recovery compile took %s after a successful answer, want under 30s", elapsed)
}
if code != 0 {
t.Fatalf("exit code = %d, want 0\nstderr:\n%s", code, stderr)
}
Expand All @@ -804,16 +806,13 @@ func TestPausePointRecoveryCompileResendsAfterABusyRejection(t *testing.T) {
for _, errorCode := range []string{"COMPILE_ALREADY_IN_PROGRESS", "COMPILE_EDITOR_UPDATING"} {
t.Run(errorCode, func(t *testing.T) {
waits := stubPausePointRecoveryBusyRetryWaits(t)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
scenario := newCompileRecoveryScenario(t,
[]compileRecoverySend{recoverySendAnswered(), recoverySendAnswered()},
compileRecoveryBusyRejectionAnswers(errorCode),
[]compileRecoveryAnswer{recoveryDone(pausePointRecoveryCompileSuccess)},
)
scenario.cancelWhen(cancel, 1, 1)

code, stdout, stderr := runPausePointRecoveryCompile(t, ctx, map[string]any{}, scenario.deps())
code, stdout, stderr := runPausePointRecoveryCompile(t, context.Background(), map[string]any{}, scenario.deps())

if code != 0 {
t.Fatalf("exit code = %d, want 0\nstderr:\n%s", code, stderr)
Expand Down Expand Up @@ -879,14 +878,10 @@ func TestPausePointRecoveryCompileWritesADefinitiveFailureOnce(t *testing.T) {
// again after one wait that the time left in the compile wait caps below the retry interval.
func TestPausePointRecoveryCompileRetriesAServerBusySend(t *testing.T) {
waits := stubPausePointRecoveryBusyRetryWaits(t)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
scenario := newCompileRecoveryScenario(t,
[]compileRecoverySend{recoverySendAnswered()},
[]compileRecoveryAnswer{recoveryDone(pausePointRecoveryCompileSuccess)},
)
// The refused send never reaches the scenario, so the scenario's first send is the retry.
scenario.cancelWhen(cancel, 0, 1)
deps := scenario.deps()
scriptedSend := deps.sendCompile
busy := serverBusyRPCError(t)
Expand All @@ -908,7 +903,7 @@ func TestPausePointRecoveryCompileRetriesAServerBusySend(t *testing.T) {

// Why a 1s wait: it is shorter than the retry interval, so only a budget taken from the time left
// in the compile wait keeps the retry wait at or under 1s.
code, stdout, stderr := runPausePointRecoveryCompile(t, ctx, map[string]any{compileWaitTimeoutParam: 1}, deps)
code, stdout, stderr := runPausePointRecoveryCompile(t, context.Background(), map[string]any{compileWaitTimeoutParam: 1}, deps)

if code != 0 {
t.Fatalf("exit code = %d, want 0\nstderr:\n%s", code, stderr)
Expand Down
Loading
Loading