mirror of
https://github.com/hanzo-apps/tabs.git
synced 2026-09-15 19:32:29 +00:00
tabs: a cloud machine is a sandbox
New cloud machine ordered a DigitalOcean droplet through visor, waited a minute for it to boot and run `hanzo link`, and — with no name in the body — answered 422 "Droplet must have a name", so in production it ordered nothing at all. It leases a dev sandbox now. POST /v1/sandboxes returns when the pod is RUNNING, so the machine and the news of it arrive together, and the button opens its first shell rather than adding a row to a list. The project counts up against what is already live, because the server keeps one live sandbox per project — which is what makes a second press a second machine instead of a second attempt at the first. That leaves one plane where there were two: visor's launch, list, and the merge that held a place for a box still booting all go, along with the pending statuses that existed only for that gap. A failed start now carries the server's own reason to the button that caused it.
This commit is contained in:
+26
-27
@@ -18,12 +18,11 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
|
||||
import {
|
||||
type CloudMachine,
|
||||
type Machine,
|
||||
type SandboxMachine,
|
||||
type Session,
|
||||
cloudMachines,
|
||||
launchCloudMachine,
|
||||
createSandbox,
|
||||
machineName,
|
||||
machines,
|
||||
sandboxes,
|
||||
sandboxTerminal,
|
||||
@@ -31,7 +30,6 @@ import {
|
||||
terminalTicket,
|
||||
} from '@/lib/api';
|
||||
import { renew, session, signIn, signOut } from '@/lib/iam';
|
||||
import { withCloud } from '@/lib/panes';
|
||||
import { Workspace, type TerminalHost } from '@/components/workspace';
|
||||
|
||||
export default function App() {
|
||||
@@ -40,7 +38,6 @@ export default function App() {
|
||||
const [data, setData] = useState<{
|
||||
m: Machine[];
|
||||
s: Session[];
|
||||
c: CloudMachine[];
|
||||
b: SandboxMachine[];
|
||||
} | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -60,21 +57,18 @@ export default function App() {
|
||||
|
||||
const refresh = useCallback(async (t: string) => {
|
||||
try {
|
||||
// Two planes, and only one of them holds this page up. The registry is where
|
||||
// machines and terminals come from; visor only knows about the boxes we
|
||||
// launched, so provisioning being down has to cost you those and nothing
|
||||
// else. It is caught here rather than reported here because the launch
|
||||
// button is where a broken visor is worth saying out loud — a banner about
|
||||
// a plane you may not be using is noise on a workspace that still works.
|
||||
const [m, s, c, b] = await Promise.all([
|
||||
// The registry is where machines and terminals come from, and it is what
|
||||
// holds this page up. Sandboxes are a SECOND read against the same plane:
|
||||
// caught here rather than reported, because their absence costs you those
|
||||
// panes and nothing else, and a banner about boxes you may not have is
|
||||
// noise on a workspace that still works. It is worth saying out loud at
|
||||
// the launch button, which is the one place it stops you.
|
||||
const [m, s, b] = await Promise.all([
|
||||
machines(t),
|
||||
sessions(t),
|
||||
cloudMachines(t).catch(() => []),
|
||||
// The builder's pods. A third plane with the same posture as visor:
|
||||
// its absence costs you those panes and nothing else.
|
||||
sandboxes(t).catch(() => []),
|
||||
]);
|
||||
setData({ m, s, c, b });
|
||||
setData({ m, s, b });
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
// A registry that cannot be read is reported as such. Rendering an empty
|
||||
@@ -121,19 +115,16 @@ export default function App() {
|
||||
for (const [host, url] of base) {
|
||||
if (!out.has(host)) out.set(host, { machine: host, base: url, status: 'online' });
|
||||
}
|
||||
// The builder's sandboxes. A sandbox has no tunnel to publish — its
|
||||
// terminal URL is MINTED per open (single-use ticket), so the host carries
|
||||
// the sandbox id instead of a base and the workspace mints when a pane
|
||||
// binds. One live sandbox per project is the server's rule, so the project
|
||||
// name is a stable, unique machine name.
|
||||
// The sandboxes. A sandbox has no tunnel to publish — its terminal URL is
|
||||
// MINTED per open (single-use ticket), so the host carries the sandbox id
|
||||
// instead of a base and the workspace mints when a pane binds. One live
|
||||
// sandbox per project is the server's rule, so the project name is a
|
||||
// stable, unique machine name.
|
||||
for (const s of data.b) {
|
||||
const key = s.project || `box-${s.id.slice(0, 6)}`;
|
||||
const key = machineName(s);
|
||||
if (!out.has(key)) out.set(key, { machine: key, sandbox: s.id, status: 'online' });
|
||||
}
|
||||
// A box we launched exists to visor before it exists to the registry. It is
|
||||
// merged in for that gap only, and stops being a special case the moment its
|
||||
// own `hanzo link` registers it above.
|
||||
return withCloud([...out.values()], data.c, (machine, status) => ({ machine, status }));
|
||||
return [...out.values()];
|
||||
}, [data]);
|
||||
|
||||
/** A fresh framed-terminal URL for a sandbox pane — ticket minted here, where
|
||||
@@ -202,7 +193,15 @@ export default function App() {
|
||||
<Workspace
|
||||
hosts={hosts}
|
||||
mint={mint}
|
||||
onLaunch={() => launchCloudMachine(token).then(() => refresh(token))}
|
||||
// Started, then read back, then named — in that order. The workspace
|
||||
// opens a shell on the name it gets, and a pane can only mint a
|
||||
// ticket for a machine the registry has already handed back, so the
|
||||
// refresh is what stands between the two.
|
||||
onLaunch={async () => {
|
||||
const box = await createSandbox(token);
|
||||
await refresh(token);
|
||||
return machineName(box);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-sm text-neutral-600">
|
||||
|
||||
+16
-10
@@ -55,7 +55,6 @@ import {
|
||||
DOT,
|
||||
TERM_DEADLINE,
|
||||
type Binding,
|
||||
isPending,
|
||||
isTermReady,
|
||||
mintName,
|
||||
rescued,
|
||||
@@ -102,15 +101,14 @@ export function Workspace({
|
||||
/** A fresh framed-terminal URL for a sandbox pane. The caller owns the
|
||||
* credential; the workspace only ever holds the minted, ticket-bearing URL. */
|
||||
mint?: (sandbox: string, shell: string) => Promise<string>;
|
||||
onLaunch?: () => Promise<void>;
|
||||
/** Start a cloud machine, and answer the name it goes by here. */
|
||||
onLaunch?: () => Promise<string>;
|
||||
}) {
|
||||
// A box we launched is admitted before it has a tunnel. Without that it would be
|
||||
// invisible for the minute it takes to boot and link, and a launch you cannot
|
||||
// see is a button that looks broken. Everything else with no `base` is a machine
|
||||
// serving no terminal, and there is nothing to open on one of those — except a
|
||||
// sandbox, whose URL is minted on bind rather than published.
|
||||
// A machine with no `base` serves no terminal, and there is nothing to open on
|
||||
// one of those — except a sandbox, whose URL is minted on bind rather than
|
||||
// published.
|
||||
const live = useMemo(
|
||||
() => hosts.filter((h) => (h.base || h.sandbox || isPending(h.status)) && h.status !== 'offline'),
|
||||
() => hosts.filter((h) => (h.base || h.sandbox) && h.status !== 'offline'),
|
||||
[hosts],
|
||||
);
|
||||
|
||||
@@ -194,6 +192,14 @@ export function Workspace({
|
||||
[takenOn],
|
||||
);
|
||||
|
||||
/** Start a machine and open its first shell. The point of the button is the
|
||||
* terminal: a machine that arrives with nothing framed on it is a row in a
|
||||
* list, and you would have to go and ask for the shell you already asked for. */
|
||||
const launch = useCallback(async () => {
|
||||
if (!onLaunch) return;
|
||||
open(await onLaunch(), focus ? 'row' : null, focus);
|
||||
}, [onLaunch, open, focus]);
|
||||
|
||||
const doClose = useCallback((id: string) => {
|
||||
setTile((t) => (t ? closePane(t, id) : null));
|
||||
setBind((b) => {
|
||||
@@ -371,7 +377,7 @@ export function Workspace({
|
||||
</p>
|
||||
{/* The header is not rendered in this branch, and someone with no machine
|
||||
at all is exactly who has nowhere else to get one. */}
|
||||
{onLaunch ? <Launch run={onLaunch} /> : null}
|
||||
{onLaunch ? <Launch run={launch} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -393,7 +399,7 @@ export function Workspace({
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" /> New shell
|
||||
</button>
|
||||
{onLaunch ? <Launch run={onLaunch} /> : null}
|
||||
{onLaunch ? <Launch run={launch} /> : null}
|
||||
<span className="ml-auto flex shrink-0 items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
+50
-67
@@ -43,73 +43,7 @@ export const sessions = (token: string) =>
|
||||
read<{ sessions: Session[] }>('/v1/agents/sessions?limit=200', token).then((r) => r.sessions ?? []);
|
||||
|
||||
/**
|
||||
* Cloud machines — the boxes we launch for you, rather than the ones you linked.
|
||||
*
|
||||
* A different plane, and deliberately not hidden behind the first one: visor
|
||||
* owns provisioning (launch, list, destroy, per-org billing over Hanzo's house
|
||||
* account), api.hanzo.ai owns the live agent registry. Proxying one through the
|
||||
* other would put Tabs in the middle of a conversation it is not part of, which
|
||||
* is the same reason this app has no backend.
|
||||
*
|
||||
* They MEET at the machine. A launched box runs `hanzo link` exactly as your
|
||||
* laptop does, so it registers itself and its terminal shows up in the registry
|
||||
* above with no special case anywhere — a cloud machine is not a new kind of
|
||||
* pane, it is a machine that happens to have been started by us.
|
||||
*/
|
||||
export const VISOR = process.env.NEXT_PUBLIC_HANZO_VISOR ?? 'https://visor.hanzo.ai';
|
||||
|
||||
/** What visor reports about a box. `state` is the provider's, `tag` carries kind. */
|
||||
export interface CloudMachine {
|
||||
name: string;
|
||||
id: string;
|
||||
displayName?: string;
|
||||
provider?: string;
|
||||
region?: string;
|
||||
size?: string;
|
||||
state?: string;
|
||||
tag?: string;
|
||||
}
|
||||
|
||||
async function visorCall<T>(path: string, token: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${VISOR}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
...(init?.body ? { 'content-type': 'application/json' } : {}),
|
||||
},
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!res.ok) throw new Error(`${path} → ${res.status}`);
|
||||
const body = (await res.json()) as { status?: string; msg?: string; data?: T };
|
||||
// Visor answers 200 with {status:"error"} — the SDK contract is to branch on
|
||||
// the field, not the code, so a failed launch that read as success would look
|
||||
// like a machine that never appears.
|
||||
if (body.status === 'error') throw new Error(body.msg || 'visor refused the request');
|
||||
return (body.data ?? body) as T;
|
||||
}
|
||||
|
||||
/** Boxes this org has running. `kind=tab` filters to the ones you can open. */
|
||||
export const cloudMachines = (token: string, kind = 'tab') =>
|
||||
visorCall<CloudMachine[]>(`/v1/machines?kind=${encodeURIComponent(kind)}`, token).then(
|
||||
(m) => m ?? [],
|
||||
);
|
||||
|
||||
/**
|
||||
* Launch one.
|
||||
*
|
||||
* `kind: 'tab'` and not `'bot'`: a tab runs `hanzo link` and stops there, so you
|
||||
* get a shell without bootstrapping an agent runtime nobody asked for — and the
|
||||
* runtime is the expensive half. See visor service/analytics.go, where the kinds
|
||||
* nest: machine ⊂ tab ⊂ bot.
|
||||
*/
|
||||
export const launchCloudMachine = (token: string, name?: string) =>
|
||||
visorCall<CloudMachine>('/v1/machines/launch', token, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ kind: 'tab', name: name || undefined }),
|
||||
});
|
||||
|
||||
/**
|
||||
* Dev sandboxes — the third kind of machine, and the ONE terminal contract.
|
||||
* Sandboxes — the machines we start for you, rather than the ones you linked.
|
||||
*
|
||||
* The builder's pods (hanzo.app, console.hanzo.ai) serve the same framed
|
||||
* terminal every other surface uses: cloud hosts the whole emulator page at
|
||||
@@ -118,6 +52,12 @@ export const launchCloudMachine = (token: string, name?: string) =>
|
||||
* kind — it is a machine whose terminal URL is MINTED per open instead of
|
||||
* published by `hanzo link`, and `?arg=` names the tmux session exactly as a
|
||||
* linked machine's ttyd does.
|
||||
*
|
||||
* It is the SAME plane as the registry above, which is why there is no second
|
||||
* base URL here. A cloud machine used to be a DigitalOcean droplet ordered
|
||||
* through visor: a second provisioning plane, a second failure mode, and a
|
||||
* minute of boot and `hanzo link` before it could serve a shell. A sandbox is a
|
||||
* pod, so the request that asks for one comes back when it is already running.
|
||||
*/
|
||||
export interface SandboxMachine {
|
||||
id: string;
|
||||
@@ -130,6 +70,49 @@ export const sandboxes = (token: string) =>
|
||||
(r.sandboxes ?? []).filter((s) => s.status === 'running'),
|
||||
);
|
||||
|
||||
/** How a sandbox names itself in the workspace. Its project is what its DISK is
|
||||
* keyed on, so it is the name that means the same box tomorrow. */
|
||||
export const machineName = (s: SandboxMachine) => s.project || `box-${s.id.slice(0, 6)}`;
|
||||
|
||||
/** The project a first cloud machine takes; the ones after it count up. */
|
||||
const PROJECT = 'tabs';
|
||||
|
||||
/**
|
||||
* Start one, and answer it once it is RUNNING.
|
||||
*
|
||||
* `class: 'dev'` is the machine you shell into — a toolchain, a home, and a disk
|
||||
* that outlives the lease — as against `exec`, which is the code interpreter's
|
||||
* scratch pod. A dev sandbox is named by its project, and the project is what
|
||||
* the disk is keyed on: one live sandbox per project is the server's rule, so
|
||||
* the name is picked against what is already live rather than made up. That is
|
||||
* what makes a second press a second machine instead of a second attempt at the
|
||||
* first one.
|
||||
*
|
||||
* The lease is the server's to set. Tabs has no opinion about how long a machine
|
||||
* should live, and a `ttlSec` here would be a second answer to drift from the one
|
||||
* in the class table.
|
||||
*/
|
||||
export const createSandbox = async (token: string): Promise<SandboxMachine> => {
|
||||
const taken = new Set((await sandboxes(token)).map(machineName));
|
||||
let project = PROJECT;
|
||||
for (let n = 2; taken.has(project); n++) project = `${PROJECT}-${n}`;
|
||||
const res = await fetch(`${API}/v1/sandboxes`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}`, 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ class: 'dev', project }),
|
||||
cache: 'no-store',
|
||||
});
|
||||
// Starting a machine fails for reasons a person can act on — an image that
|
||||
// will not pull, a project already held, an org at its ceiling — and the
|
||||
// server says which. A bare status code next to the button would name none of
|
||||
// them.
|
||||
if (!res.ok) {
|
||||
const why = (await res.json().catch(() => null)) as { error?: string } | null;
|
||||
throw new Error(why?.error || `new machine → ${res.status}`);
|
||||
}
|
||||
return (await res.json()) as SandboxMachine;
|
||||
};
|
||||
|
||||
/** Mint the single-use ticket for a sandbox's terminal. Spent on first use. */
|
||||
export const terminalTicket = async (token: string, id: string): Promise<string> => {
|
||||
const res = await fetch(`${API}/v1/sandboxes/${encodeURIComponent(id)}/terminal/ticket`, {
|
||||
|
||||
+2
-41
@@ -72,14 +72,12 @@ export function shellUrl(base: string, name: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
/** Status → dot. The ONE map: session statuses first, then a machine's, then the
|
||||
* two `withCloud` mints below for a box that has not linked yet.
|
||||
/** Status → dot. The ONE map: session statuses first, then a machine's.
|
||||
*
|
||||
* `online | offline | draining` is exactly what the control plane sends
|
||||
* (agents.TargetOnline/Offline/Draining). `busy` was here once and is sent by
|
||||
* nothing; `draining` was missing and fell through to the offline grey, so a box
|
||||
* being deliberately drained read as dead. A booting box would read as dead the
|
||||
* same way, which is why `starting` and `linking` are amber here and not absent. */
|
||||
* being deliberately drained read as dead. */
|
||||
export const DOT: Record<string, string> = {
|
||||
running: 'bg-emerald-500',
|
||||
paused: 'bg-amber-500',
|
||||
@@ -88,8 +86,6 @@ export const DOT: Record<string, string> = {
|
||||
online: 'bg-emerald-500',
|
||||
draining: 'bg-amber-500',
|
||||
offline: 'bg-muted-foreground/40',
|
||||
starting: 'bg-amber-500',
|
||||
linking: 'bg-amber-500',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -137,38 +133,3 @@ export function rescued(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cloud boxes, as ordinary machines.
|
||||
*
|
||||
* A launched box runs `hanzo link` exactly as a laptop does, so once it is up it
|
||||
* arrives through the SAME registry as everything else and needs nothing here. It
|
||||
* is only in the gap — launched, booting, not yet linked — that it exists to
|
||||
* visor and not to the registry, and a box you are waiting on has to be visible
|
||||
* or the launch button looks broken.
|
||||
*
|
||||
* So it is merged BY HOST and the registry wins: the moment the real machine
|
||||
* registers, its own entry replaces the placeholder and there is nothing left of
|
||||
* this. That direction matters — the registry knows whether a terminal is
|
||||
* actually being served, and visor only knows what the provider was told.
|
||||
*/
|
||||
export function withCloud<T extends { machine: string; status: string; base?: string }>(
|
||||
live: T[],
|
||||
cloud: { name: string; state?: string }[],
|
||||
make: (name: string, status: string) => T,
|
||||
): T[] {
|
||||
const known = new Set(live.map((h) => h.machine));
|
||||
const booting = cloud
|
||||
.filter((m) => m.name && !known.has(m.name))
|
||||
// A destroyed box is not a pending one. Anything the provider calls off is
|
||||
// simply gone, and showing it would be a machine you can never open.
|
||||
.filter((m) => !/destroy|delete|terminat|archiv/i.test(m.state ?? ''))
|
||||
.map((m) => make(m.name, /active|running/i.test(m.state ?? '') ? 'linking' : 'starting'));
|
||||
return [...live, ...booting];
|
||||
}
|
||||
|
||||
/** Whether a machine is one of the boxes above: on its way up, so it has no
|
||||
* tunnel yet. The workspace has to let you open a pane on one anyway — the pane
|
||||
* is where you watch it come up, and it turns into a terminal with nothing
|
||||
* moving — so this is what tells it apart from a linked machine that simply
|
||||
* serves no terminal, which there would be nothing to open. */
|
||||
export const isPending = (status: string) => status === 'starting' || status === 'linking';
|
||||
|
||||
@@ -3,12 +3,10 @@ import {
|
||||
DOT,
|
||||
TERM_DEADLINE,
|
||||
TERM_READY,
|
||||
isPending,
|
||||
isTermReady,
|
||||
mintName,
|
||||
rescued,
|
||||
safeName,
|
||||
withCloud,
|
||||
shellUrl,
|
||||
} from '@/lib/panes';
|
||||
|
||||
@@ -115,51 +113,3 @@ describe('a terminal announces itself, and silence is the rescue', () => {
|
||||
expect(TERM_DEADLINE).toBeGreaterThanOrEqual(3000);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A cloud box is a machine that happens to have been started by us. It arrives
|
||||
* through the ordinary registry the moment it links, so the only job here is the
|
||||
* gap before that — and the only rule is that the registry wins.
|
||||
*/
|
||||
describe('a launched box is an ordinary machine', () => {
|
||||
const make = (machine: string, status: string) => ({ machine, status });
|
||||
|
||||
it('shows a box that has not linked yet, so the launch is visible', () => {
|
||||
expect(withCloud([], [{ name: 'cloud-1', state: 'new' }], make)).toEqual([
|
||||
{ machine: 'cloud-1', status: 'starting' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('lets the registry win once the box really registers', () => {
|
||||
// The registry knows a terminal is being served; visor only knows what the
|
||||
// provider was told. A duplicate pane for one box is the bug this prevents.
|
||||
const live = [{ machine: 'cloud-1', status: 'online', base: 'https://x.share.hanzo.ai' }];
|
||||
expect(withCloud(live, [{ name: 'cloud-1', state: 'active' }], make)).toEqual(live);
|
||||
});
|
||||
|
||||
it('never offers a box the provider has destroyed', () => {
|
||||
expect(withCloud([], [{ name: 'gone', state: 'destroyed' }], make)).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps linked machines untouched', () => {
|
||||
const live = [{ machine: 'ra', status: 'online' }];
|
||||
expect(withCloud(live, [], make)).toEqual(live);
|
||||
});
|
||||
|
||||
it('is pending exactly while it has no tunnel', () => {
|
||||
// What the workspace admits without a `base`. Widening this to any status it
|
||||
// does not recognise would let a linked machine serving no terminal be opened
|
||||
// into a pane that waits forever.
|
||||
expect(isPending('starting')).toBe(true);
|
||||
expect(isPending('linking')).toBe(true);
|
||||
expect(isPending('online')).toBe(false);
|
||||
expect(isPending('offline')).toBe(false);
|
||||
});
|
||||
|
||||
it('never reads as dead while it is coming up', () => {
|
||||
// `draining` fell through to the offline grey once and a drained box read as
|
||||
// dead. A booting one fails the same way if these are absent.
|
||||
expect(DOT.starting).toBe(DOT.paused);
|
||||
expect(DOT.linking).toBe(DOT.paused);
|
||||
});
|
||||
});
|
||||
|
||||
+64
-1
@@ -1,7 +1,7 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { API, sandboxTerminal } from '@/lib/api';
|
||||
import { API, createSandbox, machineName, sandboxTerminal } from '@/lib/api';
|
||||
|
||||
/**
|
||||
* A sandbox joins the workspace as a machine whose URL is MINTED, not
|
||||
@@ -49,3 +49,66 @@ describe('the workspace mints once per pane', () => {
|
||||
expect(src).toMatch(/refused\[id\] \?[\s\S]*?Reconnect/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a sandbox is named by its project', () => {
|
||||
it('goes by the project, which is what its disk is keyed on', () => {
|
||||
expect(machineName({ id: 'abcdef123456', status: 'running', project: 'tabs' })).toBe('tabs');
|
||||
});
|
||||
|
||||
it('falls back to its id, so a projectless box is still openable', () => {
|
||||
expect(machineName({ id: 'abcdef123456', status: 'running' })).toBe('box-abcdef');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Starting one. The server keeps ONE live sandbox per project and refuses the
|
||||
* rest, so the name is picked against what is already live — which is the whole
|
||||
* difference between a second machine and a second attempt at the first.
|
||||
*/
|
||||
describe('a new cloud machine', () => {
|
||||
const calls: { url: string; init?: RequestInit }[] = [];
|
||||
const answer = (live: unknown[], made: unknown, ok = true) => {
|
||||
calls.length = 0;
|
||||
global.fetch = (async (url: string, init?: RequestInit) => {
|
||||
calls.push({ url: String(url), init });
|
||||
const body = init?.method === 'POST' ? made : { sandboxes: live };
|
||||
return {
|
||||
ok: init?.method === 'POST' ? ok : true,
|
||||
status: ok ? 201 : 503,
|
||||
json: async () => body,
|
||||
};
|
||||
}) as unknown as typeof fetch;
|
||||
};
|
||||
|
||||
it('asks for a dev sandbox — the machine you shell into, not a scratch pod', async () => {
|
||||
answer([], { id: 'b1', status: 'running', project: 'tabs' });
|
||||
await createSandbox('t');
|
||||
const post = calls.find((c) => c.init?.method === 'POST')!;
|
||||
expect(post.url).toBe(`${API}/v1/sandboxes`);
|
||||
expect(JSON.parse(String(post.init!.body))).toEqual({ class: 'dev', project: 'tabs' });
|
||||
});
|
||||
|
||||
it('counts past the boxes already live, so pressing twice makes two', async () => {
|
||||
answer(
|
||||
[
|
||||
{ id: 'b1', status: 'running', project: 'tabs' },
|
||||
{ id: 'b2', status: 'running', project: 'tabs-2' },
|
||||
],
|
||||
{ id: 'b3', status: 'running', project: 'tabs-3' },
|
||||
);
|
||||
await createSandbox('t');
|
||||
const post = calls.find((c) => c.init?.method === 'POST')!;
|
||||
expect(JSON.parse(String(post.init!.body)).project).toBe('tabs-3');
|
||||
});
|
||||
|
||||
it("carries the server's reason, because a bare status code names none of them", async () => {
|
||||
answer([], { error: 'start sandbox: pod not running after 2m0s' }, false);
|
||||
await expect(createSandbox('t')).rejects.toThrow('pod not running after 2m0s');
|
||||
});
|
||||
|
||||
it('never sends the bearer to anything but the API', async () => {
|
||||
answer([], { id: 'b1', status: 'running', project: 'tabs' });
|
||||
await createSandbox('t');
|
||||
for (const c of calls) expect(c.url.startsWith(API)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user