@amenophis1er/foreman 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DESIGN.md +408 -0
- package/LICENSE +15 -0
- package/README.md +133 -0
- package/bin/foreman.mjs +58 -0
- package/package.json +68 -0
- package/scripts/prepare.mjs +48 -0
- package/skills/director/SKILL.md +65 -0
- package/src/anthropic-models.ts +54 -0
- package/src/ask.test.ts +88 -0
- package/src/ask.ts +95 -0
- package/src/attachments.test.ts +33 -0
- package/src/attachments.ts +60 -0
- package/src/cli.test.ts +27 -0
- package/src/cli.ts +297 -0
- package/src/codex.test.ts +328 -0
- package/src/codex.ts +196 -0
- package/src/cost-basis.test.ts +76 -0
- package/src/deck.test.ts +402 -0
- package/src/deck.ts +892 -0
- package/src/fork.test.ts +31 -0
- package/src/gateway/ledger.cjs +326 -0
- package/src/gateway/ledger.test.ts +255 -0
- package/src/gateway/llm-gateway.cjs +1411 -0
- package/src/gateway/llm-gateway.test.ts +478 -0
- package/src/gateway.test.ts +226 -0
- package/src/gateway.ts +309 -0
- package/src/instance.ts +124 -0
- package/src/models.test.ts +147 -0
- package/src/models.ts +158 -0
- package/src/notify/commands.test.ts +28 -0
- package/src/notify/commands.ts +73 -0
- package/src/notify/telegram.ts +259 -0
- package/src/notify.test.ts +343 -0
- package/src/notify.ts +495 -0
- package/src/ollama.test.ts +49 -0
- package/src/ollama.ts +49 -0
- package/src/openai-prices.test.ts +58 -0
- package/src/openai-prices.ts +106 -0
- package/src/orchestrator.test.ts +1147 -0
- package/src/orchestrator.ts +2325 -0
- package/src/planner.test.ts +60 -0
- package/src/planner.ts +505 -0
- package/src/policy.test.ts +411 -0
- package/src/policy.ts +599 -0
- package/src/preflight.ts +348 -0
- package/src/prices.test.ts +69 -0
- package/src/prices.ts +90 -0
- package/src/provider.test.ts +366 -0
- package/src/provider.ts +502 -0
- package/src/secrets.test.ts +143 -0
- package/src/secrets.ts +66 -0
- package/src/server.ts +1992 -0
- package/src/services.test.ts +53 -0
- package/src/services.ts +102 -0
- package/src/sse-events.test.ts +83 -0
- package/src/store.test.ts +119 -0
- package/src/store.ts +346 -0
- package/src/tailscale.test.ts +32 -0
- package/src/tailscale.ts +79 -0
- package/src/title.ts +138 -0
- package/src/types.ts +442 -0
- package/ui/dist/assets/index-LAj0Dy9p.css +1 -0
- package/ui/dist/assets/index-lcBy-uRZ.js +65 -0
- package/ui/dist/favicon.svg +8 -0
- package/ui/dist/index.html +14 -0
package/src/codex.ts
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex credentials — read what `codex login` already wrote, refresh it when
|
|
3
|
+
* stale, never mint one ourselves.
|
|
4
|
+
*
|
|
5
|
+
* The standing rule (provider-model.md §5): Foreman is on the machine where
|
|
6
|
+
* `codex login` already ran, so it reads `~/.codex/auth.json` the way
|
|
7
|
+
* `provider.ts` reads a Claude Code install's own store. It does not
|
|
8
|
+
* reimplement OpenAI's OAuth flow, does not open a browser, and does not walk
|
|
9
|
+
* a device-code dance — the only network call this module makes is a
|
|
10
|
+
* refresh-token grant against a token that is already on disk.
|
|
11
|
+
*
|
|
12
|
+
* `auth.json` is Codex's file, not ours. A refresh rotates `refresh_token`
|
|
13
|
+
* (single-use — the old one stops working the instant a new one is issued),
|
|
14
|
+
* so the write-back must replace only the `tokens` field and leave everything
|
|
15
|
+
* else — `auth_mode`, `OPENAI_API_KEY`, whatever future field Codex adds —
|
|
16
|
+
* exactly as Codex left it. Losing an unrecognised field here would silently
|
|
17
|
+
* corrupt the CLI's own login the next time it runs.
|
|
18
|
+
*/
|
|
19
|
+
import os from 'node:os';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import crypto from 'node:crypto';
|
|
22
|
+
import { readFile, writeFile, rename } from 'node:fs/promises';
|
|
23
|
+
|
|
24
|
+
/** Matches the shape `codex login` writes to `auth.json`. */
|
|
25
|
+
export interface CodexAuth {
|
|
26
|
+
auth_mode: string;
|
|
27
|
+
OPENAI_API_KEY: string | null;
|
|
28
|
+
tokens?: {
|
|
29
|
+
id_token?: string;
|
|
30
|
+
access_token?: string;
|
|
31
|
+
refresh_token?: string;
|
|
32
|
+
account_id?: string;
|
|
33
|
+
};
|
|
34
|
+
last_refresh?: string;
|
|
35
|
+
/** Anything else Codex ever adds. Preserved verbatim on write-back. */
|
|
36
|
+
[key: string]: unknown;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** One entry from `models_cache.json` — Codex's own catalogue, not ours. */
|
|
40
|
+
interface CodexModelsCache {
|
|
41
|
+
fetched_at?: string;
|
|
42
|
+
etag?: string;
|
|
43
|
+
client_version?: string;
|
|
44
|
+
models?: Array<{ slug?: string; [key: string]: unknown }>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const TOKEN_URL = 'https://auth.openai.com/oauth/token';
|
|
48
|
+
const CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
|
|
49
|
+
|
|
50
|
+
/** Default staleness window: refresh proactively rather than on first 401. */
|
|
51
|
+
const DEFAULT_MAX_AGE_MS = 25 * 60 * 1000;
|
|
52
|
+
|
|
53
|
+
/** The Codex CLI's home: `CODEX_HOME`, then `~/.codex`. */
|
|
54
|
+
export function codexHome(override?: string): string {
|
|
55
|
+
const raw = (override ?? process.env.CODEX_HOME ?? '').trim();
|
|
56
|
+
return raw ? raw : path.join(os.homedir(), '.codex');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Reads and parses `auth.json`. Null covers every ordinary "not usable" case
|
|
61
|
+
* — no install, no login, a file mid-write, a shape we don't recognise — on
|
|
62
|
+
* purpose: a missing Codex install is a fact about the machine, not an error
|
|
63
|
+
* to surface with a stack trace. Never includes file contents in what it
|
|
64
|
+
* throws or logs, since that file holds live tokens.
|
|
65
|
+
*/
|
|
66
|
+
export async function readCodexAuth(home: string): Promise<CodexAuth | null> {
|
|
67
|
+
const file = path.join(home, 'auth.json');
|
|
68
|
+
let raw: string;
|
|
69
|
+
try {
|
|
70
|
+
raw = await readFile(file, 'utf8');
|
|
71
|
+
} catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
let parsed: unknown;
|
|
75
|
+
try {
|
|
76
|
+
parsed = JSON.parse(raw);
|
|
77
|
+
} catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
if (!parsed || typeof parsed !== 'object') return null;
|
|
81
|
+
const auth = parsed as CodexAuth;
|
|
82
|
+
// Usable means: something we can actually authenticate a request with.
|
|
83
|
+
if (!auth.OPENAI_API_KEY && !auth.tokens?.access_token) return null;
|
|
84
|
+
return auth;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Whether `auth.json`'s `last_refresh` is old enough to refresh proactively. */
|
|
88
|
+
export function isStale(auth: CodexAuth, maxAgeMs = DEFAULT_MAX_AGE_MS): boolean {
|
|
89
|
+
const last = auth.last_refresh ? Date.parse(auth.last_refresh) : NaN;
|
|
90
|
+
if (Number.isNaN(last)) return true; // no timestamp we trust — treat as stale
|
|
91
|
+
return Date.now() - last >= maxAgeMs;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
type FetchLike = typeof fetch;
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Refreshes an OAuth-mode login and persists the rotated refresh token.
|
|
98
|
+
*
|
|
99
|
+
* Returns null on any failure — no network, no refresh token to send, a
|
|
100
|
+
* non-2xx response, an unparsable body — rather than throwing, matching every
|
|
101
|
+
* other function here: a refresh failure means "fall back to what's on disk
|
|
102
|
+
* (or fail preflight)", not a crash.
|
|
103
|
+
*
|
|
104
|
+
* The write-back is tmp-file + rename (atomic on the same filesystem, which a
|
|
105
|
+
* sibling temp file always is) and starts from the file's *current* on-disk
|
|
106
|
+
* content re-read fresh, not the `auth` passed in — so a field Codex itself
|
|
107
|
+
* changed between our read and our refresh isn't clobbered by a stale copy.
|
|
108
|
+
* Only `tokens` and `last_refresh` are touched.
|
|
109
|
+
*/
|
|
110
|
+
export async function refreshCodexAuth(
|
|
111
|
+
home: string,
|
|
112
|
+
auth: CodexAuth,
|
|
113
|
+
fetchImpl: FetchLike = fetch,
|
|
114
|
+
): Promise<CodexAuth | null> {
|
|
115
|
+
const refreshToken = auth.tokens?.refresh_token;
|
|
116
|
+
if (!refreshToken) return null;
|
|
117
|
+
|
|
118
|
+
let body: { access_token?: string; id_token?: string; refresh_token?: string };
|
|
119
|
+
try {
|
|
120
|
+
const res = await fetchImpl(TOKEN_URL, {
|
|
121
|
+
method: 'POST',
|
|
122
|
+
headers: { 'content-type': 'application/json' },
|
|
123
|
+
body: JSON.stringify({
|
|
124
|
+
client_id: CLIENT_ID,
|
|
125
|
+
grant_type: 'refresh_token',
|
|
126
|
+
refresh_token: refreshToken,
|
|
127
|
+
}),
|
|
128
|
+
});
|
|
129
|
+
if (!res.ok) return null;
|
|
130
|
+
body = (await res.json()) as typeof body;
|
|
131
|
+
} catch {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
if (!body.access_token) return null;
|
|
135
|
+
|
|
136
|
+
const file = path.join(home, 'auth.json');
|
|
137
|
+
// Re-read the file on disk rather than trusting the caller's copy: this is
|
|
138
|
+
// the only place we write, so it's the only place that can silently drop a
|
|
139
|
+
// field Codex wrote in the meantime.
|
|
140
|
+
const current = (await readCodexAuth(home)) ?? auth;
|
|
141
|
+
const updated: CodexAuth = {
|
|
142
|
+
...current,
|
|
143
|
+
tokens: {
|
|
144
|
+
...current.tokens,
|
|
145
|
+
access_token: body.access_token,
|
|
146
|
+
id_token: body.id_token ?? current.tokens?.id_token,
|
|
147
|
+
// The refresh token Codex returns is single-use; if the response omits
|
|
148
|
+
// one (shouldn't happen, but the API is someone else's), keep the old
|
|
149
|
+
// one rather than nulling out the only credential we could refresh with
|
|
150
|
+
// next time.
|
|
151
|
+
refresh_token: body.refresh_token ?? current.tokens?.refresh_token,
|
|
152
|
+
},
|
|
153
|
+
last_refresh: new Date().toISOString(),
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const tmp = path.join(home, `.auth.${crypto.randomBytes(4).toString('hex')}.tmp`);
|
|
157
|
+
try {
|
|
158
|
+
await writeFile(tmp, JSON.stringify(updated, null, 2));
|
|
159
|
+
await rename(tmp, file);
|
|
160
|
+
} catch {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
return updated;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Model ids from `models_cache.json` — Codex's own list, so the picker never
|
|
168
|
+
* drifts from what the install actually has. Empty array (never a throw) if
|
|
169
|
+
* the file is absent, unparsable, or shaped differently than expected: an
|
|
170
|
+
* empty picker is an ordinary state, a crashed preflight is not.
|
|
171
|
+
*/
|
|
172
|
+
export async function codexModels(home: string): Promise<string[]> {
|
|
173
|
+
const file = path.join(home, 'models_cache.json');
|
|
174
|
+
let raw: string;
|
|
175
|
+
try {
|
|
176
|
+
raw = await readFile(file, 'utf8');
|
|
177
|
+
} catch {
|
|
178
|
+
return [];
|
|
179
|
+
}
|
|
180
|
+
try {
|
|
181
|
+
const parsed = JSON.parse(raw) as CodexModelsCache;
|
|
182
|
+
const models = parsed.models;
|
|
183
|
+
if (!Array.isArray(models)) return [];
|
|
184
|
+
return models
|
|
185
|
+
// Codex marks internal entries `visibility: "hide"` — `gpt-reserve` and
|
|
186
|
+
// `codex-auto-review` on a current install. They are real ids the API
|
|
187
|
+
// would accept, which is exactly why they must not reach a picker: an
|
|
188
|
+
// operator choosing one has picked something the product never meant to
|
|
189
|
+
// offer. Anything without an explicit visibility is shown.
|
|
190
|
+
.filter((m) => m.visibility !== 'hide')
|
|
191
|
+
.map((m) => m.slug)
|
|
192
|
+
.filter((slug): slug is string => typeof slug === 'string' && slug.length > 0);
|
|
193
|
+
} catch {
|
|
194
|
+
return [];
|
|
195
|
+
}
|
|
196
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The cost basis, and the boolean it replaced.
|
|
3
|
+
*
|
|
4
|
+
* These are the rules that decide whether a dollar cap may kill a run and
|
|
5
|
+
* whether someone is told they are spending nothing. Both have been wrong in
|
|
6
|
+
* production before — a real mission was interrupted at "125% of budget" over
|
|
7
|
+
* Anthropic prices applied to Ollama tokens — so they are pinned here rather
|
|
8
|
+
* than left to the call sites that read them.
|
|
9
|
+
*/
|
|
10
|
+
import test from 'node:test';
|
|
11
|
+
import assert from 'node:assert/strict';
|
|
12
|
+
import { combineBasis, costBasisOf, isPriced } from './types.js';
|
|
13
|
+
|
|
14
|
+
test('a recorded basis is returned as written', () => {
|
|
15
|
+
assert.equal(costBasisOf({ costBasis: 'free' }), 'free');
|
|
16
|
+
assert.equal(costBasisOf({ costBasis: 'unpriced' }), 'unpriced');
|
|
17
|
+
assert.equal(costBasisOf({ costBasis: 'priced' }), 'priced');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('a run recorded before the split still answers, and errs toward spend', () => {
|
|
21
|
+
// `metered: false` meant "not priceable", which covered both free and
|
|
22
|
+
// unpriced. It cannot be split after the fact, so it reads as unpriced:
|
|
23
|
+
// telling someone a paid run was free is the error that costs money.
|
|
24
|
+
assert.equal(costBasisOf({ metered: false }), 'unpriced');
|
|
25
|
+
assert.equal(costBasisOf({ metered: true }), 'priced');
|
|
26
|
+
// Neither field: the old default was metered, and every run recorded that
|
|
27
|
+
// way was a Claude Code one.
|
|
28
|
+
assert.equal(costBasisOf({}), 'priced');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('costBasis wins over a stale metered boolean', () => {
|
|
32
|
+
// Both are written together, but a record round-tripped through older code
|
|
33
|
+
// could disagree. The richer field is the one that means something.
|
|
34
|
+
assert.equal(costBasisOf({ costBasis: 'free', metered: true }), 'free');
|
|
35
|
+
assert.equal(costBasisOf({ costBasis: 'priced', metered: false }), 'priced');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('only a priced run may show or enforce dollars', () => {
|
|
39
|
+
assert.equal(isPriced({ costBasis: 'priced' }), true);
|
|
40
|
+
assert.equal(isPriced({ costBasis: 'free' }), false);
|
|
41
|
+
assert.equal(isPriced({ costBasis: 'unpriced' }), false);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('free and unpriced are distinct, which is the whole point', () => {
|
|
45
|
+
// The regression this file exists to prevent: a local model and an OpenAI
|
|
46
|
+
// key collapsing to one state and rendering identically.
|
|
47
|
+
assert.notEqual(costBasisOf({ costBasis: 'free' }), costBasisOf({ costBasis: 'unpriced' }));
|
|
48
|
+
// ...while still agreeing about the only thing enforcement asks them.
|
|
49
|
+
assert.equal(isPriced({ costBasis: 'free' }), isPriced({ costBasis: 'unpriced' }));
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('a mixed run is priced if any role bills real dollars', () => {
|
|
53
|
+
// A Claude director delegating to local workers still spends real money on
|
|
54
|
+
// its own turns; leaving that uncapped is worse than overstating it.
|
|
55
|
+
assert.equal(combineBasis('priced', 'free'), 'priced');
|
|
56
|
+
assert.equal(combineBasis('free', 'priced'), 'priced');
|
|
57
|
+
assert.equal(combineBasis('priced', 'unpriced'), 'priced');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('a mixed run is never called free when part of it is not', () => {
|
|
61
|
+
assert.equal(combineBasis('free', 'unpriced'), 'unpriced');
|
|
62
|
+
assert.equal(combineBasis('unpriced', 'free'), 'unpriced');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('only two free roles make a free run', () => {
|
|
66
|
+
assert.equal(combineBasis('free', 'free'), 'free');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('combining is order-independent', () => {
|
|
70
|
+
const all = ['priced', 'free', 'unpriced'] as const;
|
|
71
|
+
for (const a of all) {
|
|
72
|
+
for (const b of all) {
|
|
73
|
+
assert.equal(combineBasis(a, b), combineBasis(b, a), `${a}+${b} must not depend on role order`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
});
|
package/src/deck.test.ts
ADDED
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { execFile } from 'node:child_process';
|
|
4
|
+
import http from 'node:http';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { mkdir, mkdtemp, rm, symlink, unlink, utimes, writeFile } from 'node:fs/promises';
|
|
8
|
+
import { captureBaseline, deckFor, handleDeckRoute, loadBaseline, readArtifact, type Deck, type DeckFile } from './deck.js';
|
|
9
|
+
|
|
10
|
+
// Every test gets its own folder under os.tmpdir(), which is not inside a git
|
|
11
|
+
// work tree, so the "no git" cases really exercise the snapshot path.
|
|
12
|
+
async function tmp(prefix = 'foreman-deck-'): Promise<string> {
|
|
13
|
+
return mkdtemp(path.join(os.tmpdir(), prefix));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function git(cwd: string, ...args: string[]): Promise<string> {
|
|
17
|
+
return new Promise((resolve, reject) => {
|
|
18
|
+
execFile('git', [
|
|
19
|
+
// A hermetic identity: no dependency on the developer's global config,
|
|
20
|
+
// and no signing so CI machines without a key can commit.
|
|
21
|
+
'-c', 'user.name=t', '-c', 'user.email=t@example.com', '-c', 'commit.gpgsign=false',
|
|
22
|
+
...args,
|
|
23
|
+
], { cwd, env: { ...process.env, GIT_CONFIG_NOSYSTEM: '1' } }, (err, stdout, stderr) => {
|
|
24
|
+
if (err) reject(new Error(stderr || err.message));
|
|
25
|
+
else resolve(stdout);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function touch(file: string, whenMs: number): Promise<void> {
|
|
31
|
+
await utimes(file, whenMs / 1000, whenMs / 1000);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const byPath = (deck: Deck): Record<string, DeckFile> =>
|
|
35
|
+
Object.fromEntries(deck.files.map((f) => [f.path, f]));
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// git baseline
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
test('git folder: modified, added and deleted files are attributed; a pre-dirty file is flagged preexisting', async () => {
|
|
42
|
+
const folder = await tmp();
|
|
43
|
+
try {
|
|
44
|
+
await git(folder, 'init', '-q');
|
|
45
|
+
await writeFile(path.join(folder, 'a.txt'), 'one\ntwo\nthree\n');
|
|
46
|
+
await writeFile(path.join(folder, 'c.txt'), 'gone\nsoon\n');
|
|
47
|
+
await writeFile(path.join(folder, 'dirty.txt'), 'clean\n');
|
|
48
|
+
await git(folder, 'add', '.');
|
|
49
|
+
await git(folder, 'commit', '-q', '-m', 'base');
|
|
50
|
+
// Dirtied before the run starts: the run must not be blamed for it.
|
|
51
|
+
await writeFile(path.join(folder, 'dirty.txt'), 'dirty before\n');
|
|
52
|
+
|
|
53
|
+
const runId = '1788635675338-e8e5a4a8';
|
|
54
|
+
const baseline = await captureBaseline(folder, runId);
|
|
55
|
+
assert.equal(baseline.kind, 'git');
|
|
56
|
+
if (baseline.kind !== 'git') throw new Error('unreachable');
|
|
57
|
+
assert.match(baseline.head ?? '', /^[0-9a-f]{40}$/);
|
|
58
|
+
assert.deepEqual(baseline.dirty, ['dirty.txt']);
|
|
59
|
+
assert.deepEqual(await loadBaseline(folder, runId), baseline);
|
|
60
|
+
|
|
61
|
+
// The run's work.
|
|
62
|
+
await writeFile(path.join(folder, 'a.txt'), 'one\n2\nthree\nfour\n');
|
|
63
|
+
await writeFile(path.join(folder, 'b.txt'), 'new file\nsecond line\n');
|
|
64
|
+
await unlink(path.join(folder, 'c.txt'));
|
|
65
|
+
await writeFile(path.join(folder, 'dirty.txt'), 'dirty before\nand during\n');
|
|
66
|
+
await writeFile(path.join(folder, 'bin.dat'), Buffer.from([0, 1, 2, 3, 0, 255]));
|
|
67
|
+
|
|
68
|
+
const deck = await deckFor(folder, runId);
|
|
69
|
+
assert.equal(deck.baseline.kind, 'git');
|
|
70
|
+
assert.equal(deck.baseline.head, baseline.head);
|
|
71
|
+
const f = byPath(deck);
|
|
72
|
+
|
|
73
|
+
assert.equal(f['a.txt'].status, 'modified');
|
|
74
|
+
assert.equal(f['a.txt'].additions, 2);
|
|
75
|
+
assert.equal(f['a.txt'].deletions, 1);
|
|
76
|
+
assert.match(f['a.txt'].diff ?? '', /^-two$/m);
|
|
77
|
+
assert.match(f['a.txt'].diff ?? '', /^\+2$/m);
|
|
78
|
+
assert.equal(f['a.txt'].preexisting, undefined);
|
|
79
|
+
|
|
80
|
+
assert.equal(f['b.txt'].status, 'added');
|
|
81
|
+
assert.equal(f['b.txt'].additions, 2);
|
|
82
|
+
assert.equal(f['b.txt'].deletions, 0);
|
|
83
|
+
assert.match(f['b.txt'].diff ?? '', /^\+new file$/m);
|
|
84
|
+
|
|
85
|
+
assert.equal(f['c.txt'].status, 'deleted');
|
|
86
|
+
assert.equal(f['c.txt'].deletions, 2);
|
|
87
|
+
assert.match(f['c.txt'].diff ?? '', /^-gone$/m);
|
|
88
|
+
|
|
89
|
+
assert.equal(f['dirty.txt'].status, 'modified');
|
|
90
|
+
assert.equal(f['dirty.txt'].preexisting, true);
|
|
91
|
+
assert.match(deck.note ?? '', /preexisting/);
|
|
92
|
+
|
|
93
|
+
assert.equal(f['bin.dat'].status, 'added');
|
|
94
|
+
assert.equal(f['bin.dat'].binary, true);
|
|
95
|
+
assert.equal(f['bin.dat'].diff, undefined);
|
|
96
|
+
|
|
97
|
+
assert.equal(deck.totals.files, 5);
|
|
98
|
+
// dirty.txt is diffed against HEAD, so its pre-run edit is counted too —
|
|
99
|
+
// that is precisely what the preexisting flag warns about.
|
|
100
|
+
assert.equal(deck.totals.additions, 2 + 2 + 2);
|
|
101
|
+
assert.equal(deck.totals.deletions, 1 + 2 + 1);
|
|
102
|
+
// The baseline lives under .foreman/work, which git never sees as a change.
|
|
103
|
+
assert.ok(!Object.keys(f).some((p) => p.startsWith('.foreman/')), 'baseline leaked into the diff');
|
|
104
|
+
} finally {
|
|
105
|
+
await rm(folder, { recursive: true, force: true });
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('git folder with no commits yet: every tracked and untracked file is added', async () => {
|
|
110
|
+
const folder = await tmp();
|
|
111
|
+
try {
|
|
112
|
+
await git(folder, 'init', '-q');
|
|
113
|
+
const baseline = await captureBaseline(folder, 'r1');
|
|
114
|
+
assert.equal(baseline.kind, 'git');
|
|
115
|
+
if (baseline.kind === 'git') assert.equal(baseline.head, null);
|
|
116
|
+
await writeFile(path.join(folder, 'x.txt'), 'x\n');
|
|
117
|
+
await git(folder, 'add', 'x.txt');
|
|
118
|
+
await writeFile(path.join(folder, 'y.txt'), 'y\n');
|
|
119
|
+
const deck = await deckFor(folder, 'r1');
|
|
120
|
+
const f = byPath(deck);
|
|
121
|
+
assert.equal(f['x.txt'].status, 'added');
|
|
122
|
+
assert.equal(f['y.txt'].status, 'added');
|
|
123
|
+
assert.equal(f['y.txt'].additions, 1);
|
|
124
|
+
} finally {
|
|
125
|
+
await rm(folder, { recursive: true, force: true });
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
// snapshot baseline
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
test('snapshot folder: statuses from hashes, real diffs from the kept copies, big files skipped', async () => {
|
|
134
|
+
const folder = await tmp();
|
|
135
|
+
try {
|
|
136
|
+
await writeFile(path.join(folder, 'keep.txt'), 'same\n');
|
|
137
|
+
await writeFile(path.join(folder, 'mod.txt'), 'alpha\nbeta\ngamma\n');
|
|
138
|
+
await writeFile(path.join(folder, 'del.txt'), 'bye\nbye\nbye\n');
|
|
139
|
+
await writeFile(path.join(folder, 'huge-before.bin'), Buffer.alloc(2 * 1024 * 1024 + 1, 1));
|
|
140
|
+
await mkdir(path.join(folder, 'node_modules', 'dep'), { recursive: true });
|
|
141
|
+
await writeFile(path.join(folder, 'node_modules', 'dep', 'index.js'), 'ignored\n');
|
|
142
|
+
|
|
143
|
+
const baseline = await captureBaseline(folder, 'snap');
|
|
144
|
+
assert.equal(baseline.kind, 'snapshot');
|
|
145
|
+
if (baseline.kind !== 'snapshot') throw new Error('unreachable');
|
|
146
|
+
assert.deepEqual(Object.keys(baseline.files).sort(), ['del.txt', 'keep.txt', 'mod.txt']);
|
|
147
|
+
|
|
148
|
+
await writeFile(path.join(folder, 'mod.txt'), 'alpha\nBETA\ngamma\ndelta\n');
|
|
149
|
+
await writeFile(path.join(folder, 'new.txt'), 'n1\nn2\n');
|
|
150
|
+
await unlink(path.join(folder, 'del.txt'));
|
|
151
|
+
await writeFile(path.join(folder, 'huge-after.bin'), Buffer.alloc(3 * 1024 * 1024, 2));
|
|
152
|
+
// Touched but unchanged: must not show up.
|
|
153
|
+
await touch(path.join(folder, 'keep.txt'), Date.now() + 5_000);
|
|
154
|
+
|
|
155
|
+
const deck = await deckFor(folder, 'snap');
|
|
156
|
+
assert.equal(deck.baseline.kind, 'snapshot');
|
|
157
|
+
assert.equal(deck.baseline.at, baseline.at);
|
|
158
|
+
const f = byPath(deck);
|
|
159
|
+
assert.deepEqual(Object.keys(f).sort(), ['del.txt', 'mod.txt', 'new.txt']);
|
|
160
|
+
|
|
161
|
+
assert.equal(f['mod.txt'].status, 'modified');
|
|
162
|
+
assert.equal(f['mod.txt'].additions, 2);
|
|
163
|
+
assert.equal(f['mod.txt'].deletions, 1);
|
|
164
|
+
assert.match(f['mod.txt'].diff ?? '', /^-beta$/m);
|
|
165
|
+
assert.match(f['mod.txt'].diff ?? '', /^\+BETA$/m);
|
|
166
|
+
assert.match(f['mod.txt'].diff ?? '', /^@@ -1,3 \+1,4 @@$/m);
|
|
167
|
+
|
|
168
|
+
assert.equal(f['new.txt'].status, 'added');
|
|
169
|
+
const body = (f['new.txt'].diff ?? '').split('\n').filter((l) => !/^(---|\+\+\+|@@)/.test(l));
|
|
170
|
+
assert.equal(body.length, 2);
|
|
171
|
+
assert.ok(body.every((l) => l.startsWith('+')), `not all additions: ${body}`);
|
|
172
|
+
|
|
173
|
+
assert.equal(f['del.txt'].status, 'deleted');
|
|
174
|
+
assert.equal(f['del.txt'].deletions, 3);
|
|
175
|
+
assert.match(f['del.txt'].diff ?? '', /^-bye$/m);
|
|
176
|
+
|
|
177
|
+
assert.deepEqual(deck.totals, { files: 3, additions: 4, deletions: 4 });
|
|
178
|
+
} finally {
|
|
179
|
+
await rm(folder, { recursive: true, force: true });
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('snapshot: a long diff is capped at 400 lines and flagged', async () => {
|
|
184
|
+
const folder = await tmp();
|
|
185
|
+
try {
|
|
186
|
+
await writeFile(path.join(folder, 'long.txt'), 'x\n');
|
|
187
|
+
await captureBaseline(folder, 'cap');
|
|
188
|
+
const lines = Array.from({ length: 1000 }, (_, i) => `line ${i}`).join('\n') + '\n';
|
|
189
|
+
await writeFile(path.join(folder, 'long.txt'), lines);
|
|
190
|
+
const deck = await deckFor(folder, 'cap');
|
|
191
|
+
const f = byPath(deck)['long.txt'];
|
|
192
|
+
assert.equal(f.truncated, true);
|
|
193
|
+
assert.equal((f.diff ?? '').split('\n').length, 400);
|
|
194
|
+
assert.equal(f.additions, 1000); // counts are for the whole change, not the shown part
|
|
195
|
+
assert.equal(f.deletions, 1);
|
|
196
|
+
} finally {
|
|
197
|
+
await rm(folder, { recursive: true, force: true });
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
// artifacts
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
|
|
205
|
+
test('artifacts: screenshots and work-dir files always, report-like files only when newer than the baseline', async () => {
|
|
206
|
+
const folder = await tmp();
|
|
207
|
+
try {
|
|
208
|
+
await writeFile(path.join(folder, 'before.md'), '# old\n');
|
|
209
|
+
const baseline = await captureBaseline(folder, 'art');
|
|
210
|
+
// Pin mtimes on both sides of the baseline so the test does not depend
|
|
211
|
+
// on filesystem timestamp granularity.
|
|
212
|
+
await touch(path.join(folder, 'before.md'), baseline.at - 10_000);
|
|
213
|
+
await mkdir(path.join(folder, 'screenshots'));
|
|
214
|
+
await writeFile(path.join(folder, 'screenshots', 'x.png'), Buffer.from([0x89, 0x50, 0x4e, 0x47]));
|
|
215
|
+
await writeFile(path.join(folder, '.foreman', 'work', 'http.log'), 'GET / 200\n');
|
|
216
|
+
await writeFile(path.join(folder, '.foreman', 'work', '.gitignore'), '*\n');
|
|
217
|
+
await writeFile(path.join(folder, 'notes.md'), '# notes\n');
|
|
218
|
+
await writeFile(path.join(folder, 'report.pdf'), '%PDF-1.4\n');
|
|
219
|
+
await writeFile(path.join(folder, 'code.ts'), 'export {};\n'); // not an artifact extension
|
|
220
|
+
for (const rel of ['screenshots/x.png', '.foreman/work/http.log', 'notes.md', 'report.pdf', 'code.ts']) {
|
|
221
|
+
await touch(path.join(folder, rel), baseline.at + 10_000);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const deck = await deckFor(folder, 'art');
|
|
225
|
+
const kinds = Object.fromEntries(deck.artifacts.map((a) => [a.path, a.kind]));
|
|
226
|
+
assert.deepEqual(kinds, {
|
|
227
|
+
'screenshots/x.png': 'image',
|
|
228
|
+
'.foreman/work/http.log': 'text',
|
|
229
|
+
'notes.md': 'text',
|
|
230
|
+
'report.pdf': 'pdf',
|
|
231
|
+
});
|
|
232
|
+
const png = deck.artifacts.find((a) => a.path === 'screenshots/x.png');
|
|
233
|
+
assert.equal(png?.size, 4);
|
|
234
|
+
// Newest first.
|
|
235
|
+
const times = deck.artifacts.map((a) => a.mtimeMs);
|
|
236
|
+
assert.deepEqual(times, [...times].sort((a, b) => b - a));
|
|
237
|
+
// notes.md is also a change; the deck reports both views of it.
|
|
238
|
+
assert.equal(byPath(deck)['notes.md']?.status, 'added');
|
|
239
|
+
} finally {
|
|
240
|
+
await rm(folder, { recursive: true, force: true });
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test('no baseline: kind none with the note, artifacts still listed', async () => {
|
|
245
|
+
const folder = await tmp();
|
|
246
|
+
try {
|
|
247
|
+
await mkdir(path.join(folder, 'screenshots'));
|
|
248
|
+
await writeFile(path.join(folder, 'screenshots', 'shot.jpg'), 'jpg');
|
|
249
|
+
await writeFile(path.join(folder, 'README.md'), '# not an artifact without a baseline\n');
|
|
250
|
+
const deck = await deckFor(folder, 'never-started');
|
|
251
|
+
assert.equal(deck.baseline.kind, 'none');
|
|
252
|
+
assert.deepEqual(deck.files, []);
|
|
253
|
+
assert.equal(deck.note, 'No baseline was recorded when this run started, so changes cannot be attributed to it.');
|
|
254
|
+
assert.deepEqual(deck.artifacts.map((a) => a.path), ['screenshots/shot.jpg']);
|
|
255
|
+
assert.equal(deck.artifacts[0].kind, 'image');
|
|
256
|
+
} finally {
|
|
257
|
+
await rm(folder, { recursive: true, force: true });
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
// ---------------------------------------------------------------------------
|
|
262
|
+
// readArtifact
|
|
263
|
+
// ---------------------------------------------------------------------------
|
|
264
|
+
|
|
265
|
+
test('readArtifact stays inside the folder and reports mime and size', async () => {
|
|
266
|
+
const folder = await tmp();
|
|
267
|
+
const outside = await tmp('foreman-deck-outside-');
|
|
268
|
+
try {
|
|
269
|
+
await mkdir(path.join(folder, 'screenshots'));
|
|
270
|
+
await writeFile(path.join(folder, 'screenshots', 'a.png'), Buffer.alloc(10));
|
|
271
|
+
await writeFile(path.join(folder, 'page.html'), '<script>alert(1)</script>');
|
|
272
|
+
await writeFile(path.join(outside, 'secret.txt'), 'nope');
|
|
273
|
+
await symlink(path.join(outside, 'secret.txt'), path.join(folder, 'link.txt'));
|
|
274
|
+
await symlink(outside, path.join(folder, 'linkdir'));
|
|
275
|
+
|
|
276
|
+
assert.equal(await readArtifact(folder, '../etc/passwd'), null);
|
|
277
|
+
assert.equal(await readArtifact(folder, '/etc/passwd'), null);
|
|
278
|
+
assert.equal(await readArtifact(folder, 'screenshots/../../etc/passwd'), null);
|
|
279
|
+
assert.equal(await readArtifact(folder, 'link.txt'), null);
|
|
280
|
+
assert.equal(await readArtifact(folder, 'linkdir/secret.txt'), null);
|
|
281
|
+
assert.equal(await readArtifact(folder, 'screenshots'), null); // a directory
|
|
282
|
+
assert.equal(await readArtifact(folder, 'missing.png'), null);
|
|
283
|
+
assert.equal(await readArtifact(folder, ''), null);
|
|
284
|
+
|
|
285
|
+
const png = await readArtifact(folder, 'screenshots/a.png');
|
|
286
|
+
assert.ok(png);
|
|
287
|
+
assert.equal(png.mime, 'image/png');
|
|
288
|
+
assert.equal(png.size, 10);
|
|
289
|
+
assert.equal(png.absPath, path.join(await import('node:fs/promises').then((m) => m.realpath(folder)), 'screenshots', 'a.png'));
|
|
290
|
+
|
|
291
|
+
const html = await readArtifact(folder, 'page.html');
|
|
292
|
+
assert.ok(html);
|
|
293
|
+
assert.match(html.mime, /^text\/plain/); // never rendered as a document
|
|
294
|
+
} finally {
|
|
295
|
+
await rm(folder, { recursive: true, force: true });
|
|
296
|
+
await rm(outside, { recursive: true, force: true });
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
// ---------------------------------------------------------------------------
|
|
301
|
+
// handleDeckRoute
|
|
302
|
+
// ---------------------------------------------------------------------------
|
|
303
|
+
|
|
304
|
+
test('handleDeckRoute: matches only its two routes, 404s unknown runs and bad paths, streams real artifacts', async () => {
|
|
305
|
+
const folder = await tmp();
|
|
306
|
+
const runs: Record<string, { folder: string }> = { 'run-1': { folder } };
|
|
307
|
+
const server = http.createServer(async (req, res) => {
|
|
308
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
309
|
+
const handled = await handleDeckRoute(req, res, url, async (id) => runs[id] ?? null);
|
|
310
|
+
// A status no deck response ever uses, so the test can see "fell through".
|
|
311
|
+
if (!handled) { res.writeHead(418); res.end(); }
|
|
312
|
+
});
|
|
313
|
+
await new Promise<void>((r) => server.listen(0, '127.0.0.1', r));
|
|
314
|
+
const port = (server.address() as { port: number }).port;
|
|
315
|
+
const get = (p: string) => fetch(`http://127.0.0.1:${port}${p}`);
|
|
316
|
+
try {
|
|
317
|
+
await mkdir(path.join(folder, 'screenshots'));
|
|
318
|
+
await writeFile(path.join(folder, 'screenshots', 'a.png'), Buffer.from('pngbytes'));
|
|
319
|
+
await captureBaseline(folder, 'run-1');
|
|
320
|
+
await writeFile(path.join(folder, 'made.txt'), 'hello\n');
|
|
321
|
+
|
|
322
|
+
assert.equal((await get('/runs/run-1/events')).status, 418);
|
|
323
|
+
assert.equal((await get('/runs')).status, 418);
|
|
324
|
+
assert.equal((await get('/runs/nope/deck')).status, 404);
|
|
325
|
+
assert.equal((await get('/runs/run%2F..%2F1/deck')).status, 404); // fails the id regex
|
|
326
|
+
assert.equal((await get('/runs/run-1/artifact')).status, 404);
|
|
327
|
+
assert.equal((await get('/runs/run-1/artifact?path=..%2F..%2Fetc%2Fpasswd')).status, 404);
|
|
328
|
+
assert.equal((await get('/runs/run-1/artifact?path=missing.png')).status, 404);
|
|
329
|
+
|
|
330
|
+
const deckRes = await get('/runs/run-1/deck');
|
|
331
|
+
assert.equal(deckRes.status, 200);
|
|
332
|
+
assert.match(deckRes.headers.get('content-type') ?? '', /application\/json/);
|
|
333
|
+
const deck = await deckRes.json();
|
|
334
|
+
assert.equal(deck.runId, 'run-1');
|
|
335
|
+
assert.equal(deck.baseline.kind, 'snapshot');
|
|
336
|
+
assert.equal(deck.files[0].path, 'made.txt');
|
|
337
|
+
// made.txt is newer than the baseline and has an artifact extension, so
|
|
338
|
+
// it is listed alongside the screenshot.
|
|
339
|
+
assert.deepEqual(deck.artifacts.map((a: { path: string }) => a.path).sort(), ['made.txt', 'screenshots/a.png']);
|
|
340
|
+
|
|
341
|
+
const art = await get('/runs/run-1/artifact?path=screenshots%2Fa.png');
|
|
342
|
+
assert.equal(art.status, 200);
|
|
343
|
+
assert.equal(art.headers.get('content-type'), 'image/png');
|
|
344
|
+
assert.equal(art.headers.get('cache-control'), 'no-store');
|
|
345
|
+
assert.match(art.headers.get('content-disposition') ?? '', /^inline; filename="a\.png"$/);
|
|
346
|
+
assert.equal(Buffer.from(await art.arrayBuffer()).toString(), 'pngbytes');
|
|
347
|
+
|
|
348
|
+
const post = await fetch(`http://127.0.0.1:${port}/runs/run-1/deck`, { method: 'POST' });
|
|
349
|
+
assert.equal(post.status, 405);
|
|
350
|
+
} finally {
|
|
351
|
+
await new Promise<void>((r) => server.close(() => r()));
|
|
352
|
+
await rm(folder, { recursive: true, force: true });
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
test('readArtifact: code reads as text, unknown extensions are sniffed, binaries download', async () => {
|
|
357
|
+
const dir = await mkdtemp(path.join(os.tmpdir(), 'deck-mime-'));
|
|
358
|
+
await writeFile(path.join(dir, 'page.yml'), 'a: 1\n');
|
|
359
|
+
await writeFile(path.join(dir, 'notes.weird'), 'plain words, no extension anyone knows\n');
|
|
360
|
+
await writeFile(path.join(dir, 'blob.bin'), Buffer.from([0x89, 0x00, 0x01, 0x02]));
|
|
361
|
+
const yml = await readArtifact(dir, 'page.yml');
|
|
362
|
+
const weird = await readArtifact(dir, 'notes.weird');
|
|
363
|
+
const bin = await readArtifact(dir, 'blob.bin');
|
|
364
|
+
assert.match(yml?.mime ?? '', /^text\/plain/);
|
|
365
|
+
assert.match(weird?.mime ?? '', /^text\/plain/);
|
|
366
|
+
assert.equal(bin?.mime, 'application/octet-stream');
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
test('preview route: real types inside a CSP sandbox, still jailed', async () => {
|
|
370
|
+
const dir = await mkdtemp(path.join(os.tmpdir(), 'deck-preview-'));
|
|
371
|
+
await writeFile(path.join(dir, 'index.html'), '<!doctype html><link rel="stylesheet" href="styles.css"><script src="app.js"></script>');
|
|
372
|
+
await writeFile(path.join(dir, 'styles.css'), 'body{margin:0}');
|
|
373
|
+
await writeFile(path.join(dir, 'app.js'), 'fetch("data.json")');
|
|
374
|
+
const server = http.createServer((req, res) => {
|
|
375
|
+
void handleDeckRoute(req, res, new URL(req.url ?? '/', 'http://x'), async (id) => id === 'run-1' ? { folder: dir } : null)
|
|
376
|
+
.then((handled) => { if (!handled) { res.statusCode = 404; res.end(); } });
|
|
377
|
+
});
|
|
378
|
+
await new Promise<void>((r) => server.listen(0, '127.0.0.1', r));
|
|
379
|
+
const port = (server.address() as { port: number }).port;
|
|
380
|
+
try {
|
|
381
|
+
const html = await fetch(`http://127.0.0.1:${port}/runs/run-1/preview/index.html`);
|
|
382
|
+
assert.equal(html.status, 200);
|
|
383
|
+
assert.match(html.headers.get('content-type') ?? '', /^text\/html/);
|
|
384
|
+
assert.match(html.headers.get('content-security-policy') ?? '', /\bsandbox allow-scripts\b/);
|
|
385
|
+
assert.doesNotMatch(html.headers.get('content-security-policy') ?? '', /allow-same-origin/);
|
|
386
|
+
const css = await fetch(`http://127.0.0.1:${port}/runs/run-1/preview/styles.css`);
|
|
387
|
+
assert.match(css.headers.get('content-type') ?? '', /^text\/css/);
|
|
388
|
+
const js = await fetch(`http://127.0.0.1:${port}/runs/run-1/preview/app.js`);
|
|
389
|
+
assert.match(js.headers.get('content-type') ?? '', /javascript/);
|
|
390
|
+
assert.equal(js.headers.get('access-control-allow-origin'), '*');
|
|
391
|
+
// The plain artifact route is unchanged: HTML there is still text, never a document.
|
|
392
|
+
const raw = await fetch(`http://127.0.0.1:${port}/runs/run-1/artifact?path=index.html`);
|
|
393
|
+
assert.match(raw.headers.get('content-type') ?? '', /^text\/plain/);
|
|
394
|
+
// Jail holds on the preview path too.
|
|
395
|
+
const out = await fetch(`http://127.0.0.1:${port}/runs/run-1/preview/..%2F..%2Fetc%2Fpasswd`);
|
|
396
|
+
assert.equal(out.status, 404);
|
|
397
|
+
const bare = await fetch(`http://127.0.0.1:${port}/runs/run-1/preview`);
|
|
398
|
+
assert.equal(bare.status, 404);
|
|
399
|
+
} finally {
|
|
400
|
+
server.close();
|
|
401
|
+
}
|
|
402
|
+
});
|