ework-aio 0.2.7 → 0.2.9
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/package.json +1 -1
- package/scripts/e2e-browser.sh +322 -0
- package/scripts/e2e-install.sh +340 -42
- package/scripts/fake-llm-server.ts +221 -0
- package/src/commands/env.ts +23 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ework-aio",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.9",
|
|
4
4
|
"description": "All-in-one installer for ework (issue tracker) + ework-daemon (AI bridge) + opencode-ework (plugin). One command: npm i -g ework-aio && ework-aio install.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# End-to-end browser test: install → fake LLM → opencode config → Playwright.
|
|
3
|
+
#
|
|
4
|
+
# This is the "full product path" test. Compared to e2e-install.sh (which
|
|
5
|
+
# covers install/subcommand/lifecycle), this script:
|
|
6
|
+
# - Starts a fake OpenAI-compatible LLM server (scripts/fake-llm-server.ts)
|
|
7
|
+
# - Configures opencode.json to use it (no external API needed)
|
|
8
|
+
# - Creates an issue (which triggers daemon → opencode → fake LLM)
|
|
9
|
+
# - Drives a headless browser through ework-web UI
|
|
10
|
+
# - Verifies the resulting session is browsable and shows content
|
|
11
|
+
#
|
|
12
|
+
# Requires Docker image with Chromium pre-installed. The Dockerfile.regression
|
|
13
|
+
# image handles this; pass via IMAGE env or rely on the default.
|
|
14
|
+
#
|
|
15
|
+
# Usage: ./scripts/e2e-browser.sh [container-runtime] [npm-tag]
|
|
16
|
+
|
|
17
|
+
set -euo pipefail
|
|
18
|
+
|
|
19
|
+
RUNTIME="${1:-docker}"
|
|
20
|
+
NPM_TAG="${2:-latest}"
|
|
21
|
+
IMAGE="${IMAGE:-ework-aio:e2e}"
|
|
22
|
+
|
|
23
|
+
c_grn=$'\033[32m'; c_red=$'\033[31m'; c_ylw=$'\033[33m'; c_rst=$'\033[0m'
|
|
24
|
+
pass() { printf '%sPASS%s %s\n' "$c_grn" "$c_rst" "$*"; }
|
|
25
|
+
fail() { printf '%sFAIL%s %s\n' "$c_red" "$c_rst" "$*"; exit 1; }
|
|
26
|
+
info() { printf '%s…%s %s\n' "$c_ylw" "$c_rst" "$*" >&2; }
|
|
27
|
+
|
|
28
|
+
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
29
|
+
|
|
30
|
+
if ! "$RUNTIME" image inspect "$IMAGE" >/dev/null 2>&1; then
|
|
31
|
+
info "Building $IMAGE (with Chromium deps — first build is slow)"
|
|
32
|
+
"$RUNTIME" build --network=host -f "$REPO_ROOT/Dockerfile.regression" \
|
|
33
|
+
-t "$IMAGE" "$REPO_ROOT"
|
|
34
|
+
fi
|
|
35
|
+
|
|
36
|
+
info "Using image: $IMAGE"
|
|
37
|
+
|
|
38
|
+
# Hardcoded ports — see e2e-install.sh for why we don't inherit from host.
|
|
39
|
+
WORK_PORT="14002"
|
|
40
|
+
DAEMON_PORT="14101"
|
|
41
|
+
FAKE_LLM_PORT="8400"
|
|
42
|
+
|
|
43
|
+
OPENCODE_HOST_BIN="${OPENCODE_HOST_BIN:-/home/dog/.local/bin/opencode}"
|
|
44
|
+
|
|
45
|
+
"$RUNTIME" run --rm -i --network host \
|
|
46
|
+
-v "$OPENCODE_HOST_BIN:/usr/local/bin/opencode:ro" \
|
|
47
|
+
-v "$REPO_ROOT/scripts:/host-scripts:ro" \
|
|
48
|
+
-v "$REPO_ROOT/test:/host-test:ro" \
|
|
49
|
+
-e WORK_PORT="$WORK_PORT" \
|
|
50
|
+
-e DAEMON_PORT="$DAEMON_PORT" \
|
|
51
|
+
-e FAKE_LLM_PORT="$FAKE_LLM_PORT" \
|
|
52
|
+
-e NPM_TAG="$NPM_TAG" \
|
|
53
|
+
-e HTTP_PROXY="${HTTP_PROXY:-}" \
|
|
54
|
+
-e HTTPS_PROXY="${HTTPS_PROXY:-}" \
|
|
55
|
+
-e http_proxy="${HTTP_PROXY:-}" \
|
|
56
|
+
-e https_proxy="${HTTPS_PROXY:-}" \
|
|
57
|
+
-e NO_PROXY="127.0.0.1,localhost" \
|
|
58
|
+
"$IMAGE" bash -euo pipefail <<'EOSCRIPT'
|
|
59
|
+
set -euo pipefail
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
c_grn=$'\033[32m'; c_red=$'\033[31m'; c_ylw=$'\033[33m'; c_rst=$'\033[0m'
|
|
63
|
+
pass() { printf '%sPASS%s %s\n' "$c_grn" "$c_rst" "$*"; }
|
|
64
|
+
fail() { printf '%sFAIL%s %s\n' "$c_red" "$c_rst" "$*"; exit 1; }
|
|
65
|
+
info() { printf '%s…%s %s\n' "$c_ylw" "$c_rst" "$*" >&2; }
|
|
66
|
+
|
|
67
|
+
WORK_PORT="${WORK_PORT:-14002}"
|
|
68
|
+
DAEMON_PORT="${DAEMON_PORT:-14101}"
|
|
69
|
+
FAKE_LLM_PORT="${FAKE_LLM_PORT:-8400}"
|
|
70
|
+
NPM_TAG="${NPM_TAG:-latest}"
|
|
71
|
+
DATA_DIR=/tmp/aio-browser
|
|
72
|
+
|
|
73
|
+
info "clean state"
|
|
74
|
+
rm -rf "$DATA_DIR"
|
|
75
|
+
mkdir -p "$DATA_DIR"
|
|
76
|
+
|
|
77
|
+
info "opencode binary on PATH"
|
|
78
|
+
opencode --version | head -1
|
|
79
|
+
|
|
80
|
+
info "install ework-aio@${NPM_TAG} + deps"
|
|
81
|
+
for pkg in ework-web ework-daemon opencode-ework; do
|
|
82
|
+
npm install -g "$pkg@latest" 2>&1 | tail -2
|
|
83
|
+
done
|
|
84
|
+
npm install -g "ework-aio@${NPM_TAG}" 2>&1 | tail -2
|
|
85
|
+
|
|
86
|
+
info "install Playwright npm package (browser itself is pre-baked into image)"
|
|
87
|
+
# Install into a local node_modules so module resolution works regardless of
|
|
88
|
+
# whether we drive the test via `node` or `bun`. Global installs don't get
|
|
89
|
+
# picked up by Node's import resolver.
|
|
90
|
+
mkdir -p /tmp/test-runner
|
|
91
|
+
cd /tmp/test-runner
|
|
92
|
+
npm init -y >/dev/null
|
|
93
|
+
npm install playwright 2>&1 | tail -2
|
|
94
|
+
|
|
95
|
+
info "start fake LLM server in background"
|
|
96
|
+
PORT="$FAKE_LLM_PORT" bun run /host-scripts/fake-llm-server.ts 2>/tmp/fake-llm.log &
|
|
97
|
+
FAKE_LLM_PID=$!
|
|
98
|
+
# Wait for server to come up.
|
|
99
|
+
for i in $(seq 1 30); do
|
|
100
|
+
if curl -sf "http://127.0.0.1:$FAKE_LLM_PORT/v1/models" >/dev/null; then
|
|
101
|
+
pass "fake LLM up (pid $FAKE_LLM_PID, port $FAKE_LLM_PORT)"
|
|
102
|
+
break
|
|
103
|
+
fi
|
|
104
|
+
sleep 0.5
|
|
105
|
+
[[ $i -eq 30 ]] && { tail /tmp/fake-llm.log; fail "fake LLM did not come up"; }
|
|
106
|
+
done
|
|
107
|
+
|
|
108
|
+
info "run ework-aio install (writes opencode.json with plugin only)"
|
|
109
|
+
ework-aio install \
|
|
110
|
+
--allow-root \
|
|
111
|
+
--data-dir "$DATA_DIR" \
|
|
112
|
+
--port "$WORK_PORT" \
|
|
113
|
+
--daemon-port "$DAEMON_PORT" \
|
|
114
|
+
--bot-name e2e-bot \
|
|
115
|
+
--yes 2>&1 | tee /tmp/install.log | tail -5
|
|
116
|
+
|
|
117
|
+
pass "install exited 0"
|
|
118
|
+
|
|
119
|
+
info "verify services respond"
|
|
120
|
+
for i in $(seq 1 60); do
|
|
121
|
+
curl -sf "http://127.0.0.1:$WORK_PORT/login" >/dev/null && break
|
|
122
|
+
sleep 0.5
|
|
123
|
+
[[ $i -eq 60 ]] && fail "ework-web did not come up"
|
|
124
|
+
done
|
|
125
|
+
for i in $(seq 1 60); do
|
|
126
|
+
curl -sf "http://127.0.0.1:$DAEMON_PORT/api/status" >/dev/null && break
|
|
127
|
+
sleep 0.5
|
|
128
|
+
[[ $i -eq 60 ]] && fail "daemon did not come up"
|
|
129
|
+
done
|
|
130
|
+
pass "both services responding"
|
|
131
|
+
|
|
132
|
+
info "configure opencode to use fake LLM"
|
|
133
|
+
# Overwrite ~/.config/opencode/opencode.json with fake provider + plugin.
|
|
134
|
+
# install.ts only registers the plugin; we add the fake provider here so
|
|
135
|
+
# the test stays isolated from any real API credentials.
|
|
136
|
+
OPENCODE_CONFIG_DIR="$HOME/.config/opencode"
|
|
137
|
+
mkdir -p "$OPENCODE_CONFIG_DIR"
|
|
138
|
+
cat > "$OPENCODE_CONFIG_DIR/opencode.json" <<OCJSON
|
|
139
|
+
{
|
|
140
|
+
"\$schema": "https://opencode.ai/config.json",
|
|
141
|
+
"plugin": ["opencode-ework@latest"],
|
|
142
|
+
"provider": {
|
|
143
|
+
"fake": {
|
|
144
|
+
"npm": "@ai-sdk/openai-compatible",
|
|
145
|
+
"name": "Fake (E2E test)",
|
|
146
|
+
"options": {
|
|
147
|
+
"baseURL": "http://127.0.0.1:$FAKE_LLM_PORT/v1"
|
|
148
|
+
},
|
|
149
|
+
"models": {
|
|
150
|
+
"fake-model": {
|
|
151
|
+
"name": "Fake Model"
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
"model": "fake/fake-model"
|
|
157
|
+
}
|
|
158
|
+
OCJSON
|
|
159
|
+
pass "opencode.json points at fake LLM"
|
|
160
|
+
|
|
161
|
+
info "warm up opencode (first-run DB migration takes minutes)"
|
|
162
|
+
# Opencode prints "Performing one time database migration, may take a few
|
|
163
|
+
# minutes..." on its first invocation against a fresh DB. If we don't warm
|
|
164
|
+
# it up here, both the smoke test AND the daemon's spawned opencode get
|
|
165
|
+
# killed mid-migration and never reach the chat step. Running any cheap
|
|
166
|
+
# command against the DB triggers the migration once; subsequent runs are
|
|
167
|
+
# fast. `session list` is the cheapest touch we can do.
|
|
168
|
+
# </dev/null: opencode session list reads stdin (cosmetic for list mode,
|
|
169
|
+
# but defensive — see smoke test comment for the heredoc-eof trap).
|
|
170
|
+
timeout -s KILL 300 opencode session list </dev/null >/dev/null 2>&1 || true
|
|
171
|
+
pass "opencode warm-up done (migration triggered if needed)"
|
|
172
|
+
|
|
173
|
+
info "verify opencode can reach fake LLM (manual smoke)"
|
|
174
|
+
# SIGKILL after 90s — opencode can hang on tool-call prompts if the LLM
|
|
175
|
+
# response shape is wrong; SIGTERM is caught for cleanup and takes longer.
|
|
176
|
+
# 90s because even post-migration, first stream response can take 10-20s.
|
|
177
|
+
#
|
|
178
|
+
# </dev/null is critical: this script's heredoc provides bash's stdin.
|
|
179
|
+
# opencode run reads stdin when waiting for prompts, which would silently
|
|
180
|
+
# consume the rest of the heredoc — bash then hits EOF and exits without
|
|
181
|
+
# running any subsequent step.
|
|
182
|
+
cd /tmp && timeout -s KILL 90 opencode run --format json --model fake/fake-model --dir /tmp "manual smoke test" </dev/null > /tmp/smoke.json 2>&1 || true
|
|
183
|
+
pass "opencode smoke test ran (output captured)"
|
|
184
|
+
echo " events: $(wc -l < /tmp/smoke.json)"
|
|
185
|
+
echo " --- smoke.json content (first 1000 chars) ---"
|
|
186
|
+
head -c 1000 /tmp/smoke.json
|
|
187
|
+
echo
|
|
188
|
+
echo " --- fake-llm log tail ---"
|
|
189
|
+
tail -5 /tmp/fake-llm.log
|
|
190
|
+
|
|
191
|
+
info "create issue that triggers daemon → opencode → fake LLM"
|
|
192
|
+
# Build admin cookie (same recipe as e2e-install.sh).
|
|
193
|
+
WORK_TOKEN=$(grep ^WORK_TOKEN= "$DATA_DIR/ework-web/.env" | cut -d= -f2-)
|
|
194
|
+
WORK_COOKIE_SECRET=$(grep ^WORK_COOKIE_SECRET= "$DATA_DIR/ework-web/.env" | cut -d= -f2-)
|
|
195
|
+
COOKIE_SIG=$(printf '%s' "$WORK_TOKEN" \
|
|
196
|
+
| openssl dgst -sha256 -hmac "$WORK_COOKIE_SECRET" -binary \
|
|
197
|
+
| base64 | tr '+/' '-_' | tr -d '=')
|
|
198
|
+
AUTH_COOKIE="ework_auth=${WORK_TOKEN}.${COOKIE_SIG}"
|
|
199
|
+
|
|
200
|
+
# Create project (idempotent — if it exists, 303 is still returned).
|
|
201
|
+
curl -sS -o /dev/null -w 'project: %{http_code}\n' -X POST \
|
|
202
|
+
"http://127.0.0.1:$WORK_PORT/projects" \
|
|
203
|
+
-H "Cookie: $AUTH_COOKIE" \
|
|
204
|
+
--data-urlencode 'owner=e2e' \
|
|
205
|
+
--data-urlencode 'name=browser-test'
|
|
206
|
+
|
|
207
|
+
# Create issue (triggers webhook → daemon → opencode).
|
|
208
|
+
ISSUE_TITLE="Browser E2E $(date +%s)"
|
|
209
|
+
curl -sS -o /dev/null -w 'issue: %{http_code}\n' -X POST \
|
|
210
|
+
"http://127.0.0.1:$WORK_PORT/e2e/browser-test/issues" \
|
|
211
|
+
-H "Cookie: $AUTH_COOKIE" \
|
|
212
|
+
--data-urlencode "title=$ISSUE_TITLE" \
|
|
213
|
+
--data-urlencode 'body=please reply with anything'
|
|
214
|
+
|
|
215
|
+
info "wait for daemon to process the issue + spawn opencode"
|
|
216
|
+
# daemon polls every few seconds; opencode run takes a few seconds against
|
|
217
|
+
# fake LLM. Give it generous room.
|
|
218
|
+
for i in $(seq 1 60); do
|
|
219
|
+
ACTIVE=$(curl -sS "http://127.0.0.1:$DAEMON_PORT/api/status" | jq -r '.issues // 0')
|
|
220
|
+
if [[ "$ACTIVE" -ge 1 ]]; then
|
|
221
|
+
pass "daemon registered issue after ${i} seconds (issues=$ACTIVE)"
|
|
222
|
+
break
|
|
223
|
+
fi
|
|
224
|
+
sleep 1
|
|
225
|
+
[[ $i -eq 60 ]] && fail "daemon did not register issue"
|
|
226
|
+
done
|
|
227
|
+
|
|
228
|
+
info "wait for opencode to write a session"
|
|
229
|
+
OPENCODE_DB="$HOME/.local/share/opencode/opencode.db"
|
|
230
|
+
# Query opencode.db via bun (bun:sqlite ships with the runtime, no extra
|
|
231
|
+
# dependency to apt install). One-line JS keeps the bash flow readable.
|
|
232
|
+
sql_query() {
|
|
233
|
+
bun -e "const db=require('bun:sqlite');const d=new db.Database('$1',{readonly:true,create:false});try{process.stdout.write(JSON.stringify(d.prepare(\`$2\`).all()))}catch(e){process.stdout.write('')}" 2>/dev/null
|
|
234
|
+
}
|
|
235
|
+
SESSION_ID=""
|
|
236
|
+
# 180s window: opencode spawn + fake-LLM roundtrip + write to DB. The
|
|
237
|
+
# daemon's poller picks up the issue within ~30s, then opencode takes
|
|
238
|
+
# 5-30s depending on warm-up state.
|
|
239
|
+
for i in $(seq 1 180); do
|
|
240
|
+
ROWS=$(sql_query "$OPENCODE_DB" "SELECT id FROM session ORDER BY time_updated DESC LIMIT 1;")
|
|
241
|
+
SESSION_ID=$(echo "$ROWS" | jq -r '.[0].id // empty' 2>/dev/null || echo "")
|
|
242
|
+
if [[ -n "$SESSION_ID" ]]; then
|
|
243
|
+
pass "session created: $SESSION_ID (after ${i}s)"
|
|
244
|
+
break
|
|
245
|
+
fi
|
|
246
|
+
sleep 1
|
|
247
|
+
[[ $i -eq 180 ]] && {
|
|
248
|
+
echo "--- daemon.log (last 40) ---"
|
|
249
|
+
tail -40 "$DATA_DIR/run/daemon.log" 2>/dev/null || echo "(no daemon.log)"
|
|
250
|
+
echo "--- fake-llm log (last 10) ---"
|
|
251
|
+
tail -10 /tmp/fake-llm.log
|
|
252
|
+
fail "opencode did not write a session";
|
|
253
|
+
}
|
|
254
|
+
done
|
|
255
|
+
|
|
256
|
+
info "wait for session to have assistant content (fake LLM reply)"
|
|
257
|
+
for i in $(seq 1 30); do
|
|
258
|
+
ROWS=$(sql_query "$OPENCODE_DB" "SELECT COUNT(*) AS n FROM message WHERE session_id='${SESSION_ID}';")
|
|
259
|
+
MSG_COUNT=$(echo "$ROWS" | jq -r '.[0].n // 0' 2>/dev/null || echo 0)
|
|
260
|
+
if [[ "$MSG_COUNT" -ge 2 ]]; then
|
|
261
|
+
pass "session has $MSG_COUNT messages (user + assistant)"
|
|
262
|
+
break
|
|
263
|
+
fi
|
|
264
|
+
sleep 1
|
|
265
|
+
[[ $i -eq 30 ]] && fail "session never got assistant reply (msgs=$MSG_COUNT)";
|
|
266
|
+
done
|
|
267
|
+
|
|
268
|
+
info "verify session is browsable via awork-web"
|
|
269
|
+
# awork-web's OpencodeClient reads opencode.db and calls `opencode export`.
|
|
270
|
+
# Hit the /sessions list and the specific session page. Both require the
|
|
271
|
+
# admin auth cookie (otherwise 302 → /login).
|
|
272
|
+
SESSIONS_RESP=$(curl -sS -i -H "Cookie: $AUTH_COOKIE" "http://127.0.0.1:$WORK_PORT/sessions")
|
|
273
|
+
SESSIONS_HTML=$(echo "$SESSIONS_RESP" | tail -n +5)
|
|
274
|
+
SESSIONS_STATUS=$(echo "$SESSIONS_RESP" | head -1)
|
|
275
|
+
if ! echo "$SESSIONS_HTML" | grep -q "$SESSION_ID"; then
|
|
276
|
+
echo "--- HTTP status: $SESSIONS_STATUS ---"
|
|
277
|
+
echo "--- /sessions response (first 2000 chars of body) ---"
|
|
278
|
+
echo "$SESSIONS_HTML" | head -c 2000
|
|
279
|
+
echo
|
|
280
|
+
echo "--- sessions actually in opencode.db ---"
|
|
281
|
+
sql_query "$OPENCODE_DB" "SELECT id, time_created, time_archived FROM session ORDER BY time_created DESC LIMIT 5;" | jq . 2>/dev/null
|
|
282
|
+
echo "--- ework-web access log (last 20) ---"
|
|
283
|
+
tail -20 "$DATA_DIR/run/web-access.log" 2>/dev/null || echo "(no access log)"
|
|
284
|
+
fail "/sessions list does not contain the new session ID"
|
|
285
|
+
fi
|
|
286
|
+
pass "/sessions lists the new session"
|
|
287
|
+
|
|
288
|
+
SESSION_PAGE_HTML=$(curl -sS -H "Cookie: $AUTH_COOKIE" "http://127.0.0.1:$WORK_PORT/sessions/$SESSION_ID")
|
|
289
|
+
# Look for any marker that the fake LLM reply rendered. The reply body always
|
|
290
|
+
# starts with "E2E fake-LLM reply" so we grep for that substring.
|
|
291
|
+
echo "$SESSION_PAGE_HTML" | grep -q "E2E fake-LLM" \
|
|
292
|
+
|| fail "session page does not contain fake-LLM reply text"
|
|
293
|
+
pass "session page renders fake-LLM reply content"
|
|
294
|
+
|
|
295
|
+
info "drive headless browser through the UI"
|
|
296
|
+
# bun (not tsx) because the script imports `bun:sqlite` to peek at opencode.db.
|
|
297
|
+
cp /host-test/browser-flow.ts /tmp/test-runner/
|
|
298
|
+
WORK_PORT="$WORK_PORT" \
|
|
299
|
+
WORK_DATA_DIR="$DATA_DIR" \
|
|
300
|
+
OPENCODE_DB="$OPENCODE_DB" \
|
|
301
|
+
HEADLESS=1 \
|
|
302
|
+
bun /tmp/test-runner/browser-flow.ts
|
|
303
|
+
pass "browser flow test PASSED"
|
|
304
|
+
|
|
305
|
+
info "fake LLM log tail"
|
|
306
|
+
tail -10 /tmp/fake-llm.log
|
|
307
|
+
|
|
308
|
+
echo
|
|
309
|
+
echo "====================================================="
|
|
310
|
+
echo "BROWSER E2E PASSED"
|
|
311
|
+
echo "====================================================="
|
|
312
|
+
echo " session ID: $SESSION_ID"
|
|
313
|
+
echo " session URL: http://127.0.0.1:$WORK_PORT/sessions/$SESSION_ID"
|
|
314
|
+
echo " fake LLM log: /tmp/fake-llm.log"
|
|
315
|
+
echo " data dir: $DATA_DIR"
|
|
316
|
+
|
|
317
|
+
# Kill fake LLM (container exit also does this, but explicit is clearer).
|
|
318
|
+
kill "$FAKE_LLM_PID" 2>/dev/null || true
|
|
319
|
+
EOSCRIPT
|
|
320
|
+
|
|
321
|
+
echo
|
|
322
|
+
echo "${c_grn}BROWSER E2E COMPLETE${c_rst}"
|
package/scripts/e2e-install.sh
CHANGED
|
@@ -1,19 +1,24 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
|
-
# End-to-end install test.
|
|
2
|
+
# End-to-end install + lifecycle + subcommand test.
|
|
3
3
|
#
|
|
4
|
-
# Spawns a clean debian container (via Dockerfile.regression) and
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
4
|
+
# Spawns a clean debian container (via Dockerfile.regression) and exercises:
|
|
5
|
+
# 1. Install paths: --allow-root + re-install idempotency
|
|
6
|
+
# 2. Service startup: web /login + daemon /api/status
|
|
7
|
+
# 3. Subcommand smoke: status, env, config list/get/set, logs
|
|
8
|
+
# 4. Service lifecycle: stop / start / restart (PIDs change)
|
|
9
|
+
# 5. Issue flow: create project → create issue → daemon receives webhook
|
|
10
|
+
# 6. Cleanup: uninstall removes pidfiles + kills services
|
|
8
11
|
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
12
|
+
# Each block also documents which past bug the assertion prevents:
|
|
13
|
+
# v0.2.4 plugin key → issue-create → daemon receives webhook
|
|
14
|
+
# v0.2.5 daemon binary → /api/status probe (was printing help + exiting 0)
|
|
15
|
+
# v0.2.6 secret drift → "no invalid signature in daemon.log"
|
|
16
|
+
# v0.2.6 access log → "no EACCES in web.log"
|
|
17
|
+
# v0.2.7+ regression → all subcommands exit 0 with expected markers
|
|
13
18
|
#
|
|
14
19
|
# Usage: ./scripts/e2e-install.sh [container-runtime] [npm-tag]
|
|
15
20
|
# container-runtime: docker (default) | podman
|
|
16
|
-
# npm-tag: latest (default) | 0.2.
|
|
21
|
+
# npm-tag: latest (default) | 0.2.7 | file:.. (for local builds)
|
|
17
22
|
|
|
18
23
|
set -euo pipefail
|
|
19
24
|
|
|
@@ -38,9 +43,12 @@ fi
|
|
|
38
43
|
info "Using image: $IMAGE (testing npm tag: $NPM_TAG)"
|
|
39
44
|
|
|
40
45
|
# Unique ports so we don't collide with a host-side ework stack when run with
|
|
41
|
-
# --network host.
|
|
42
|
-
WORK_PORT
|
|
43
|
-
|
|
46
|
+
# --network host. OVERRIDE via env only if you know what you're doing — by
|
|
47
|
+
# default we IGNORE any inherited WORK_PORT/DAEMON_PORT from the host shell
|
|
48
|
+
# (a host running ework-daemon on :3100 would otherwise leak into the test
|
|
49
|
+
# and break assertions about which port the container's daemon binds to).
|
|
50
|
+
WORK_PORT="14002"
|
|
51
|
+
DAEMON_PORT="14101"
|
|
44
52
|
|
|
45
53
|
# opencode binary is host-specific (path may differ across machines). Mount
|
|
46
54
|
# readonly so the container preflight sees it on PATH. Override via env.
|
|
@@ -69,14 +77,96 @@ DAEMON_PORT="${DAEMON_PORT:-14101}"
|
|
|
69
77
|
NPM_TAG="${NPM_TAG:-latest}"
|
|
70
78
|
DATA_DIR=/tmp/aio-e2e
|
|
71
79
|
|
|
80
|
+
# Defend against host env leak: the OUTER script always passes -e WORK_PORT
|
|
81
|
+
# and -e DAEMON_PORT, but if the user invokes this heredoc directly from a
|
|
82
|
+
# shell that has DAEMON_PORT set (e.g. their production daemon on :3100),
|
|
83
|
+
# the assertions below will fail mysteriously. The values above are the
|
|
84
|
+
# authoritative defaults; only override via the outer script's env passthrough.
|
|
85
|
+
|
|
86
|
+
# -----------------------------------------------------------------------------
|
|
87
|
+
# Helpers
|
|
88
|
+
# -----------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
# Poll a URL until it returns < 500, or fail after N half-seconds.
|
|
91
|
+
wait_for_url() {
|
|
92
|
+
local url="$1" name="$2" half_seconds="${3:-60}"
|
|
93
|
+
for i in $(seq 1 "$half_seconds"); do
|
|
94
|
+
if curl -sf -o /dev/null "$url"; then
|
|
95
|
+
pass "$name responds (after ${i} half-seconds)"
|
|
96
|
+
return 0
|
|
97
|
+
fi
|
|
98
|
+
sleep 0.5
|
|
99
|
+
done
|
|
100
|
+
return 1
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
# Read .env key value (last match wins — install.ts overwrites keys in place).
|
|
104
|
+
env_val() { grep "^$1=" "$2" | tail -1 | cut -d= -f2-; }
|
|
105
|
+
|
|
106
|
+
# Build admin auth cookie from web .env.
|
|
107
|
+
build_auth_cookie() {
|
|
108
|
+
local web_env="$DATA_DIR/ework-web/.env"
|
|
109
|
+
local token secret sig
|
|
110
|
+
token=$(env_val WORK_TOKEN "$web_env")
|
|
111
|
+
secret=$(env_val WORK_COOKIE_SECRET "$web_env")
|
|
112
|
+
sig=$(printf '%s' "$token" \
|
|
113
|
+
| openssl dgst -sha256 -hmac "$secret" -binary \
|
|
114
|
+
| base64 | tr '+/' '-_' | tr -d '=')
|
|
115
|
+
echo "ework_auth=${token}.${sig}"
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
# Read PID from pidfile (or echo empty if missing/empty).
|
|
119
|
+
pid_of() {
|
|
120
|
+
local pf="$DATA_DIR/run/$1.pid"
|
|
121
|
+
[[ -f "$pf" ]] || { echo ""; return; }
|
|
122
|
+
echo "$(cat "$pf")"
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
# Is a PID alive?
|
|
126
|
+
pid_alive() { kill -0 "$1" >/dev/null 2>&1; }
|
|
127
|
+
|
|
128
|
+
# Wait for a service's pidfile PID to differ from the given "old" PID.
|
|
129
|
+
wait_for_new_pid() {
|
|
130
|
+
local svc="$1" old_pid="$2" half_seconds="${3:-40}"
|
|
131
|
+
for i in $(seq 1 "$half_seconds"); do
|
|
132
|
+
local new_pid
|
|
133
|
+
new_pid=$(pid_of "$svc")
|
|
134
|
+
if [[ -n "$new_pid" && "$new_pid" != "$old_pid" ]] && pid_alive "$new_pid"; then
|
|
135
|
+
pass "$svc pid changed ($old_pid → $new_pid) after ${i} half-seconds"
|
|
136
|
+
return 0
|
|
137
|
+
fi
|
|
138
|
+
sleep 0.5
|
|
139
|
+
done
|
|
140
|
+
return 1
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
# Wait for a service's pidfile to vanish (or PID to die).
|
|
144
|
+
wait_for_no_pid() {
|
|
145
|
+
local svc="$1" half_seconds="${2:-30}"
|
|
146
|
+
for i in $(seq 1 "$half_seconds"); do
|
|
147
|
+
local pid
|
|
148
|
+
pid=$(pid_of "$svc")
|
|
149
|
+
if [[ -z "$pid" ]] || ! pid_alive "$pid"; then
|
|
150
|
+
pass "$svc stopped (no live pid) after ${i} half-seconds"
|
|
151
|
+
return 0
|
|
152
|
+
fi
|
|
153
|
+
sleep 0.5
|
|
154
|
+
done
|
|
155
|
+
return 1
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
echo "====================================================="
|
|
159
|
+
echo "Phase 0: clean state + npm install"
|
|
160
|
+
echo "====================================================="
|
|
72
161
|
info "clean state"
|
|
73
162
|
rm -rf "$DATA_DIR"
|
|
74
163
|
mkdir -p "$DATA_DIR"
|
|
75
164
|
|
|
76
165
|
info "opencode binary on PATH"
|
|
77
166
|
opencode --version | head -1
|
|
167
|
+
pass "opencode present"
|
|
78
168
|
|
|
79
|
-
info "npm install -g ework-web
|
|
169
|
+
info "npm install -g ework-web@latest ework-daemon@latest opencode-ework@latest ework-aio@${NPM_TAG}"
|
|
80
170
|
# Note: ework-web and ework-daemon aren't version-locked to ework-aio. We
|
|
81
171
|
# always install latest of the services and only parameterize ework-aio so
|
|
82
172
|
# we can E2E-test a PR branch against published services.
|
|
@@ -85,13 +175,21 @@ for pkg in ework-web ework-daemon opencode-ework; do
|
|
|
85
175
|
done
|
|
86
176
|
npm install -g "ework-aio@${NPM_TAG}" 2>&1 | tail -2
|
|
87
177
|
|
|
178
|
+
# -----------------------------------------------------------------------------
|
|
179
|
+
# Phase 1: install paths
|
|
180
|
+
# -----------------------------------------------------------------------------
|
|
181
|
+
echo
|
|
182
|
+
echo "====================================================="
|
|
183
|
+
echo "Phase 1: install paths"
|
|
184
|
+
echo "====================================================="
|
|
185
|
+
|
|
88
186
|
info "v0.2.5 regression: all 4 bins on PATH"
|
|
89
187
|
for b in ework-aio ework-web ework-daemon ework-daemon-server; do
|
|
90
188
|
command -v "$b" >/dev/null 2>&1 || fail "$b not on PATH"
|
|
91
189
|
pass "$b -> $(command -v "$b")"
|
|
92
190
|
done
|
|
93
191
|
|
|
94
|
-
info "
|
|
192
|
+
info "install path A: --allow-root + custom ports + --yes"
|
|
95
193
|
# --allow-root: container runs as root; refusing would test an unrealistic
|
|
96
194
|
# path. The installer's data still lands in /tmp/aio-e2e via --data-dir.
|
|
97
195
|
ework-aio install \
|
|
@@ -102,11 +200,33 @@ ework-aio install \
|
|
|
102
200
|
--bot-name e2e-bot \
|
|
103
201
|
--yes 2>&1 | tee /tmp/install.log
|
|
104
202
|
|
|
105
|
-
pass "install exited 0"
|
|
203
|
+
pass "install (path A) exited 0"
|
|
204
|
+
|
|
205
|
+
info "install path B: re-install over existing data dir (idempotency)"
|
|
206
|
+
# Re-running install should not crash on existing .env / pidfiles / webhooks.
|
|
207
|
+
# This catches bugs where install.ts isn't idempotent (e.g. UNIQUE constraint
|
|
208
|
+
# on second bot token, or "file exists" on second .env write).
|
|
209
|
+
ework-aio install \
|
|
210
|
+
--allow-root \
|
|
211
|
+
--data-dir "$DATA_DIR" \
|
|
212
|
+
--port "$WORK_PORT" \
|
|
213
|
+
--daemon-port "$DAEMON_PORT" \
|
|
214
|
+
--bot-name e2e-bot \
|
|
215
|
+
--yes 2>&1 | tee /tmp/install2.log | tail -10
|
|
216
|
+
|
|
217
|
+
pass "install (path B: idempotent re-install) exited 0"
|
|
218
|
+
|
|
219
|
+
# -----------------------------------------------------------------------------
|
|
220
|
+
# Phase 2: post-install assertions (regression suite for v0.2.5 / v0.2.6)
|
|
221
|
+
# -----------------------------------------------------------------------------
|
|
222
|
+
echo
|
|
223
|
+
echo "====================================================="
|
|
224
|
+
echo "Phase 2: post-install env / regression assertions"
|
|
225
|
+
echo "====================================================="
|
|
106
226
|
|
|
107
227
|
info "v0.2.6 regression: webhook secrets match across web and daemon .env"
|
|
108
|
-
WEB_SEC=$(
|
|
109
|
-
DAE_SEC=$(
|
|
228
|
+
WEB_SEC=$(env_val WORK_DAEMON_WEBHOOK_SECRET "$DATA_DIR/ework-web/.env")
|
|
229
|
+
DAE_SEC=$(env_val GITEA_WEBHOOK_SECRET "$DATA_DIR/ework-daemon/.env")
|
|
110
230
|
[[ -n "$WEB_SEC" ]] || fail "WORK_DAEMON_WEBHOOK_SECRET empty in web .env"
|
|
111
231
|
[[ -n "$DAE_SEC" ]] || fail "GITEA_WEBHOOK_SECRET empty in daemon .env"
|
|
112
232
|
[[ "$WEB_SEC" == "$DAE_SEC" ]] \
|
|
@@ -114,31 +234,19 @@ DAE_SEC=$(grep ^GITEA_WEBHOOK_SECRET= "$DATA_DIR/ework-daemon/.env" | cut -d= -f
|
|
|
114
234
|
pass "secrets match (${#WEB_SEC} chars)"
|
|
115
235
|
|
|
116
236
|
info "v0.2.6 regression: WORK_ACCESS_LOG set, not defaulting to /tmp/ework-access.log"
|
|
117
|
-
ACCESS_LOG_VAL=$(
|
|
237
|
+
ACCESS_LOG_VAL=$(env_val WORK_ACCESS_LOG "$DATA_DIR/ework-web/.env")
|
|
118
238
|
[[ -n "$ACCESS_LOG_VAL" ]] || fail "WORK_ACCESS_LOG missing from web .env"
|
|
119
239
|
[[ "$ACCESS_LOG_VAL" != "/tmp/ework-access.log" ]] \
|
|
120
240
|
|| fail "WORK_ACCESS_LOG still defaults to /tmp/ework-access.log"
|
|
121
241
|
pass "WORK_ACCESS_LOG=$ACCESS_LOG_VAL"
|
|
122
242
|
|
|
123
243
|
info "wait for ework-web /login"
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
pass "/login responds (after ${i} half-seconds)"
|
|
127
|
-
break
|
|
128
|
-
fi
|
|
129
|
-
sleep 0.5
|
|
130
|
-
[[ $i -eq 60 ]] && { tail -30 "$DATA_DIR/run/web.log" 2>/dev/null; fail "ework-web did not come up"; }
|
|
131
|
-
done
|
|
244
|
+
wait_for_url "http://127.0.0.1:$WORK_PORT/login" "/login" 60 \
|
|
245
|
+
|| { tail -30 "$DATA_DIR/run/web.log" 2>/dev/null; fail "ework-web did not come up"; }
|
|
132
246
|
|
|
133
247
|
info "wait for daemon /api/status"
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
pass "/api/status responds (after ${i} half-seconds)"
|
|
137
|
-
break
|
|
138
|
-
fi
|
|
139
|
-
sleep 0.5
|
|
140
|
-
[[ $i -eq 60 ]] && { tail -30 "$DATA_DIR/run/daemon.log" 2>/dev/null; fail "daemon did not come up"; }
|
|
141
|
-
done
|
|
248
|
+
wait_for_url "http://127.0.0.1:$DAEMON_PORT/api/status" "/api/status" 60 \
|
|
249
|
+
|| { tail -30 "$DATA_DIR/run/daemon.log" 2>/dev/null; fail "daemon did not come up"; }
|
|
142
250
|
|
|
143
251
|
info "v0.2.6 regression: no EACCES in web.log"
|
|
144
252
|
sleep 1
|
|
@@ -155,13 +263,170 @@ if grep -q "invalid signature" "$DATA_DIR/run/daemon.log" 2>/dev/null; then
|
|
|
155
263
|
fi
|
|
156
264
|
pass "no invalid signature yet"
|
|
157
265
|
|
|
266
|
+
# -----------------------------------------------------------------------------
|
|
267
|
+
# Phase 3: subcommand smoke tests
|
|
268
|
+
# -----------------------------------------------------------------------------
|
|
269
|
+
echo
|
|
270
|
+
echo "====================================================="
|
|
271
|
+
echo "Phase 3: subcommand smoke tests"
|
|
272
|
+
echo "====================================================="
|
|
273
|
+
|
|
274
|
+
info "status: lists both services, both running, both listening"
|
|
275
|
+
STATUS_OUT=$(ework-aio status --data-dir "$DATA_DIR" 2>&1)
|
|
276
|
+
echo "$STATUS_OUT"
|
|
277
|
+
echo "$STATUS_OUT" | grep -q "ework-web" || fail "status missing ework-web line"
|
|
278
|
+
echo "$STATUS_OUT" | grep -q "ework-daemon" || fail "status missing ework-daemon line"
|
|
279
|
+
echo "$STATUS_OUT" | grep "ework-web" | grep -q "✓ running" || fail "web not reported running"
|
|
280
|
+
echo "$STATUS_OUT" | grep "ework-daemon" | grep -q "✓ running" || {
|
|
281
|
+
echo "$STATUS_OUT" | grep "ework-daemon"
|
|
282
|
+
fail "daemon not reported running (see status output above)"
|
|
283
|
+
}
|
|
284
|
+
pass "status lists both services as running"
|
|
285
|
+
|
|
286
|
+
info "env: prints key paths"
|
|
287
|
+
ENV_OUT=$(ework-aio env --data-dir "$DATA_DIR" 2>&1)
|
|
288
|
+
echo "$ENV_OUT"
|
|
289
|
+
echo "$ENV_OUT" | grep -q "data dir" || fail "env missing 'data dir'"
|
|
290
|
+
echo "$ENV_OUT" | grep -q "web env" || fail "env missing 'web env'"
|
|
291
|
+
echo "$ENV_OUT" | grep -q "daemon env" || fail "env missing 'daemon env'"
|
|
292
|
+
echo "$ENV_OUT" | grep -q "bot token" || fail "env missing 'bot token'"
|
|
293
|
+
# env.ts globs for bot-token* (install.ts writes bot-token.<botName> when
|
|
294
|
+
# --bot-name isn't default). With --bot-name e2e-bot in this run, the env
|
|
295
|
+
# output MUST list the suffixed file.
|
|
296
|
+
echo "$ENV_OUT" | grep -q "bot-token.e2e-bot" \
|
|
297
|
+
|| fail "env missing bot-token.e2e-bot (install wrote it but env didn't list it)"
|
|
298
|
+
# Sanity: the printed .env paths must actually exist.
|
|
299
|
+
for p in "$DATA_DIR/ework-web/.env" "$DATA_DIR/ework-daemon/.env"; do
|
|
300
|
+
[[ -f "$p" ]] || fail "env printed non-existent path: $p"
|
|
301
|
+
done
|
|
302
|
+
pass "env prints valid paths (including bot-token suffix)"
|
|
303
|
+
|
|
304
|
+
info "config list: shows settable keys"
|
|
305
|
+
CONFIG_LIST_OUT=$(ework-aio config list --data-dir "$DATA_DIR" 2>&1)
|
|
306
|
+
echo "$CONFIG_LIST_OUT"
|
|
307
|
+
echo "$CONFIG_LIST_OUT" | grep -q "WORK_PORT" || fail "config list missing WORK_PORT"
|
|
308
|
+
echo "$CONFIG_LIST_OUT" | grep -q "DAEMON_PORT" || fail "config list missing DAEMON_PORT"
|
|
309
|
+
# Secrets must NOT appear (enforced by SECRET_ENV_VARS exclusion).
|
|
310
|
+
echo "$CONFIG_LIST_OUT" | grep -q "WORK_TOKEN" \
|
|
311
|
+
&& fail "config list leaks WORK_TOKEN (should be excluded)" || true
|
|
312
|
+
pass "config list shows settable keys, excludes secrets"
|
|
313
|
+
|
|
314
|
+
info "config get WORK_PORT: prints the port"
|
|
315
|
+
CONFIG_GET_OUT=$(ework-aio config get WORK_PORT --data-dir "$DATA_DIR" 2>&1)
|
|
316
|
+
echo "$CONFIG_GET_OUT"
|
|
317
|
+
[[ "$CONFIG_GET_OUT" == "$WORK_PORT" ]] \
|
|
318
|
+
|| fail "config get WORK_PORT returned '$CONFIG_GET_OUT', expected '$WORK_PORT'"
|
|
319
|
+
pass "config get WORK_PORT = $WORK_PORT"
|
|
320
|
+
|
|
321
|
+
info "config get on a secret key: rejects"
|
|
322
|
+
if ework-aio config get WORK_TOKEN --data-dir "$DATA_DIR" 2>/dev/null; then
|
|
323
|
+
fail "config get WORK_TOKEN should have been rejected"
|
|
324
|
+
fi
|
|
325
|
+
pass "config get on secret rejected"
|
|
326
|
+
|
|
327
|
+
info "config set WORK_TTS_SPEED 1.5 --no-restart: writes .env, no restart"
|
|
328
|
+
ework-aio config set WORK_TTS_SPEED 1.5 --no-restart --data-dir "$DATA_DIR" 2>&1 | tail -5
|
|
329
|
+
TTS_VAL=$(env_val WORK_TTS_SPEED "$DATA_DIR/ework-web/.env")
|
|
330
|
+
[[ "$TTS_VAL" == "1.5" ]] || fail "WORK_TTS_SPEED not persisted in .env (got '$TTS_VAL')"
|
|
331
|
+
pass "config set persisted WORK_TTS_SPEED=1.5"
|
|
332
|
+
|
|
333
|
+
info "config set with newline value: rejected (env-injection guard)"
|
|
334
|
+
# Pre-v0.2.6, a value like "alice\nWORK_TOKEN=evil" would inject a new key
|
|
335
|
+
# into .env. cli-dispatch.test.ts:138 covers this in unit tests; we cover
|
|
336
|
+
# it here too because the integration surface (bash quoting) differs.
|
|
337
|
+
if ework-aio config set WORK_OPERATOR_LOGIN $'alice\nWORK_TOKEN=evil' \
|
|
338
|
+
--no-restart --data-dir "$DATA_DIR" 2>/dev/null; then
|
|
339
|
+
fail "config set accepted a newline value (env-injection regression)"
|
|
340
|
+
fi
|
|
341
|
+
pass "config set rejects newline value"
|
|
342
|
+
# Confirm the injection didn't actually happen.
|
|
343
|
+
if grep -q "^WORK_TOKEN=evil" "$DATA_DIR/ework-web/.env"; then
|
|
344
|
+
fail "WORK_TOKEN=evil was injected into .env despite rejection"
|
|
345
|
+
fi
|
|
346
|
+
pass "no env injection occurred"
|
|
347
|
+
|
|
348
|
+
info "logs web: starts tailing, exits on SIGTERM"
|
|
349
|
+
# logs.ts uses fs.watchFile and never resolves — must be killed.
|
|
350
|
+
# Use `timeout` to send SIGTERM after 1s; expect exit 124 (timeout's SIGTERM
|
|
351
|
+
# exit) or 143 (SIGTERM). The point: it started without error (no
|
|
352
|
+
# "log file not found") and produced output.
|
|
353
|
+
set +e
|
|
354
|
+
LOGS_OUT=$(timeout -s TERM 1 ework-aio logs web --data-dir "$DATA_DIR" 2>&1)
|
|
355
|
+
LOGS_RC=$?
|
|
356
|
+
set -e
|
|
357
|
+
[[ $LOGS_RC -eq 124 || $LOGS_RC -eq 143 ]] \
|
|
358
|
+
|| fail "logs web exited $LOGS_RC (expected 124/143 from SIGTERM)"
|
|
359
|
+
# Output should contain SOMETHING (the "tailing" line + recent log content).
|
|
360
|
+
[[ -n "$LOGS_OUT" ]] || fail "logs web produced no output"
|
|
361
|
+
pass "logs web tail started, exited cleanly on SIGTERM"
|
|
362
|
+
|
|
363
|
+
# -----------------------------------------------------------------------------
|
|
364
|
+
# Phase 4: service lifecycle (stop / start / restart)
|
|
365
|
+
# -----------------------------------------------------------------------------
|
|
366
|
+
echo
|
|
367
|
+
echo "====================================================="
|
|
368
|
+
echo "Phase 4: service lifecycle"
|
|
369
|
+
echo "====================================================="
|
|
370
|
+
|
|
371
|
+
WEB_PID_BEFORE=$(pid_of web)
|
|
372
|
+
DAEMON_PID_BEFORE=$(pid_of daemon)
|
|
373
|
+
[[ -n "$WEB_PID_BEFORE" ]] || fail "web pid empty before stop"
|
|
374
|
+
[[ -n "$DAEMON_PID_BEFORE" ]] || fail "daemon pid empty before stop"
|
|
375
|
+
pass "before stop: web pid=$WEB_PID_BEFORE, daemon pid=$DAEMON_PID_BEFORE"
|
|
376
|
+
|
|
377
|
+
info "stop both services"
|
|
378
|
+
ework-aio stop --data-dir "$DATA_DIR" 2>&1 | tail -5
|
|
379
|
+
wait_for_no_pid web || fail "web pid still alive after stop"
|
|
380
|
+
wait_for_no_pid daemon || fail "daemon pid still alive after stop"
|
|
381
|
+
|
|
382
|
+
info "confirm /login stops responding"
|
|
383
|
+
sleep 1
|
|
384
|
+
if curl -sf -o /dev/null "http://127.0.0.1:$WORK_PORT/login" 2>/dev/null; then
|
|
385
|
+
fail "/login still responds after stop — web did not actually stop"
|
|
386
|
+
fi
|
|
387
|
+
pass "/login stopped responding"
|
|
388
|
+
|
|
389
|
+
info "start both services"
|
|
390
|
+
ework-aio start --data-dir "$DATA_DIR" 2>&1 | tail -5
|
|
391
|
+
# Wait for both services to bind their ports again.
|
|
392
|
+
wait_for_url "http://127.0.0.1:$WORK_PORT/login" "/login after start" 60 \
|
|
393
|
+
|| { tail -30 "$DATA_DIR/run/web.log"; fail "web did not come up after start"; }
|
|
394
|
+
wait_for_url "http://127.0.0.1:$DAEMON_PORT/api/status" "/api/status after start" 60 \
|
|
395
|
+
|| { tail -30 "$DATA_DIR/run/daemon.log"; fail "daemon did not come up after start"; }
|
|
396
|
+
|
|
397
|
+
WEB_PID_AFTER_START=$(pid_of web)
|
|
398
|
+
DAEMON_PID_AFTER_START=$(pid_of daemon)
|
|
399
|
+
[[ "$WEB_PID_AFTER_START" != "$WEB_PID_BEFORE" ]] \
|
|
400
|
+
|| fail "web pid unchanged across stop+start ($WEB_PID_BEFORE)"
|
|
401
|
+
[[ "$DAEMON_PID_AFTER_START" != "$DAEMON_PID_BEFORE" ]] \
|
|
402
|
+
|| fail "daemon pid unchanged across stop+start ($DAEMON_PID_BEFORE)"
|
|
403
|
+
pass "PIDs changed after stop+start (web $WEB_PID_BEFORE→$WEB_PID_AFTER_START)"
|
|
404
|
+
|
|
405
|
+
info "restart both services (config restart both)"
|
|
406
|
+
ework-aio config restart both --data-dir "$DATA_DIR" 2>&1 | tail -5
|
|
407
|
+
wait_for_new_pid web "$WEB_PID_AFTER_START" 60 \
|
|
408
|
+
|| fail "web pid did not change after restart"
|
|
409
|
+
wait_for_new_pid daemon "$DAEMON_PID_AFTER_START" 60 \
|
|
410
|
+
|| fail "daemon pid did not change after restart"
|
|
411
|
+
|
|
412
|
+
info "confirm services respond after restart"
|
|
413
|
+
wait_for_url "http://127.0.0.1:$WORK_PORT/login" "/login after restart" 60 \
|
|
414
|
+
|| fail "web did not come up after restart"
|
|
415
|
+
wait_for_url "http://127.0.0.1:$DAEMON_PORT/api/status" "/api/status after restart" 60 \
|
|
416
|
+
|| fail "daemon did not come up after restart"
|
|
417
|
+
|
|
418
|
+
# -----------------------------------------------------------------------------
|
|
419
|
+
# Phase 5: issue flow (the actual product path)
|
|
420
|
+
# -----------------------------------------------------------------------------
|
|
421
|
+
echo
|
|
422
|
+
echo "====================================================="
|
|
423
|
+
echo "Phase 5: issue flow (create project → create issue → webhook)"
|
|
424
|
+
echo "====================================================="
|
|
425
|
+
|
|
158
426
|
info "build admin auth cookie"
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
| openssl dgst -sha256 -hmac "$WORK_COOKIE_SECRET" -binary \
|
|
163
|
-
| base64 | tr '+/' '-_' | tr -d '=')
|
|
164
|
-
AUTH_COOKIE="ework_auth=${WORK_TOKEN}.${COOKIE_SIG}"
|
|
427
|
+
AUTH_COOKIE=$(build_auth_cookie)
|
|
428
|
+
[[ -n "$AUTH_COOKIE" ]] || fail "auth cookie empty"
|
|
429
|
+
pass "auth cookie built"
|
|
165
430
|
|
|
166
431
|
info "create test project (triggers autoWire webhook registration)"
|
|
167
432
|
PROJ_CODE=$(curl -sS -o /dev/null -w '%{http_code}' -X POST \
|
|
@@ -200,8 +465,41 @@ ACTIVE=$(curl -sS "http://127.0.0.1:$DAEMON_PORT/api/status" | jq -r '.issues //
|
|
|
200
465
|
|| fail "daemon did not register the issue (issues=$ACTIVE)"
|
|
201
466
|
pass "daemon registered issue (issues=$ACTIVE)"
|
|
202
467
|
|
|
468
|
+
# -----------------------------------------------------------------------------
|
|
469
|
+
# Phase 6: uninstall
|
|
470
|
+
# -----------------------------------------------------------------------------
|
|
471
|
+
echo
|
|
472
|
+
echo "====================================================="
|
|
473
|
+
echo "Phase 6: uninstall"
|
|
474
|
+
echo "====================================================="
|
|
475
|
+
|
|
476
|
+
info "ework-aio uninstall: stops services + removes pidfiles"
|
|
477
|
+
ework-aio uninstall --data-dir "$DATA_DIR" 2>&1 | tail -10
|
|
478
|
+
|
|
479
|
+
wait_for_no_pid web 15 || fail "web pid still alive after uninstall"
|
|
480
|
+
wait_for_no_pid daemon 15 || fail "daemon pid still alive after uninstall"
|
|
481
|
+
|
|
482
|
+
# pidfiles should be gone OR empty.
|
|
483
|
+
for svc in web daemon; do
|
|
484
|
+
pf="$DATA_DIR/run/$svc.pid"
|
|
485
|
+
if [[ -f "$pf" && -s "$pf" ]]; then
|
|
486
|
+
fail "$svc.pid still exists and non-empty after uninstall: $(cat "$pf")"
|
|
487
|
+
fi
|
|
488
|
+
done
|
|
489
|
+
pass "pidfiles cleared"
|
|
490
|
+
|
|
491
|
+
info "uninstall preserves data dir"
|
|
492
|
+
# uninstall.ts:62 documents "services removed. data preserved at <dir>".
|
|
493
|
+
# Verify the .env files survive so a re-install picks up the same token.
|
|
494
|
+
[[ -f "$DATA_DIR/ework-web/.env" ]] || fail "web .env deleted by uninstall"
|
|
495
|
+
[[ -f "$DATA_DIR/ework-daemon/.env" ]] || fail "daemon .env deleted by uninstall"
|
|
496
|
+
pass "data preserved (both .env files intact)"
|
|
497
|
+
|
|
498
|
+
# -----------------------------------------------------------------------------
|
|
203
499
|
echo
|
|
204
|
-
echo "
|
|
500
|
+
echo "====================================================="
|
|
501
|
+
echo "E2E PASSED"
|
|
502
|
+
echo "====================================================="
|
|
205
503
|
echo " ework-aio version: $(ework-aio --version 2>/dev/null || echo unknown)"
|
|
206
504
|
echo " data dir: $DATA_DIR"
|
|
207
505
|
echo " web: http://127.0.0.1:$WORK_PORT/login"
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// Fake OpenAI-compatible LLM server for E2E tests.
|
|
3
|
+
//
|
|
4
|
+
// Listens on 127.0.0.1:PORT and responds to /v1/chat/completions with a
|
|
5
|
+
// deterministic, context-aware canned reply. Used by the docker E2E test
|
|
6
|
+
// to drive real `opencode run` against a stub LLM — opencode can't tell
|
|
7
|
+
// the difference, writes a real session to opencode.db, and awork-web can
|
|
8
|
+
// then render that session via its normal /sessions/:id route.
|
|
9
|
+
//
|
|
10
|
+
// Why not just stub the opencode binary? Because awork-web reads opencode.db
|
|
11
|
+
// directly (src/opencode.ts:98-139 listSessions) and calls `opencode export`
|
|
12
|
+
// for transcripts — stubbing the binary means re-implementing the SQLite
|
|
13
|
+
// schema, which would silently drift from real opencode. Talking to a fake
|
|
14
|
+
// LLM exercises every piece except the network call to a real model.
|
|
15
|
+
//
|
|
16
|
+
// Usage:
|
|
17
|
+
// PORT=8400 bun run scripts/fake-llm-server.ts
|
|
18
|
+
// # or just: bun run scripts/fake-llm-server.ts (defaults to 8400)
|
|
19
|
+
|
|
20
|
+
const PORT = parseInt(process.env.PORT ?? "8400", 10);
|
|
21
|
+
const HOST = process.env.HOST ?? "127.0.0.1";
|
|
22
|
+
|
|
23
|
+
const server = Bun.serve({
|
|
24
|
+
port: PORT,
|
|
25
|
+
hostname: HOST,
|
|
26
|
+
fetch(req) {
|
|
27
|
+
const url = new URL(req.url);
|
|
28
|
+
log(`${req.method} ${url.pathname}`);
|
|
29
|
+
|
|
30
|
+
if (req.method === "GET" && url.pathname === "/v1/models") {
|
|
31
|
+
return json({
|
|
32
|
+
object: "list",
|
|
33
|
+
data: [
|
|
34
|
+
{
|
|
35
|
+
id: "fake-model",
|
|
36
|
+
object: "model",
|
|
37
|
+
created: 1_700_000_000_000,
|
|
38
|
+
owned_by: "e2e-test",
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (req.method === "POST" && url.pathname === "/v1/chat/completions") {
|
|
45
|
+
return handleChatCompletion(req);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return json({ error: `not found: ${req.method} ${url.pathname}` }, 404);
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
function log(msg: string): void {
|
|
53
|
+
process.stderr.write(`[fake-llm] ${new Date().toISOString()} ${msg}\n`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function json(body: unknown, status = 200): Response {
|
|
57
|
+
return new Response(JSON.stringify(body), {
|
|
58
|
+
status,
|
|
59
|
+
headers: {
|
|
60
|
+
"content-type": "application/json",
|
|
61
|
+
"access-control-allow-origin": "*",
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function handleChatCompletion(req: Request): Promise<Response> {
|
|
67
|
+
let body: any;
|
|
68
|
+
try {
|
|
69
|
+
body = await req.json();
|
|
70
|
+
} catch (err) {
|
|
71
|
+
return json({ error: `invalid JSON: ${(err as Error).message}` }, 400);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Log the full request so we can see what opencode/@ai-sdk is asking for
|
|
75
|
+
// (stream vs non-stream, tool definitions, system prompt, etc).
|
|
76
|
+
log(` body: stream=${body?.stream} model=${body?.model} msgs=${body?.messages?.length} tools=${body?.tools?.length ?? 0}`);
|
|
77
|
+
|
|
78
|
+
const messages = Array.isArray(body?.messages) ? body.messages : [];
|
|
79
|
+
const userMsgs = messages.filter((m: any) => m?.role === "user");
|
|
80
|
+
const lastUser = userMsgs[userMsgs.length - 1];
|
|
81
|
+
const userText = typeof lastUser?.content === "string"
|
|
82
|
+
? lastUser.content
|
|
83
|
+
: Array.isArray(lastUser?.content)
|
|
84
|
+
? lastUser.content.map((p: any) => p?.text ?? "").join(" ")
|
|
85
|
+
: "";
|
|
86
|
+
|
|
87
|
+
// Deterministic, context-aware reply: echo a snippet of the user's last
|
|
88
|
+
// message so the session transcript has traceable content (not just a
|
|
89
|
+
// fixed string). Truncated so the reply stays readable in the UI.
|
|
90
|
+
const snippet = userText.slice(0, 120).replace(/\s+/g, " ").trim();
|
|
91
|
+
const reply =
|
|
92
|
+
`E2E fake-LLM reply.\n\n` +
|
|
93
|
+
`You said: "${snippet}${userText.length > 120 ? "…" : ""}"\n\n` +
|
|
94
|
+
`This is a stub response from scripts/fake-llm-server.ts. ` +
|
|
95
|
+
`The real value of this test is that opencode wrote a real session row ` +
|
|
96
|
+
`to opencode.db and awork-web can render it end-to-end.`;
|
|
97
|
+
|
|
98
|
+
const promptTokens = roughTokens(JSON.stringify(messages));
|
|
99
|
+
const completionTokens = roughTokens(reply);
|
|
100
|
+
const usage = {
|
|
101
|
+
prompt_tokens: promptTokens,
|
|
102
|
+
completion_tokens: completionTokens,
|
|
103
|
+
total_tokens: promptTokens + completionTokens,
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// opencode (@ai-sdk/openai-compatible) defaults to stream=true. Returning
|
|
107
|
+
// a non-streaming JSON response silently produces a 0-token session row
|
|
108
|
+
// with no assistant text — the request completes but the transcript is
|
|
109
|
+
// empty. Honor the stream flag and emit SSE chunks.
|
|
110
|
+
if (body?.stream === true) {
|
|
111
|
+
return streamResponse(body?.model ?? "fake-model", reply, usage);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Non-streaming path (used by curl sanity checks in tests).
|
|
115
|
+
return json({
|
|
116
|
+
id: `chatcmpl-fake-${crypto.randomUUID()}`,
|
|
117
|
+
object: "chat.completion",
|
|
118
|
+
created: Math.floor(Date.now() / 1000),
|
|
119
|
+
model: body?.model ?? "fake-model",
|
|
120
|
+
choices: [
|
|
121
|
+
{
|
|
122
|
+
index: 0,
|
|
123
|
+
message: { role: "assistant", content: reply },
|
|
124
|
+
finish_reason: "stop",
|
|
125
|
+
},
|
|
126
|
+
],
|
|
127
|
+
usage,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function streamResponse(model: string, reply: string, usage: any): Response {
|
|
132
|
+
const id = `chatcmpl-fake-${crypto.randomUUID()}`;
|
|
133
|
+
const created = Math.floor(Date.now() / 1000);
|
|
134
|
+
|
|
135
|
+
// Split into ~10 chunks so the streaming code path actually streams
|
|
136
|
+
// (single-chunk streams work but look unrealistic in logs).
|
|
137
|
+
const chunks: string[] = [];
|
|
138
|
+
const words = reply.split(/(\s+)/);
|
|
139
|
+
const perChunk = Math.max(1, Math.ceil(words.length / 10));
|
|
140
|
+
for (let i = 0; i < words.length; i += perChunk) {
|
|
141
|
+
chunks.push(words.slice(i, i + perChunk).join(""));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const encoder = new TextEncoder();
|
|
145
|
+
const readable = new ReadableStream({
|
|
146
|
+
start(controller) {
|
|
147
|
+
// First chunk: role + opening content.
|
|
148
|
+
controller.enqueue(
|
|
149
|
+
encoder.encode(
|
|
150
|
+
sseLine({
|
|
151
|
+
id,
|
|
152
|
+
object: "chat.completion.chunk",
|
|
153
|
+
created,
|
|
154
|
+
model,
|
|
155
|
+
choices: [{ index: 0, delta: { role: "assistant", content: chunks[0] ?? "" }, finish_reason: null }],
|
|
156
|
+
}),
|
|
157
|
+
),
|
|
158
|
+
);
|
|
159
|
+
// Subsequent chunks: content deltas only.
|
|
160
|
+
for (let i = 1; i < chunks.length; i++) {
|
|
161
|
+
controller.enqueue(
|
|
162
|
+
encoder.encode(
|
|
163
|
+
sseLine({
|
|
164
|
+
id,
|
|
165
|
+
object: "chat.completion.chunk",
|
|
166
|
+
created,
|
|
167
|
+
model,
|
|
168
|
+
choices: [{ index: 0, delta: { content: chunks[i] }, finish_reason: null }],
|
|
169
|
+
}),
|
|
170
|
+
),
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
// Final chunk: empty delta + finish_reason + usage (usage must be on
|
|
174
|
+
// the final chunk when stream_options.include_usage is set; opencode
|
|
175
|
+
// sets it so the session row gets non-zero token counts).
|
|
176
|
+
controller.enqueue(
|
|
177
|
+
encoder.encode(
|
|
178
|
+
sseLine({
|
|
179
|
+
id,
|
|
180
|
+
object: "chat.completion.chunk",
|
|
181
|
+
created,
|
|
182
|
+
model,
|
|
183
|
+
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
|
|
184
|
+
usage,
|
|
185
|
+
}),
|
|
186
|
+
),
|
|
187
|
+
);
|
|
188
|
+
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
|
|
189
|
+
controller.close();
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
return new Response(readable, {
|
|
194
|
+
headers: {
|
|
195
|
+
"content-type": "text/event-stream",
|
|
196
|
+
"cache-control": "no-cache",
|
|
197
|
+
"connection": "keep-alive",
|
|
198
|
+
"access-control-allow-origin": "*",
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function sseLine(obj: any): string {
|
|
204
|
+
return `data: ${JSON.stringify(obj)}\n\n`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function roughTokens(text: string): number {
|
|
208
|
+
// Crude 4-chars-per-token heuristic. Token counts only need to be >0 for
|
|
209
|
+
// awork-web's heat bar (which divides by max-peak-token across the session).
|
|
210
|
+
// Real tokenizer isn't needed for test data.
|
|
211
|
+
return Math.max(1, Math.ceil(text.length / 4));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
process.stderr.write(
|
|
215
|
+
`[fake-llm] listening on http://${HOST}:${PORT}\n` +
|
|
216
|
+
`[fake-llm] POST /v1/chat/completions -> canned OpenAI response\n` +
|
|
217
|
+
`[fake-llm] GET /v1/models -> { fake-model }\n`,
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
// Keep stderr unbuffered so logs show up immediately in docker output.
|
|
221
|
+
process.stderr.write(`[fake-llm] ready (pid ${process.pid})\n`);
|
package/src/commands/env.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// `ework-aio env` — print resolved paths (no secrets). Useful for users
|
|
2
2
|
// to find where data, .env files, and the bot token live without grepping.
|
|
3
3
|
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
4
6
|
import { Logger } from "../log.ts";
|
|
5
7
|
import { resolvePaths } from "../paths.ts";
|
|
6
8
|
import type { GlobalOptions } from "../types.ts";
|
|
@@ -12,13 +14,33 @@ export async function runEnv(opts: GlobalOptions, logger: Logger): Promise<void>
|
|
|
12
14
|
useSystemd: opts.useSystemd,
|
|
13
15
|
});
|
|
14
16
|
|
|
17
|
+
// install.ts writes the bot PAT to <dataDir>/bot-token OR
|
|
18
|
+
// <dataDir>/bot-token.<botName> when --bot-name isn't the default.
|
|
19
|
+
// paths.botTokenFile only knows the un-suffixed path, so glob for any
|
|
20
|
+
// bot-token* matches in the data dir rather than confidently printing a
|
|
21
|
+
// path that may not exist.
|
|
22
|
+
let botTokenPaths: string[] = [];
|
|
23
|
+
try {
|
|
24
|
+
for (const name of await fs.promises.readdir(paths.dataDir)) {
|
|
25
|
+
if (name === "bot-token" || name.startsWith("bot-token.")) {
|
|
26
|
+
botTokenPaths.push(path.join(paths.dataDir, name));
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
} catch {
|
|
30
|
+
// data dir doesn't exist yet — fall through to the un-suffixed default
|
|
31
|
+
// so the user sees what path install.ts WOULD use.
|
|
32
|
+
}
|
|
33
|
+
const botTokenLine = botTokenPaths.length === 0
|
|
34
|
+
? `${paths.botTokenFile} (not yet created — run ework-aio install)`
|
|
35
|
+
: botTokenPaths.join(", ");
|
|
36
|
+
|
|
15
37
|
logger.hr();
|
|
16
38
|
logger.log("ework-aio paths");
|
|
17
39
|
logger.hr();
|
|
18
40
|
logger.log(` data dir : ${paths.dataDir}`);
|
|
19
41
|
logger.log(` web env : ${paths.webEnvFile}`);
|
|
20
42
|
logger.log(` daemon env : ${paths.daemonEnvFile}`);
|
|
21
|
-
logger.log(` bot token : ${
|
|
43
|
+
logger.log(` bot token : ${botTokenLine}`);
|
|
22
44
|
logger.log(` opencode cfg : ${paths.opencodeConfigFile}`);
|
|
23
45
|
logger.log(` scope : ${opts.scope}`);
|
|
24
46
|
logger.log(` mode : ${opts.useSystemd ? "systemd" : "PID-file"}`);
|