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
14 changes: 13 additions & 1 deletion internal/api/snippets.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
42 changes: 42 additions & 0 deletions internal/api/snippets_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 8 additions & 2 deletions web/e2e/console.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});

Expand Down
41 changes: 34 additions & 7 deletions web/src/routes/(console)/o/[org]/p/[project]/snippets/+page.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script lang="ts">
import { page } from '$app/state';
import { projectPath } from '$lib/tenancy';

import { previewSnippet, generateSnippet } from '$lib/api/snippets';
import { isApiError } from '$lib/api/errors';
Expand Down Expand Up @@ -142,6 +143,23 @@
}

const gotcha = $derived(gotchas[selected]);
const selectedTile = $derived(tiles.find((t) => t.id === selected) ?? tiles[0]);
// The server guarantees an array, but `.length` on a missing field is how this
// screen blanked in v0.1.0 (nil slice -> `null`), so read it defensively --
// same as `files` and `env` below.
const pushCommands = $derived(response?.push_commands ?? []);
// One truth for "did this preview produce anything". The mint bar is gated
// on it too, so a key can never be minted into a snippet with no blocks --
// the one-time secret would have nothing to be baked into, and the empty
// state below would immediately invite a regenerate that wipes it.
const hasContent = $derived(
!!response &&
(!!response.local_conf ||
!!response.netrc ||
pushCommands.length > 0 ||
(response.files ?? []).length > 0 ||
(response.env ?? []).length > 0)
);
</script>

{#snippet block(title: string, content: string)}
Expand Down Expand Up @@ -207,7 +225,7 @@
</div>
{/if}

{#if response.preview}
{#if response.preview && hasContent}
<div
class="flex items-center gap-2.5 rounded-2 border border-border-0 bg-bg-2 px-3 py-2.5 text-sm text-text-2"
>
Expand Down Expand Up @@ -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)}
<div class="flex flex-col gap-1">
Expand All @@ -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}
<EmptyState
glyph="∅"
title="Nothing to show"
desc="This project has no backend configured that {selected} can target. See the warning above."
/>
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()}
<Button
variant="secondary"
size="md"
href="{projectPath(org.slug, project.slug)}/backends/new"
>Configure backends</Button
>
{/snippet}
</EmptyState>
{/if}
{/if}
</div>
Expand Down
Loading