diff --git a/internal/api/snippets.go b/internal/api/snippets.go index e3be6ee..8bef4f0 100644 --- a/internal/api/snippets.go +++ b/internal/api/snippets.go @@ -351,13 +351,25 @@ func (a *API) handleGenerateSnippet(w http.ResponseWriter, r *http.Request) erro // snippetResponse assembles the wire body. key == nil is a PREVIEW: 200, no // credential, snippetTokenPlaceholder wherever a token would be. func snippetResponse(t snippetTarget, c snippetContent, key *CreatedAPIKey) SnippetResponse { + // push_commands is an ARRAY on the wire, always -- never null. It is legitimately + // empty for eight of the nine tools (they write to the cache themselves) and for + // a yocto project with neither sstate nor downloads, and every builder leaves it + // nil in those cases. A nil slice marshals as `null`, the console reads + // `.length` off it and throws before it can render the warnings that say WHY + // the snippet is thin -- a blank screen where the explanation should be. Same + // convention as newProject's backends. + push := c.pushCommands + if push == nil { + push = []string{} + } + return SnippetResponse{ Tool: t.tool, Host: hostOnly(t.host), BaseURL: t.baseURL, LocalConf: c.localConf, Netrc: c.netrc, - PushCommands: c.pushCommands, + PushCommands: push, Files: c.files, Env: c.env, APIKey: key, diff --git a/internal/api/snippets_test.go b/internal/api/snippets_test.go index ea7c7c5..725ed9d 100644 --- a/internal/api/snippets_test.go +++ b/internal/api/snippets_test.go @@ -1185,6 +1185,48 @@ func TestSnippetOCIToolsHaveNoPushCommands(t *testing.T) { } } +// TestSnippetPushCommandsIsAnArrayOnTheWire pins the JSON shape, not the Go one: +// `len(nil) == 0` passes every len-based assertion in this file while the wire +// carries `"push_commands":null`, and the console does `.length` on it -- which +// is how selecting ANY non-yocto tile blanked the snippets screen in v0.1.0. The +// three cases are the three ways the slice is left nil: a tool that never has a +// push, an OCI tool (pull-through only), and yocto on a project with no sstate or +// downloads. Both the minted and the preview branch go through snippetResponse. +func TestSnippetPushCommandsIsAnArrayOnTheWire(t *testing.T) { + const wantKey = `"push_commands":[]` + + cases := []struct { + name string + store Store + body string + want int + }{ + {"bazel minted", snippetStore(t, allSnippetBackends...), `{"tool":"bazel","scope":"read"}`, http.StatusCreated}, + {"bazel preview", snippetStore(t, allSnippetBackends...), `{"tool":"bazel","preview":true}`, http.StatusOK}, + {"docker preview", snippetStore(t, allSnippetBackends...), `{"tool":"docker","preview":true}`, http.StatusOK}, + {"yocto with no backends", snippetStore(t), `{"tool":"yocto","preview":true}`, http.StatusOK}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + a := snippetAPI(t, tc.store, &fakeMinter{token: snippetToken}) + w := snippetPost(t, a, principals(t)["proj_write"], tc.body, nil) + + if w.Code != tc.want { + t.Fatalf("status = %d, want %d: %s", w.Code, tc.want, w.Body.String()) + } + + if !strings.Contains(w.Body.String(), wantKey) { + t.Errorf("wire body must carry %s, got:\n%s", wantKey, w.Body.String()) + } + + if strings.Contains(w.Body.String(), `"push_commands":null`) { + t.Errorf("push_commands must never be null on the wire") + } + }) + } +} + // TestSnippetNeverEmitsAnIDSecretPair: a Bakery cache credential is ONE opaque // bkry_ token. `key_id:key_secret` is a credential that cannot exist, and it was the // single most repeated drift in the hand-authored console copy this generator diff --git a/web/e2e/console.spec.ts b/web/e2e/console.spec.ts index af7f0f7..cf3ebde 100644 --- a/web/e2e/console.spec.ts +++ b/web/e2e/console.spec.ts @@ -316,8 +316,14 @@ test.describe('console: personal access tokens and robots (wave 1)', () => { }); await test.step('the token now appears live in the table', async () => { - await expect(page.getByText(tokenName)).toBeVisible(); - await expect(page.getByText('live')).toBeVisible(); + // Scope to the row this test just created: a bare getByText('live') + // matches every live token's status badge, and the seeded dev user's + // table is not guaranteed to hold exactly one -- a reused local + // database carries rows from earlier runs (CI's fresh database + // masks the ambiguity). Same class as the robot-row locator fix. + const row = page.getByRole('row').filter({ hasText: tokenName }); + await expect(row).toBeVisible(); + await expect(row.getByText('live')).toBeVisible(); }); }); diff --git a/web/src/routes/(console)/o/[org]/p/[project]/snippets/+page.svelte b/web/src/routes/(console)/o/[org]/p/[project]/snippets/+page.svelte index e67d73e..6fa0fd1 100644 --- a/web/src/routes/(console)/o/[org]/p/[project]/snippets/+page.svelte +++ b/web/src/routes/(console)/o/[org]/p/[project]/snippets/+page.svelte @@ -1,5 +1,6 @@ {#snippet block(title: string, content: string)} @@ -207,7 +225,7 @@ {/if} - {#if response.preview} + {#if response.preview && hasContent}
@@ -252,8 +270,8 @@ {#if response.netrc} {@render block('~/.netrc', response.netrc)} {/if} - {#if response.push_commands.length > 0} - {@render block('after a build — push to the cache', response.push_commands.join('\n'))} + {#if pushCommands.length > 0} + {@render block('after a build — push to the cache', pushCommands.join('\n'))} {/if} {#each response.files ?? [] as f (f.path)}
@@ -267,12 +285,21 @@ response.env.map((e) => `export ${e.name}="${e.value}"`).join('\n') )} {/if} - {#if !response.local_conf && !response.netrc && response.push_commands.length === 0 && (!response.files || response.files.length === 0) && (!response.env || response.env.length === 0)} + {#if !hasContent} + title="Nothing to generate for {selectedTile.name}" + desc="{selectedTile.name} needs the {selectedTile.backend} backend on this project. The warnings above say exactly what's missing or disabled — add it (or re-enable it on its backend page), then pick this tile again." + > + {#snippet action()} + + {/snippet} + {/if} {/if}