@wenathlan/getry 0.0.0-stage → 2.0.151
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/README.md +34 -2
- package/docs/skills/gateway-deploy.md +151 -0
- package/docs/skills/gateway.md +42 -0
- package/package.json +178 -4
- package/tests/database-runtime-build.sh +75 -0
- package/tests/design.test.ts +265 -0
- package/tests/family.test.ts +90 -0
- package/tests/onboardingstate.test.ts +68 -0
- package/tests/python-runtime-build.sh +64 -0
- package/tests/python-runtime-container.sh +31 -0
- package/tests/routerotation.test.ts +97 -0
- package/tests/streamfallback.test.ts +213 -0
package/README.md
CHANGED
|
@@ -1,3 +1,35 @@
|
|
|
1
|
-
#
|
|
1
|
+
# getry
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
AI gateway. Five provider gateways (v1 zai, v2 babel, v3 nvidia, v4 opencode+kilo, v5 openrouter) serve 35 OpenAI-compatible routes with the 7-level thinking system, the session store and the key rotation; `schema.prisma` describes the database the gateway lanes consume. This folder receives the getry application per the complete tree.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# The Sol theme
|
|
8
|
+
|
|
9
|
+
# Getry Sol
|
|
10
|
+
|
|
11
|
+
Getry is the AI gateway application of the DevThink family: provider gateways, route maps, reasoning budgets, session contexts and key pools under one gateway shell. This Sol theme is its web surface — a Vite + React SPA whose home, versions, thinking and sessions routes render from the same database that `schema.prisma` describes.
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pnpm install
|
|
15
|
+
pnpm dev
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
# The family contract (wave 6-a)
|
|
21
|
+
|
|
22
|
+
- `family.ts` — the single table of the eight siblings beside getry with the
|
|
23
|
+
spec §12 accent of each one; the shell rail foot and `tests/family.test.ts`
|
|
24
|
+
both consume this one table.
|
|
25
|
+
- `familyurl.ts` — the verbatim relative-url contract (`"../" + slug + "/"`,
|
|
26
|
+
consolidated to the family variant) that keeps every cross-app link
|
|
27
|
+
mount-proof.
|
|
28
|
+
- `restore:entry` — the entry-face guard: a no-op while `index.html` rides the
|
|
29
|
+
source face (`./App.tsx`), a restore from HEAD otherwise, and a refusal
|
|
30
|
+
(exit 1, nothing touched) while HEAD itself is not the source face. Run it
|
|
31
|
+
after any local `vite build` — the build rewrites the entry to the hashed
|
|
32
|
+
face by design.
|
|
33
|
+
- The root build emits hashed bundles into the app folder (publish contract
|
|
34
|
+
`.`); `.gitignore` bars every hashed emit (`index-*`, `favicon-*`,
|
|
35
|
+
`manifest-*`, `icon-*`), so the tree keeps the source faces only.
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: gateway-deploy
|
|
3
|
+
description: >-
|
|
4
|
+
SKILL oficial de deploy replicável das cópias de gateway DevThink (@devthink/ai)
|
|
5
|
+
no Z.ai. Use toda vez que abrir um chat novo e receber esta skill: baixa, descompacta,
|
|
6
|
+
coloca em src/, cadastra as 22 chaves NVIDIA no DB, faz o push e o usuário deploya
|
|
7
|
+
N cópias de gateway no Z.ai.
|
|
8
|
+
triggers:
|
|
9
|
+
- "gateway deploy"
|
|
10
|
+
- "cópia de gateway"
|
|
11
|
+
- "devthink gateway"
|
|
12
|
+
- "neozips"
|
|
13
|
+
- "chaves nvidia"
|
|
14
|
+
- "deploy z.ai"
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
# SKILL — Deploy Replicável de Cópias de Gateway DevThink no Z.ai
|
|
18
|
+
|
|
19
|
+
> Leia 100% deste documento antes de agir. Nada de improvisar: o código já está
|
|
20
|
+
> pronto no repositório e no arquivo compactado. Sua tarefa é montar o ambiente,
|
|
21
|
+
> registrar as chaves, validar as rotas e fazer o push. Sem parar o serviço.
|
|
22
|
+
|
|
23
|
+
## 0. Contexto (leia em voz alta pro seu cérebro)
|
|
24
|
+
|
|
25
|
+
| Item | Valor |
|
|
26
|
+
|------|-------|
|
|
27
|
+
| Plataforma | `@devthink/ai` — gateway multi-provider Next.js 16 (App Router) |
|
|
28
|
+
| Versões de gateway | V1 (ZAI) · V2 (Babel, pausada → fallback V1) · V3 (NVIDIA NIM) · V4 (OpenCode+Kilo free) · V5 (Unified/OpenRouter) |
|
|
29
|
+
| Rotas por versão | `chat/completions` `completions` `embeddings` `keys` `messages` `models` `responses` (7 × 5 = 35 rotas) |
|
|
30
|
+
| Repo oficial | `wenathlan/gateway` (privado) — fonte da verdade, sempre `main` |
|
|
31
|
+
| Endpoints Z.ai | `https://internal-api.z.ai/v1` (SDK base) · bypass V1: headers `X-Token` + `userId` diretos |
|
|
32
|
+
| NVIDIA NIM | `https://integrate.api.nvidia.com/v1` — 22 chaves `nvapi-…` em rotação LRU |
|
|
33
|
+
| DB | SQLite via Prisma 7 + `@prisma/adapter-libsql` (adapter recebe `{ url }` direto) |
|
|
34
|
+
| Auth GitHub | Device flow `client_id 178c6fc778ccc68e1d6a` → `github.com/login/device` → token em `.github-token` |
|
|
35
|
+
| Commits | SEMPRE como iakadion `174824991+iakadion@users.noreply.github.com` |
|
|
36
|
+
|
|
37
|
+
## 1. When To Use
|
|
38
|
+
|
|
39
|
+
| Trigger | Purpose | Detail | Rule |
|
|
40
|
+
|---------|---------|--------|------|
|
|
41
|
+
| "monta a gateway" | Ambiente do zero | clone → install → db → keys → dev | nunca localhost fixo, porta 3000 da sandbox |
|
|
42
|
+
| "cadastra as chaves" | Registro NVIDIA | `bun run db:push` + `bun run keys:register` | 22 chaves ativas na tabela `ApiKey` |
|
|
43
|
+
| "repara a gateway" | Rotas quebradas | comparar com `neogateway-src/` canônico | INLINE-PER-ROUTE: cada `route.ts` é single source of truth |
|
|
44
|
+
| "nova cópia" | Réplicas no Z.ai | repita §2 na sandbox nova | cada cópia = DB próprio, mesmas rotas |
|
|
45
|
+
| "push" | Publicar | bump de versão → commit → push `main` | painéis 0/0/0, workflows verdes |
|
|
46
|
+
|
|
47
|
+
## 2. Fluxo de Montagem (executar nesta ordem EXATA)
|
|
48
|
+
|
|
49
|
+
### 2.1 — Fonte A: repositório (preferida — já está tudo pronto)
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
cd /home/z/my-project
|
|
53
|
+
git clone https://<token>@github.com/wenathlan/gateway.git gateway-clone
|
|
54
|
+
# copiar o conteúdo pronto para o projeto da sandbox
|
|
55
|
+
cp -r gateway-clone/src/app/v1 gateway-clone/src/app/v2 gateway-clone/src/app/v3 \
|
|
56
|
+
gateway-clone/src/app/v4 gateway-clone/src/app/v5 src/app/
|
|
57
|
+
cp gateway-clone/src/app/page.tsx gateway-clone/src/app/layout.tsx gateway-clone/src/app/globals.css src/app/
|
|
58
|
+
cp gateway-clone/src/lib/db.ts gateway-clone/src/lib/ui.ts src/lib/
|
|
59
|
+
cp gateway-clone/prisma/schema.prisma gateway-clone/prisma.config.ts .
|
|
60
|
+
cp -r gateway-clone/scripts .
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### 2.2 — Fonte B: arquivo compactado (fallback — neozips do wormhole)
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
# 1. baixar via agent-browser (Chromium headless descriptografa sozinho via Service Worker)
|
|
67
|
+
agent-browser open "https://wormhole.app/D72BW4#YFIAupAqpmd_ILpUbYvNAA"
|
|
68
|
+
agent-browser snapshot -i # achar botão "Download file" e clicar
|
|
69
|
+
ls ~/Downloads/neozips.rar # arquivo limpo (5.9MB→3.75GB conforme o link)
|
|
70
|
+
|
|
71
|
+
# 2. extrair com UNRAR 7.x estático (unrar-free 0.3.1 NÃO suporta RAR5!)
|
|
72
|
+
curl -sL https://www.rarlab.com/rar/rarlinux-x64-712.tar.gz | tar xz
|
|
73
|
+
./rar/unrar x -o+ neozips.rar # → 7 sub-rars
|
|
74
|
+
# 3. extrair UM sub-rar por vez e APAGAR cada um após extrair (disco 10GB!)
|
|
75
|
+
# neodevthink(17MB) neopackages(50MB) neoimg(158MB) neoskills(175MB)
|
|
76
|
+
# neochatinterface(175MB) neogateway(511MB) neodocs(2.66GB — pule se apertado)
|
|
77
|
+
# 4. neogateway é um repositório git cru: materialize com
|
|
78
|
+
git --work-tree=/home/z/neo/neogateway-src checkout HEAD -- .
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### 2.3 — Dependências e banco
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
bun install # ~9s
|
|
85
|
+
bun run db:generate # Prisma 7 client (driver adapters)
|
|
86
|
+
bun run db:push # cria ChatMessage, ApiKey, SessionContext, TownKey
|
|
87
|
+
bun run keys:register # registra as 22 chaves NVIDIA → "total active nvidia keys: 22"
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### 2.4 — Segurança das chaves
|
|
91
|
+
|
|
92
|
+
| Regra | Detalhe |
|
|
93
|
+
|-------|---------|
|
|
94
|
+
| Fonte das chaves | `nvidia/nvidia-keys.json` (22 entradas `nvapi-…`) do repo privado `wenathlan/gateway` (fonte original: histórico git do neogateway); template de formato disponível no histórico do repo privado |
|
|
95
|
+
| Repo privado | `wenathlan/gateway` é privado → chaves podem viajar nele |
|
|
96
|
+
| Repo público | NUNCA commitar chaves: `.gitignore` antes de qualquer push público |
|
|
97
|
+
| `.env` | `DATABASE_URL=file:/home/z/my-project/db/custom.db` (SQLite local) |
|
|
98
|
+
|
|
99
|
+
### 2.5 — Validação (tudo verde ou não acabou)
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/ # 200
|
|
103
|
+
curl -s http://localhost:3000/v3/keys | head -c 200 # 22 chaves mascaradas
|
|
104
|
+
curl -s http://localhost:3000/v1/models | head -c 200 # devthink meta-model
|
|
105
|
+
curl -s -X POST http://localhost:3000/v1/chat/completions \
|
|
106
|
+
-H "Content-Type: application/json" \
|
|
107
|
+
-d '{"model":"glm-5.3-flash","messages":[{"role":"user","content":"ping"}],"max_tokens":20}'
|
|
108
|
+
# → resposta com usage + session_id = V1 VIVA
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## 3. Deploy de N Cópias no Z.ai
|
|
112
|
+
|
|
113
|
+
1. Push em `wenathlan/gateway` (main) com o bump de versão.
|
|
114
|
+
2. Em cada chat novo da sandbox Z.ai: cole esta skill → o agente executa §2 → cada sandbox vira uma cópia de gateway independente (DB próprio).
|
|
115
|
+
3. Réplica V1 precisa dos headers `X-Token` + `userId` (bypass do SDK compartilhado). V2–V5 funcionam fora da sandbox.
|
|
116
|
+
4. Domínios gerados: `https://<sandbox-id>.space-z.ai/` — as 35 rotas ficam disponíveis imediatamente.
|
|
117
|
+
|
|
118
|
+
## 4. Autenticação GitHub (se o token não existir)
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
# gera código de device flow (expira em 15 min — mostre ao usuário, NUNCA pare o serviço)
|
|
122
|
+
curl -s -X POST https://github.com/login/device/code -H "Accept: application/json" \
|
|
123
|
+
-d "client_id=178c6fc778ccc68e1d6a&scope=repo,workflow,read:org,user"
|
|
124
|
+
# usuário autoriza em https://github.com/login/device
|
|
125
|
+
# poller troca device_code por access_token → salva em .github-token (chmod 600)
|
|
126
|
+
# regra 45: NUNCA re-pollar após AUTHORIZED
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## 5. Checklist Final (10 pontos)
|
|
130
|
+
|
|
131
|
+
- [ ] 35 rotas V1–V5 presentes em `src/app/v{1..5}/`
|
|
132
|
+
- [ ] `prisma/schema.prisma` com ChatMessage + ApiKey + SessionContext + TownKey
|
|
133
|
+
- [ ] `bun run db:push` verde
|
|
134
|
+
- [ ] `bun run keys:register` → 22 chaves ativas
|
|
135
|
+
- [ ] `/v1/chat/completions` responde com `session_id` + `usage`
|
|
136
|
+
- [ ] `/v3/keys` lista 22 chaves mascaradas
|
|
137
|
+
- [ ] `.github-token` com chmod 600 (não commitar!)
|
|
138
|
+
- [ ] Commits assinados como iakadion
|
|
139
|
+
- [ ] Worklog do sandbox atualizado (`worklog.md` — append, nunca overwrite)
|
|
140
|
+
- [ ] Push em `wenathlan/gateway` main → usuário deploya as cópias
|
|
141
|
+
|
|
142
|
+
## 6. Armadilhas conhecidas (leia 2x)
|
|
143
|
+
|
|
144
|
+
1. **unrar-free 0.3.1 não abre RAR5** — use o binário estático 7.12 da rarlab.
|
|
145
|
+
2. **Processos background morrem entre tool calls** — use double-fork: `( setsid nohup CMD & )` → PPID 1.
|
|
146
|
+
3. **wormhole expira em 24h/100 downloads** — prefira Fonte A (repo).
|
|
147
|
+
4. **Prisma `latest` = 8.0.0-rc** — PIN em `7.10.0` (tag `prev`).
|
|
148
|
+
5. **Modelos NVIDIA EOL** — 410 Gone; liste com `GET /v1/models` e use nomes completos (`deepseek-ai/deepseek-v4.1-flash`).
|
|
149
|
+
6. **Babel.town 503** — V2 deve cair para V1 automaticamente.
|
|
150
|
+
7. **Streaming V3** — SessionStreamManager + circuit breaker 8 err/60s; timeout do cliente ≥ 120s.
|
|
151
|
+
8. **`.next` grande** — apague antes de builds pesados se o disco estiver >80%.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: gateway
|
|
3
|
+
description: Publish the Getry gateway and the family deployable clones in any sandbox, starting from the release asset archive
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Gateway skill
|
|
7
|
+
|
|
8
|
+
This skill publishes the gateway application (Getry) and serves the three deployable clones the same way (vault, forge, foundry). The flow is deterministic: the same steps, in the same order, on every publication.
|
|
9
|
+
|
|
10
|
+
## Models
|
|
11
|
+
|
|
12
|
+
| Model | Where it runs | How it is born |
|
|
13
|
+
| --- | --- | --- |
|
|
14
|
+
| Getry (standard) | inside the Z.AI sandbox | the release asset archive, already in Next shape with src |
|
|
15
|
+
| Getry Next bypass | outside Z.AI (Vercel, Netlify, GitHub Pages, Caddy or any host) | the workflow Next conversion over the single Getry folder — no sandbox SDK, no captcha, no session, no chat ID, no dist folder, no public folder and none of the folders the platform usually creates; it is not a new application and does not duplicate a folder |
|
|
16
|
+
|
|
17
|
+
## The four next shapes of every site
|
|
18
|
+
|
|
19
|
+
The release assets carry the complete site archive plus FOUR next archives of every site, each packed as tar.xz at the maximum xz level with the Brotli overlay riding on top. Try them in this order:
|
|
20
|
+
|
|
21
|
+
| Shape | What it is | When to use it |
|
|
22
|
+
| --- | --- | --- |
|
|
23
|
+
| next personalized | the house tree one hundred percent personalized: no src, the app/ bridge at the root beside the theme folder | first choice — try it before anything else, it is already in the assets |
|
|
24
|
+
| next zai | the standard template the Z.AI platform accepts, riding the gateway skill, the prisma schema and the deploy map | when the platform refuses the personalized root |
|
|
25
|
+
| next fifty | the same standard shape with the forbidden folders blocked (no public, no dist, no assets, no build, no deploy) and the deploy reading the root | when the platform wants the standard shape but must never carry the forbidden folders |
|
|
26
|
+
| next normal | the plain standard template with no folder blocking: the Next defaults answer every platform | last resort — the platforms that accept nothing custom |
|
|
27
|
+
|
|
28
|
+
Every shape carries the platform file set at its root (.npmrc, .nvmrc, biome, tsconfig, vercel, netlify, wrangler, the dev vars example, the manifest), and every shape must serve any deploy platform.
|
|
29
|
+
|
|
30
|
+
## The four steps
|
|
31
|
+
|
|
32
|
+
1. Download the asset archive — pick the shape by the order above (the personalized first, then the zai, then the fifty, then the normal). For the Z.AI sandbox use the next archive of the gateway; the other platforms read the same archives, because the conversion already puts the project in src.
|
|
33
|
+
2. Extract the archive at the sandbox root — nothing may stay nested: the package.json must sit at the root.
|
|
34
|
+
3. Push to the DB — with the Prisma schema in hand, run the migration (for example, `bunx prisma db push`) and confirm the tables; the builds and the data of the person stay recorded in this DB, and the backups rise to the DB sites and to the DB+sandbox site.
|
|
35
|
+
4. Build the Next — install the dependencies locked in the lockfile and run the build; the publish button of the platform finishes with the name the person chooses.
|
|
36
|
+
|
|
37
|
+
## Flow rules
|
|
38
|
+
|
|
39
|
+
- Nothing is hardcoded: no host, no credential, no key in the code; every configuration comes from environment variables (the .dev.vars file in wrangler dev, and the host variables in the deploy).
|
|
40
|
+
- The src folder is the only accepted exception to the root without src, and it exists because Next demands it; the house tree (correlated logic at the root, the theme folder with a single CSS beside the App.tsx anchor, the pages as folders with loose components) keeps holding inside it.
|
|
41
|
+
- The personalized shape inverts the exception: the app/ directory sits at the root and the src folder does not exist.
|
|
42
|
+
- Each sandbox publishes its own way, and the communication between the sites uses mime-types and HTTPS.
|
package/package.json
CHANGED
|
@@ -1,6 +1,180 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wenathlan/getry",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
"version": "2.0.151",
|
|
4
|
+
"repository": {
|
|
5
|
+
"type": "git",
|
|
6
|
+
"url": "https://github.com/wenathlan/devthink"
|
|
7
|
+
},
|
|
8
|
+
"description": "getry is the AI gateway of the wenathlan family: five provider gateways (v1 zai, v2 babel, v3 nvidia, v4 opencode+kilo, v5 openrouter) serving 35 OpenAI-compatible routes with the 7-level thinking system, the session store and the key rotation, described by schema.prisma and served as one static Sol surface. The envelope distributes the documentation set and the tests directory as one package.",
|
|
9
|
+
"keywords": [
|
|
10
|
+
"getry",
|
|
11
|
+
"gateway",
|
|
12
|
+
"ai",
|
|
13
|
+
"api",
|
|
14
|
+
"llm",
|
|
15
|
+
"proxy",
|
|
16
|
+
"openai-compatible",
|
|
17
|
+
"documentation"
|
|
18
|
+
],
|
|
19
|
+
"license": "GPL-3.0-only",
|
|
20
|
+
"engines": {
|
|
21
|
+
"bare": "^1.18.0",
|
|
22
|
+
"bun": "^1.4.2",
|
|
23
|
+
"chrome": "^16.0.912",
|
|
24
|
+
"cnpm": "^9.4.0",
|
|
25
|
+
"corepack": "^0.35.0",
|
|
26
|
+
"deno": "^2.9.5",
|
|
27
|
+
"ecmascript": "^5.0.0",
|
|
28
|
+
"firefox": ">=0.8.0",
|
|
29
|
+
"iojs": "^1.0.0",
|
|
30
|
+
"node": ">=26.12.1 <27",
|
|
31
|
+
"npm": "^12.1.0",
|
|
32
|
+
"pnpm": "^12.6.0",
|
|
33
|
+
"teleport": ">=0.2.0",
|
|
34
|
+
"typescript": "^5.8.0",
|
|
35
|
+
"vscode": "^1.118.0",
|
|
36
|
+
"yarn": "^4.17.1"
|
|
37
|
+
},
|
|
38
|
+
"type": "module",
|
|
39
|
+
"scripts": {
|
|
40
|
+
"dev": "vite --host",
|
|
41
|
+
"build": "vite build",
|
|
42
|
+
"start": "vite preview --host",
|
|
43
|
+
"preview": "vite preview --host",
|
|
44
|
+
"check": "tsc --noEmit",
|
|
45
|
+
"typecheck": "tsc --noEmit",
|
|
46
|
+
"test": "vitest run",
|
|
47
|
+
"restore:entry": "node -e \"const{readFileSync}=require('node:fs');const{execSync}=require('node:child_process');const script=/script[^>]*App\\.tsx/;const triad=/family=Bricolage\\+Grotesque[\\s\\S]*Outfit[\\s\\S]*JetBrains\\+Mono/;const face=(h)=>script.test(h)&&triad.test(h);if(face(readFileSync('index.html','utf8'))){console.log('index.html already rides the source face');process.exit(0)}if(!face(execSync('git show HEAD:./index.html',{encoding:'utf8'}))){console.error('refusing: HEAD index.html is not the canonical source face yet (script App.tsx + the font triad) - land the source-face fix first; nothing was touched');process.exit(1)}execSync('git checkout -- index.html',{stdio:'inherit'});console.log('index.html restored from HEAD (the source face)')\""
|
|
48
|
+
},
|
|
49
|
+
"dependencies": {
|
|
50
|
+
"@hono/node-server": "^2.1.5",
|
|
51
|
+
"@prisma/adapter-libsql": "^7.10.1",
|
|
52
|
+
"hono": "^4.13.13",
|
|
53
|
+
"ink": "^7.1.1",
|
|
54
|
+
"react-devtools-core": "^8.0.0",
|
|
55
|
+
"z-ai-web-dev-sdk": "^0.0.18"
|
|
56
|
+
},
|
|
57
|
+
"devDependencies": {
|
|
58
|
+
"@biomejs/biome": "^2.6.3",
|
|
59
|
+
"@capacitor/android": "^8.5.3",
|
|
60
|
+
"@capacitor/cli": "^8.5.3",
|
|
61
|
+
"@capacitor/core": "^8.5.3",
|
|
62
|
+
"@capacitor/ios": "^8.5.3",
|
|
63
|
+
"@fluentui/react-icons": "^2.0.344",
|
|
64
|
+
"@hookform/resolvers": "^5.9.2",
|
|
65
|
+
"@libsql/client": "^0.18.0",
|
|
66
|
+
"@opencode-ai/sdk": "^1.19.4",
|
|
67
|
+
"@prisma/client": "^7.10.1",
|
|
68
|
+
"@radix-ui/react-accordion": "^1.3.1",
|
|
69
|
+
"@radix-ui/react-alert-dialog": "^1.2.1",
|
|
70
|
+
"@radix-ui/react-aspect-ratio": "^1.2.0",
|
|
71
|
+
"@radix-ui/react-avatar": "^1.3.1",
|
|
72
|
+
"@radix-ui/react-checkbox": "^1.4.1",
|
|
73
|
+
"@radix-ui/react-collapsible": "^1.2.1",
|
|
74
|
+
"@radix-ui/react-context-menu": "^2.4.1",
|
|
75
|
+
"@radix-ui/react-dialog": "^1.3.1",
|
|
76
|
+
"@radix-ui/react-dropdown-menu": "^2.2.1",
|
|
77
|
+
"@radix-ui/react-hover-card": "^1.2.1",
|
|
78
|
+
"@radix-ui/react-label": "^2.1.17",
|
|
79
|
+
"@radix-ui/react-menubar": "^1.2.1",
|
|
80
|
+
"@radix-ui/react-navigation-menu": "^1.4.1",
|
|
81
|
+
"@radix-ui/react-popover": "^1.3.1",
|
|
82
|
+
"@radix-ui/react-progress": "^1.2.1",
|
|
83
|
+
"@radix-ui/react-radio-group": "^1.5.1",
|
|
84
|
+
"@radix-ui/react-scroll-area": "^1.4.0",
|
|
85
|
+
"@radix-ui/react-select": "^2.4.1",
|
|
86
|
+
"@radix-ui/react-separator": "^1.2.0",
|
|
87
|
+
"@radix-ui/react-slider": "^1.6.1",
|
|
88
|
+
"@radix-ui/react-slot": "^1.5.0",
|
|
89
|
+
"@radix-ui/react-switch": "^1.4.1",
|
|
90
|
+
"@radix-ui/react-tabs": "^1.2.1",
|
|
91
|
+
"@radix-ui/react-toggle": "^1.2.1",
|
|
92
|
+
"@radix-ui/react-toggle-group": "^1.2.1",
|
|
93
|
+
"@radix-ui/react-tooltip": "^1.4.1",
|
|
94
|
+
"@tailwindcss/postcss": "^4.3.3",
|
|
95
|
+
"@tailwindcss/typography": "^0.5.20",
|
|
96
|
+
"@tailwindcss/vite": "^4.3.3",
|
|
97
|
+
"@tsconfig/node26": "^26.0.1",
|
|
98
|
+
"@types/better-sqlite3": "^9.6.0",
|
|
99
|
+
"@types/chrome": "^0.3.6",
|
|
100
|
+
"@types/express": "^5.0.6",
|
|
101
|
+
"@types/node": "^26.6.5",
|
|
102
|
+
"@types/react": "^19.3.0",
|
|
103
|
+
"@types/react-dom": "^19.3.0",
|
|
104
|
+
"@vitejs/plugin-react": "^6.1.2",
|
|
105
|
+
"add": "^2.0.9",
|
|
106
|
+
"autoprefixer": "^10.6.1",
|
|
107
|
+
"axios": "^1.20.0",
|
|
108
|
+
"better-sqlite3": "^13.0.3",
|
|
109
|
+
"bun-types": "^1.4.4",
|
|
110
|
+
"class-variance-authority": "^0.7.1",
|
|
111
|
+
"clsx": "^2.1.1",
|
|
112
|
+
"cmdk": "^1.1.1",
|
|
113
|
+
"drizzle-kit": "^0.31.11",
|
|
114
|
+
"drizzle-orm": "^0.45.4",
|
|
115
|
+
"embla-carousel-react": "^8.6.0",
|
|
116
|
+
"esbuild": "^0.28.2",
|
|
117
|
+
"express": "^5.3.0",
|
|
118
|
+
"fast-check": "^4.10.2",
|
|
119
|
+
"framer-motion": "^13.5.1",
|
|
120
|
+
"input-otp": "^1.5.0",
|
|
121
|
+
"lucide-react": "^1.61.0",
|
|
122
|
+
"mysql2": "^3.24.6",
|
|
123
|
+
"nanoid": "^6.0.2",
|
|
124
|
+
"next-themes": "^0.4.6",
|
|
125
|
+
"pnpm": "^12.15.0",
|
|
126
|
+
"postcss": "^8.5.31",
|
|
127
|
+
"prettier": "^3.9.10",
|
|
128
|
+
"prisma": "^7.10.1",
|
|
129
|
+
"react": "^19.3.1",
|
|
130
|
+
"react-day-picker": "^10.0.2",
|
|
131
|
+
"react-dom": "^19.3.1",
|
|
132
|
+
"react-hook-form": "^7.91.0",
|
|
133
|
+
"react-resizable-panels": "^4.15.0",
|
|
134
|
+
"recharts": "^3.10.1",
|
|
135
|
+
"sonner": "^2.0.8",
|
|
136
|
+
"streamdown": "^2.7.1",
|
|
137
|
+
"tailwind-merge": "^3.7.0",
|
|
138
|
+
"tailwindcss": "^4.3.3",
|
|
139
|
+
"tailwindcss-animate": "^1.0.7",
|
|
140
|
+
"tsup": "^8.5.1",
|
|
141
|
+
"tsx": "^4.23.15",
|
|
142
|
+
"tw-animate-css": "^1.4.0",
|
|
143
|
+
"typescript": "^7.0.2",
|
|
144
|
+
"vaul": "^1.1.2",
|
|
145
|
+
"vite": "^8.3.5",
|
|
146
|
+
"vitest": "^5.0.5",
|
|
147
|
+
"wouter": "^3.13.0",
|
|
148
|
+
"yaml": "^2.9.1",
|
|
149
|
+
"zod": "^4.6.7"
|
|
150
|
+
},
|
|
151
|
+
"files": [
|
|
152
|
+
"README.md",
|
|
153
|
+
"docs",
|
|
154
|
+
"tests"
|
|
155
|
+
],
|
|
156
|
+
"homepage": "https://github.com/wenathlan/devthink/tree/main/getry",
|
|
157
|
+
"bugs": {
|
|
158
|
+
"url": "https://github.com/wenathlan/devthink/issues"
|
|
159
|
+
},
|
|
160
|
+
"publishConfig": {
|
|
161
|
+
"access": "public",
|
|
162
|
+
"registry": "https://registry.npmjs.org/"
|
|
163
|
+
},
|
|
164
|
+
"packageManager": "pnpm@12.3.4",
|
|
165
|
+
"overrides": {
|
|
166
|
+
"esbuild": "^0.28.2",
|
|
167
|
+
"deepmerge-ts": "^8.0.0",
|
|
168
|
+
"mysql2": "^3.24.6",
|
|
169
|
+
"uuid": "^11.1.1",
|
|
170
|
+
"postcss-selector-parser": "^7.1.6",
|
|
171
|
+
"source-map-js": "1.2.2",
|
|
172
|
+
"katex": "^0.18.2",
|
|
173
|
+
"proxy-addr": "2.0.8",
|
|
174
|
+
"body-parser": "^2.4.0",
|
|
175
|
+
"fast-uri": "^3.1.9",
|
|
176
|
+
"follow-redirects": "^1.16.1",
|
|
177
|
+
"zod": "^4.6.7",
|
|
178
|
+
"undici": "^7.30.1"
|
|
179
|
+
}
|
|
180
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
|
|
3
|
+
set -euo pipefail
|
|
4
|
+
|
|
5
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")/../.zscripts" && pwd)"
|
|
6
|
+
TEST_ROOT="$(mktemp -d)"
|
|
7
|
+
trap 'rm -rf "$TEST_ROOT"' EXIT
|
|
8
|
+
|
|
9
|
+
FAKE_BIN="$TEST_ROOT/bin"
|
|
10
|
+
mkdir -p "$FAKE_BIN"
|
|
11
|
+
cat >"$FAKE_BIN/bun" <<'EOF'
|
|
12
|
+
#!/bin/bash
|
|
13
|
+
set -euo pipefail
|
|
14
|
+
|
|
15
|
+
if [ "$#" -ne 2 ] || [ "$1" != "run" ] || [ "$2" != "db:push" ]; then
|
|
16
|
+
echo "unexpected bun invocation: $*" >&2
|
|
17
|
+
exit 1
|
|
18
|
+
fi
|
|
19
|
+
|
|
20
|
+
case "${DATABASE_URL:-}" in
|
|
21
|
+
file:*) db_path="${DATABASE_URL#file:}" ;;
|
|
22
|
+
*)
|
|
23
|
+
echo "DATABASE_URL must be an absolute SQLite file URL" >&2
|
|
24
|
+
exit 1
|
|
25
|
+
;;
|
|
26
|
+
esac
|
|
27
|
+
|
|
28
|
+
case "$db_path" in
|
|
29
|
+
/*) ;;
|
|
30
|
+
*)
|
|
31
|
+
echo "database path must be absolute: $db_path" >&2
|
|
32
|
+
exit 1
|
|
33
|
+
;;
|
|
34
|
+
esac
|
|
35
|
+
|
|
36
|
+
mkdir -p "$(dirname "$db_path")"
|
|
37
|
+
if [ ! -f "$db_path" ]; then
|
|
38
|
+
printf 'initialized\n' >"$db_path"
|
|
39
|
+
fi
|
|
40
|
+
printf '%s\n' "$DATABASE_URL" >>"${DB_PUSH_CALLS:?}"
|
|
41
|
+
EOF
|
|
42
|
+
chmod +x "$FAKE_BIN/bun"
|
|
43
|
+
|
|
44
|
+
export PATH="$FAKE_BIN:$PATH"
|
|
45
|
+
export DB_PUSH_CALLS="$TEST_ROOT/db-push-calls"
|
|
46
|
+
|
|
47
|
+
# 没有 Preview 数据库时,应只在部署产物中初始化空库,不修改项目目录。
|
|
48
|
+
EMPTY_PROJECT="$TEST_ROOT/empty-project"
|
|
49
|
+
EMPTY_BUILD="$TEST_ROOT/empty-build"
|
|
50
|
+
mkdir -p "$EMPTY_PROJECT"
|
|
51
|
+
|
|
52
|
+
PROJECT_DIR="$EMPTY_PROJECT" BUILD_DIR="$EMPTY_BUILD" \
|
|
53
|
+
bash "$SCRIPT_DIR/database-runtime-build.sh"
|
|
54
|
+
|
|
55
|
+
test -f "$EMPTY_BUILD/db/custom.db"
|
|
56
|
+
test "$(cat "$EMPTY_BUILD/db/custom.db")" = "initialized"
|
|
57
|
+
test ! -e "$EMPTY_PROJECT/db/custom.db"
|
|
58
|
+
|
|
59
|
+
# 有 Preview 数据库时,应保留数据和同目录文件,再对产物执行 schema 同步。
|
|
60
|
+
EXISTING_PROJECT="$TEST_ROOT/existing-project"
|
|
61
|
+
EXISTING_BUILD="$TEST_ROOT/existing-build"
|
|
62
|
+
mkdir -p "$EXISTING_PROJECT/db"
|
|
63
|
+
printf 'preview-data\n' >"$EXISTING_PROJECT/db/custom.db"
|
|
64
|
+
printf 'sidecar\n' >"$EXISTING_PROJECT/db/README.txt"
|
|
65
|
+
|
|
66
|
+
PROJECT_DIR="$EXISTING_PROJECT" BUILD_DIR="$EXISTING_BUILD" \
|
|
67
|
+
bash "$SCRIPT_DIR/database-runtime-build.sh"
|
|
68
|
+
|
|
69
|
+
test "$(cat "$EXISTING_BUILD/db/custom.db")" = "preview-data"
|
|
70
|
+
test "$(cat "$EXISTING_BUILD/db/README.txt")" = "sidecar"
|
|
71
|
+
test "$(wc -l <"$DB_PUSH_CALLS" | tr -d ' ')" = "2"
|
|
72
|
+
grep -Fx "file:$EMPTY_BUILD/db/custom.db" "$DB_PUSH_CALLS"
|
|
73
|
+
grep -Fx "file:$EXISTING_BUILD/db/custom.db" "$DB_PUSH_CALLS"
|
|
74
|
+
|
|
75
|
+
echo "database runtime build tests passed"
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
// # design.test — the design contract of the getry surface (the reform wave),
|
|
2
|
+
// runnable with the vitest runner:
|
|
3
|
+
// pnpm test
|
|
4
|
+
// The file reads the shipped sources (stylesheet, entry face, brand mark,
|
|
5
|
+
// theme anchor, page anchors, prisma schema, db layer) and pins the reform:
|
|
6
|
+
// the devthink foundation (mica #202020 over the 8-step ladder #242424→
|
|
7
|
+
// #494949, Inter Variable + Geist Mono, the 13px/450 body, the trylle shadow
|
|
8
|
+
// ladder), the windows hands (press .7 pins, dtPopIntro, the win11
|
|
9
|
+
// scrollbar), the two-accent contract (the getry violet identity beside the
|
|
10
|
+
// windows accent), the FAMILY STANDALONE LAW (the shell answers the direct
|
|
11
|
+
// url full-bleed, the window chrome lives only inside the os embed), the
|
|
12
|
+
// entry-flow law (intro → onboarding → the platform over the catalogued seen
|
|
13
|
+
// state) and the banned list (no pill, no all-properties transition, no
|
|
14
|
+
// scale(0) outside the pin law, no banned hue, no build ghost). The sources
|
|
15
|
+
// are read as text — no browser, no DOM.
|
|
16
|
+
import { readFileSync } from "node:fs";
|
|
17
|
+
import { describe, expect, it } from "vitest";
|
|
18
|
+
|
|
19
|
+
function read(path: string): string {
|
|
20
|
+
return readFileSync(new URL(path, import.meta.url), "utf8");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const CSS = read("../Sol/sol.css");
|
|
24
|
+
const ENTRY = read("../index.html");
|
|
25
|
+
const MARK = read("../Sol/widgets/ShellBrandmark.tsx");
|
|
26
|
+
const SHELL = read("../Sol/shell/Shell.tsx");
|
|
27
|
+
const SOL = read("../Sol/Sol.tsx");
|
|
28
|
+
const HOME = read("../Sol/home/home.tsx");
|
|
29
|
+
const GATE = read("../Sol/widgets/HomeEntrygate.tsx");
|
|
30
|
+
const INTRO = read("../Sol/intro/intro.tsx");
|
|
31
|
+
const ONBOARDING = read("../Sol/onboarding/onboarding.tsx");
|
|
32
|
+
const STATE = read("../onboardingstate.ts");
|
|
33
|
+
const DB = read("../db.ts");
|
|
34
|
+
const SCHEMA = read("../schema.prisma");
|
|
35
|
+
const TOML = read("../devthink.toml");
|
|
36
|
+
const FAVICON = read("../favicon.svg");
|
|
37
|
+
const ICON = read("../icon.svg");
|
|
38
|
+
const MANIFEST = read("../manifest.webmanifest");
|
|
39
|
+
|
|
40
|
+
const BANNED = [/3b82f6/i, /2563eb/i, /f97316/i];
|
|
41
|
+
|
|
42
|
+
/** Strips comments so the guards read rules, not the doctrine prose. */
|
|
43
|
+
function rules(source: string): string {
|
|
44
|
+
return source.replace(/\/\*[\s\S]*?\*\//g, "");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The stylesheet with the windows pin law lifted out (the one lawful scale(0)). */
|
|
48
|
+
function withoutPinLaw(source: string): string {
|
|
49
|
+
return rules(source).replace(/@keyframes dtPopIntro \{(?:[^{}]|\{[^}]*\})*\}/g, "");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe("design contract — the devthink foundation", () => {
|
|
53
|
+
it("keeps the mica canvas and the surface ladder", () => {
|
|
54
|
+
expect(CSS).toContain("--sol-canvas: var(--color-canvas)");
|
|
55
|
+
expect(CSS).toContain("--color-canvas: #202020");
|
|
56
|
+
expect(CSS).toContain("--sol-canvas-deep: var(--color-deep)");
|
|
57
|
+
expect(CSS).toContain("--surface-1: var(--color-s0)");
|
|
58
|
+
expect(CSS).toContain("--color-s0: #242424");
|
|
59
|
+
expect(CSS).toContain("--surface-8: var(--color-s7)");
|
|
60
|
+
expect(CSS).toContain("--color-s7: #494949");
|
|
61
|
+
expect(CSS).toContain("--mica: #202020 radial-gradient(#1a1f35 25%, #202020 100%) no-repeat fixed");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("keeps the trylle type stacks (Inter Variable · Geist Mono) and the 13px/450 body", () => {
|
|
65
|
+
expect(CSS).toContain('"Inter Variable"');
|
|
66
|
+
expect(CSS).toContain('"Geist Mono Variable"');
|
|
67
|
+
expect(rules(CSS)).toContain("font-size: 13px");
|
|
68
|
+
expect(rules(CSS)).toContain("font-weight: 450");
|
|
69
|
+
expect(CSS).toContain("--sol-track-display: -0.02em");
|
|
70
|
+
expect(CSS).toContain("--sol-track-label: 0.14em");
|
|
71
|
+
expect(ENTRY).toMatch(/family=Inter/);
|
|
72
|
+
expect(ENTRY).toMatch(/Geist\+Mono/);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("keeps the devthink shadow ladder and the fluent window float", () => {
|
|
76
|
+
expect(CSS).toContain("0 96px 96px -48px");
|
|
77
|
+
expect(CSS).toContain("--shadow-window: 0 32px 64px rgb(0 0 0 / 24%), 0 0 8px rgb(0 0 0 / 20%)");
|
|
78
|
+
expect(CSS).toContain("--ease-window: cubic-bezier(0.85, 0.14, 0.14, 0.85)");
|
|
79
|
+
// the sol aliases ride the underlying motion tokens — both stay pinned
|
|
80
|
+
expect(CSS).toContain("--sol-micro: var(--dur-1)");
|
|
81
|
+
expect(CSS).toContain("--dur-1: 150ms");
|
|
82
|
+
expect(CSS).toContain("--sol-ease-out: var(--ease-micro)");
|
|
83
|
+
expect(CSS).toContain("--ease-micro: cubic-bezier(0.22, 1, 0.36, 1)");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("keeps the trylle button tone stack (the real site recipe)", () => {
|
|
87
|
+
expect(CSS).toContain("inset 0 1.2px 0 0 rgb(255 255 255 / 45%)");
|
|
88
|
+
expect(CSS).toContain("inset 0 8px 14px -6px rgb(255 255 255 / 22%)");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("keeps the two-accent contract (the getry violet identity beside the windows accent)", () => {
|
|
92
|
+
expect(CSS).toContain("--violet-500: #8b5cf6");
|
|
93
|
+
expect(CSS).toContain("--accent: var(--violet-500)");
|
|
94
|
+
expect(CSS).toContain("--win-accent: var(--color-win)");
|
|
95
|
+
expect(CSS).toContain("--color-win: #4cc2ff");
|
|
96
|
+
expect(CSS).toContain("--focus: var(--color-win)");
|
|
97
|
+
expect(MARK).toContain("#8b5cf6");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("ships no banned hue in the stylesheet, the entry, the mark or the icons", () => {
|
|
101
|
+
for (const source of [CSS, ENTRY, MARK, FAVICON, ICON, MANIFEST]) {
|
|
102
|
+
for (const banned of BANNED) {
|
|
103
|
+
expect(banned.test(source), `banned hue ${banned} found`).toBe(false);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
expect(CSS.includes("#60a5fa"), "the residual sky retired").toBe(false);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("design contract — the windows hands", () => {
|
|
111
|
+
it("pops the pins with the windows bounce (the one lawful scale(0))", () => {
|
|
112
|
+
const pin = rules(CSS).match(/@keyframes dtPopIntro \{(?:[^{}]|\{[^}]*\})*\}/);
|
|
113
|
+
expect(pin, "the dtPopIntro keyframe is missing").not.toBeNull();
|
|
114
|
+
expect(pin?.[0]).toContain("scale(1.125)");
|
|
115
|
+
expect(pin?.[0]).toContain("scale(0.725)");
|
|
116
|
+
expect(/scale\(0\)/.test(withoutPinLaw(CSS)), "a scale(0) entry rides outside dtPopIntro").toBe(false);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("presses the pins and the icon buttons at scale .7 on 100ms", () => {
|
|
120
|
+
expect(rules(CSS)).toContain(".railnav__item:active { transform: scale(0.7); }");
|
|
121
|
+
expect(rules(CSS)).toContain(".winapp__theme:active { transform: scale(0.7); }");
|
|
122
|
+
expect(CSS).toContain("--dur-press: 100ms");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("answers the direct url full-bleed — the window chrome lives only inside the os", () => {
|
|
126
|
+
/* the family standalone law: an app accessed by its own url is the
|
|
127
|
+
normal application, one hundred percent — the shell renders
|
|
128
|
+
shell--standalone with no window chrome of its own (no title bar, no
|
|
129
|
+
captions, no resize handles, no inactive law, no entry scale), while
|
|
130
|
+
the framed contract keeps the bare stage for the os power clip */
|
|
131
|
+
expect(SHELL).toContain("shell--standalone");
|
|
132
|
+
expect(SHELL).toContain("shell--embedded");
|
|
133
|
+
expect(SHELL).not.toMatch(
|
|
134
|
+
/className="appframe"|winapp__title|winapp__cap|winapp__rz|winchip|setPointerCapture|data-inactive|RESIZE_EDGES/,
|
|
135
|
+
);
|
|
136
|
+
expect(CSS).toMatch(/\.shell--standalone \{[^}]*height: 100vh/);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("rides the win11 scrollbar beside the standalone stage", () => {
|
|
140
|
+
expect(CSS).toContain("*:hover::-webkit-scrollbar-thumb");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("pins the footer to the window floor (the sticky-footer law)", () => {
|
|
144
|
+
expect(CSS).toMatch(/\.winapp__stage > main\.shell \{[^}]*flex-direction: column/);
|
|
145
|
+
expect(CSS).toMatch(/\.r3-quad \{[^}]*margin: auto/);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("keeps tabular numerals on the platform numbers", () => {
|
|
149
|
+
expect(CSS).toContain("font-variant-numeric: tabular-nums");
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe("design contract — the banned list", () => {
|
|
154
|
+
it("keeps the pill out of the stylesheet (no 999px, no stadium radius)", () => {
|
|
155
|
+
expect(CSS.includes("999px"), "the pill radius is retired").toBe(false);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("never ships an all-properties transition", () => {
|
|
159
|
+
expect(/transition:\s*all/.test(rules(CSS)), "transition: all is a reprovação").toBe(false);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe("design contract — the guards", () => {
|
|
164
|
+
it("carries the reduced-motion hard stop as the last word", () => {
|
|
165
|
+
const blocks = CSS.match(/@media \(prefers-reduced-motion: reduce\)/g) ?? [];
|
|
166
|
+
expect(blocks.length >= 1, "the universal hard stop is missing").toBe(true);
|
|
167
|
+
expect(CSS).toMatch(
|
|
168
|
+
/@media \(prefers-reduced-motion: reduce\) \{\s*\*,\s*\*::before,\s*\*::after \{[^}]*animation-duration: 0\.01ms !important/,
|
|
169
|
+
);
|
|
170
|
+
const lastBlock = CSS.lastIndexOf("@media");
|
|
171
|
+
const hardStop = CSS.lastIndexOf("@media (prefers-reduced-motion: reduce)");
|
|
172
|
+
expect(lastBlock, "a media block rides after the reduced-motion stop").toBe(hardStop);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("mirrors the media kills into the in-session data-motion switch", () => {
|
|
176
|
+
expect(CSS.includes('[data-motion="reduced"]'), "the settings switch has no stylesheet guards").toBe(true);
|
|
177
|
+
expect(CSS).toMatch(
|
|
178
|
+
/\[data-motion="reduced"\] \*,\s*\[data-motion="reduced"\] \*::before,\s*\[data-motion="reduced"\] \*::after \{[^}]*animation-duration: 0\.01ms !important/,
|
|
179
|
+
);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("carries the reduced-transparency floors", () => {
|
|
183
|
+
const floors = CSS.match(/@media \(prefers-reduced-transparency: reduce\)/g) ?? [];
|
|
184
|
+
expect(floors.length >= 1, "the transparency floors are missing").toBe(true);
|
|
185
|
+
expect(CSS).toContain("backdrop-filter: none");
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
describe("flow contract — intro → onboarding → the platform", () => {
|
|
190
|
+
it("mounts every route of the application (entry flow before the fundamentals)", () => {
|
|
191
|
+
expect(TOML).toMatch(/pages = \["home", "versions", "thinking", "sessions", "notfound"\]/);
|
|
192
|
+
for (const route of ['"/intro"', '"/onboarding"', '"/versions"', '"/thinking"', '"/sessions"', '"/404"']) {
|
|
193
|
+
expect(SOL.includes(`path=${route}`), `route ${route} must mount`).toBe(true);
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("gates the home anchor on the seen state before anything shows", () => {
|
|
198
|
+
/* the widget surface law: the dotted widget name is written exactly
|
|
199
|
+
once (inside Sol/widgets/index.ts) and the page answers the surface
|
|
200
|
+
import with its canonical alias — never the dotted path again */
|
|
201
|
+
expect(HOME).toMatch(/import \{ HomeEntrygate as EntryGate \} from "\.\.\/widgets"/);
|
|
202
|
+
expect(GATE).toContain('navigate("/intro", { replace: true })');
|
|
203
|
+
expect(GATE).toContain('navigate("/onboarding", { replace: true })');
|
|
204
|
+
expect(GATE).toContain("flowthrough");
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("hands the intro directly to the onboarding and marks it seen", () => {
|
|
208
|
+
expect(INTRO).toMatch(/from "\.\.\/\.\.\/onboardingstate\.ts"/);
|
|
209
|
+
expect(INTRO).toContain("markIntroSeen()");
|
|
210
|
+
expect(INTRO).toContain('"/onboarding"');
|
|
211
|
+
expect(INTRO).not.toContain("sessionStorage");
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("marks the onboarding seen on finish and skip alike", () => {
|
|
215
|
+
expect(ONBOARDING).toContain("markOnboardingSeen()");
|
|
216
|
+
expect(ONBOARDING).toContain('className="winonboard__skip"');
|
|
217
|
+
expect(ONBOARDING).toContain('navigate("/")');
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("consults the platform database over https with the host law", () => {
|
|
221
|
+
expect(STATE).toContain("VITE_ONBOARDING_URL");
|
|
222
|
+
expect(STATE).toContain("hostallowed");
|
|
223
|
+
expect(STATE).toContain("getry.onboarding.seen");
|
|
224
|
+
expect(STATE).toContain("mergeStage");
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
describe("db contract — the seen state catalogued", () => {
|
|
229
|
+
it("declares the OnboardingState kind on the prisma schema", () => {
|
|
230
|
+
expect(SCHEMA).toMatch(/model OnboardingState \{/);
|
|
231
|
+
expect(SCHEMA).toMatch(/visitor\s+String\s+@unique/);
|
|
232
|
+
expect(SCHEMA).toMatch(/introseen\s+Boolean\s+@default\(false\)/);
|
|
233
|
+
expect(SCHEMA).toMatch(/onboardingseen\s+Boolean\s+@default\(false\)/);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("mirrors the kind on the sqlite layer with prepared statements only", () => {
|
|
237
|
+
expect(DB).toContain("CREATE TABLE IF NOT EXISTS onboarding_states");
|
|
238
|
+
expect(DB).toContain("export async function getonboardingstate");
|
|
239
|
+
expect(DB).toContain("export async function setonboardingstage");
|
|
240
|
+
expect(DB).toContain("ON CONFLICT(visitor) DO UPDATE SET");
|
|
241
|
+
expect(DB).toContain("DATABASE_URL");
|
|
242
|
+
// never concatenated SQL: the upsert binds every value
|
|
243
|
+
expect(DB).not.toMatch(/VALUES \(\$\{/);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("keeps the session seeds and the https accessors of the static build", () => {
|
|
247
|
+
expect(DB).toContain("export async function listkeys");
|
|
248
|
+
expect(DB).toContain("export async function listmessages");
|
|
249
|
+
expect(DB).toContain("KEYSEED");
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
describe("entry contract — the source face", () => {
|
|
254
|
+
it("keeps the entry on the source face (./App.tsx, canonical assets, no hashed ghosts)", () => {
|
|
255
|
+
expect(ENTRY).toMatch(/script[^>]*\.\/App\.tsx/);
|
|
256
|
+
expect(/-[0-9a-f]{8}\.(js|css)/.test(ENTRY), "no hashed bundle rides the entry").toBe(false);
|
|
257
|
+
expect(ENTRY).toMatch(/href="\.\/favicon\.svg"/);
|
|
258
|
+
expect(ENTRY).toMatch(/href="\.\/manifest\.webmanifest"/);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("renders the family row on the landing footer through the familyurl contract", () => {
|
|
262
|
+
expect(HOME).toMatch(/familyrow\(\)/);
|
|
263
|
+
expect(HOME).toMatch(/familyurl\(member\.slug\)/);
|
|
264
|
+
});
|
|
265
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// # family.test — the family table the chrome rides, runnable with the
|
|
2
|
+
// vitest runner:
|
|
3
|
+
// pnpm test
|
|
4
|
+
// Every test watches the promise the row makes: the eight siblings beside
|
|
5
|
+
// getry are listed once, every slug resolves through the familyurl contract
|
|
6
|
+
// into a climb-one-level url, every member carries the spec §12 accent with
|
|
7
|
+
// its own dot ink, and no accent ever collides with the banned palette.
|
|
8
|
+
import assert from "node:assert/strict";
|
|
9
|
+
import { describe, it } from "vitest";
|
|
10
|
+
import { FAMILY_ACCENTS, FAMILY_SLUGS, familyrow } from "../family.ts";
|
|
11
|
+
import { familyurl } from "../familyurl.ts";
|
|
12
|
+
|
|
13
|
+
const BANNED = new Set(["#3b82f6", "#2563eb", "#f97316"]);
|
|
14
|
+
|
|
15
|
+
describe("family table", () => {
|
|
16
|
+
it("lists the eight siblings beside getry, never getry itself", () => {
|
|
17
|
+
assert.deepEqual(FAMILY_SLUGS, [
|
|
18
|
+
"argan",
|
|
19
|
+
"cadria",
|
|
20
|
+
"debonair",
|
|
21
|
+
"forge",
|
|
22
|
+
"foundry",
|
|
23
|
+
"saddle",
|
|
24
|
+
"stealthhead",
|
|
25
|
+
"vault",
|
|
26
|
+
]);
|
|
27
|
+
assert.ok(!FAMILY_SLUGS.includes("getry"));
|
|
28
|
+
assert.ok(!FAMILY_SLUGS.includes("devthink"));
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("answers an accent for every listed sibling", () => {
|
|
32
|
+
for (const slug of FAMILY_SLUGS) {
|
|
33
|
+
const member = FAMILY_ACCENTS[slug];
|
|
34
|
+
assert.ok(member, `${slug} must carry an accent`);
|
|
35
|
+
assert.equal(member.slug, slug);
|
|
36
|
+
assert.match(member.accent, /^#[0-9a-f]{6}$/);
|
|
37
|
+
assert.match(member.dotInk, /^#[0-9a-f]{6}$/);
|
|
38
|
+
}
|
|
39
|
+
// the table holds exactly the siblings — no orphan accents
|
|
40
|
+
assert.deepEqual(Object.keys(FAMILY_ACCENTS).sort(), [...FAMILY_SLUGS].sort());
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("resolves every member through the familyurl contract", () => {
|
|
44
|
+
for (const slug of FAMILY_SLUGS) {
|
|
45
|
+
assert.equal(familyurl(slug), `../${slug}/`);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("strips decorative slashes and keeps the body verbatim across apps", () => {
|
|
50
|
+
assert.equal(familyurl("/argan/"), "../argan/");
|
|
51
|
+
// the consolidated body: plain concatenation, the family variant A
|
|
52
|
+
assert.equal(familyurl.toString().includes('"../" +'), true);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("keeps the spec §12 accents stable", () => {
|
|
56
|
+
assert.equal(FAMILY_ACCENTS.argan.accent, "#34d8a8");
|
|
57
|
+
assert.equal(FAMILY_ACCENTS.cadria.accent, "#f472b6");
|
|
58
|
+
assert.equal(FAMILY_ACCENTS.debonair.accent, "#d9962e");
|
|
59
|
+
assert.equal(FAMILY_ACCENTS.forge.accent, "#a2cb3a");
|
|
60
|
+
assert.equal(FAMILY_ACCENTS.foundry.accent, "#2fb8b5");
|
|
61
|
+
assert.equal(FAMILY_ACCENTS.saddle.accent, "#e86f2d");
|
|
62
|
+
assert.equal(FAMILY_ACCENTS.stealthhead.accent, "#e8563f");
|
|
63
|
+
assert.equal(FAMILY_ACCENTS.vault.accent, "#a3d7e6");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("never ships a banned hue in the row", () => {
|
|
67
|
+
for (const member of familyrow()) {
|
|
68
|
+
assert.ok(!BANNED.has(member.accent), `${member.slug} rides a banned accent`);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("builds the rail row in slug order without gaps", () => {
|
|
73
|
+
const row = familyrow();
|
|
74
|
+
assert.equal(row.length, FAMILY_SLUGS.length);
|
|
75
|
+
assert.deepEqual(
|
|
76
|
+
row.map((member) => member.slug),
|
|
77
|
+
[...FAMILY_SLUGS],
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("keeps every dot ink readable on its own accent (the ink is darker)", () => {
|
|
82
|
+
for (const member of familyrow()) {
|
|
83
|
+
const lum = (hex: string): number => {
|
|
84
|
+
const channel = (offset: number) => Number.parseInt(hex.slice(offset, offset + 2), 16) / 255;
|
|
85
|
+
return 0.2126 * channel(1) + 0.7152 * channel(3) + 0.0722 * channel(5);
|
|
86
|
+
};
|
|
87
|
+
assert.ok(lum(member.dotInk) < lum(member.accent), `${member.slug} dot ink must be darker than its accent`);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// # onboardingstate.test — the pure contract of the seen flags: the host
|
|
2
|
+
// validation of the platform base (https only, public hosts only, mapped
|
|
3
|
+
// ipv6 normalized) and the seen-wins stage merge the accessor and the
|
|
4
|
+
// pages share. runnable with vitest: pnpm vitest run tests/onboardingstate.test.ts
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import { describe, it } from "vitest";
|
|
7
|
+
import { hostallowed, mergeStage } from "../onboardingstate.ts";
|
|
8
|
+
|
|
9
|
+
describe("onboarding state", () => {
|
|
10
|
+
it("accepts public https bases", () => {
|
|
11
|
+
assert.equal(hostallowed("https://gateway.wenathlan.dev"), true);
|
|
12
|
+
assert.equal(hostallowed("https://gateway.wenathlan.dev/"), true);
|
|
13
|
+
assert.equal(hostallowed("https://getry.example.com:8443"), true);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("refuses plaintext, localhost, loopback and every private or reserved range", () => {
|
|
17
|
+
const refused = [
|
|
18
|
+
"http://gateway.wenathlan.dev",
|
|
19
|
+
"https://localhost",
|
|
20
|
+
"https://localhost:8787",
|
|
21
|
+
"https://app.localhost",
|
|
22
|
+
"https://box.local",
|
|
23
|
+
"https://service.internal",
|
|
24
|
+
"https://gateway.home.arpa",
|
|
25
|
+
"https://127.0.0.1",
|
|
26
|
+
"https://0.0.0.0",
|
|
27
|
+
"https://10.0.0.9",
|
|
28
|
+
"https://172.16.0.1",
|
|
29
|
+
"https://172.31.255.255",
|
|
30
|
+
"https://192.168.1.4",
|
|
31
|
+
"https://169.254.10.10",
|
|
32
|
+
"https://100.64.0.1",
|
|
33
|
+
"https://192.0.2.7",
|
|
34
|
+
"https://198.51.100.7",
|
|
35
|
+
"https://203.0.113.9",
|
|
36
|
+
"https://198.18.0.4",
|
|
37
|
+
"https://224.0.0.1",
|
|
38
|
+
"https://[::1]",
|
|
39
|
+
"https://[::]",
|
|
40
|
+
"https://[::ffff:127.0.0.1]",
|
|
41
|
+
"https://[fd00::1]",
|
|
42
|
+
"https://[fe80::1]",
|
|
43
|
+
"not a url",
|
|
44
|
+
"",
|
|
45
|
+
];
|
|
46
|
+
for (const base of refused) assert.equal(hostallowed(base), false, base);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("normalizes the mapped ipv6 spelling the url parser produces", () => {
|
|
50
|
+
// `https://[::ffff:8.8.8.8]` arrives at the validator as the hex form
|
|
51
|
+
assert.equal(hostallowed("https://[::ffff:0808:0808]"), true);
|
|
52
|
+
assert.equal(hostallowed("https://[::ffff:7f00:1]"), false);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("merges stages seen-wins — a stage never unsees", () => {
|
|
56
|
+
const fresh = { introseen: false, onboardingseen: false };
|
|
57
|
+
assert.deepEqual(mergeStage(fresh, "intro"), { introseen: true, onboardingseen: false });
|
|
58
|
+
assert.deepEqual(mergeStage(fresh, "onboarding"), { introseen: true, onboardingseen: true });
|
|
59
|
+
const half = { introseen: true, onboardingseen: false };
|
|
60
|
+
assert.deepEqual(mergeStage(half, "onboarding"), { introseen: true, onboardingseen: true });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("never mutates the state it merges", () => {
|
|
64
|
+
const state = { introseen: false, onboardingseen: false };
|
|
65
|
+
mergeStage(state, "onboarding");
|
|
66
|
+
assert.deepEqual(state, { introseen: false, onboardingseen: false });
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
|
|
3
|
+
set -euo pipefail
|
|
4
|
+
|
|
5
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")/../.zscripts" && pwd)"
|
|
6
|
+
TEST_ROOT="$(mktemp -d)"
|
|
7
|
+
trap 'rm -rf "$TEST_ROOT"' EXIT
|
|
8
|
+
|
|
9
|
+
PROJECT_DIR="$TEST_ROOT/project"
|
|
10
|
+
BUILD_DIR="$TEST_ROOT/build"
|
|
11
|
+
mkdir -p \
|
|
12
|
+
"$PROJECT_DIR/scripts" \
|
|
13
|
+
"$PROJECT_DIR/.venv" \
|
|
14
|
+
"$BUILD_DIR/next-service-dist"
|
|
15
|
+
|
|
16
|
+
cat >"$PROJECT_DIR/requirements.txt" <<'EOF'
|
|
17
|
+
EOF
|
|
18
|
+
cat >"$PROJECT_DIR/scripts/report.py" <<'EOF'
|
|
19
|
+
print("report")
|
|
20
|
+
EOF
|
|
21
|
+
cat >"$PROJECT_DIR/.venv/should-not-copy.py" <<'EOF'
|
|
22
|
+
ignored
|
|
23
|
+
EOF
|
|
24
|
+
|
|
25
|
+
PROJECT_DIR="$PROJECT_DIR" BUILD_DIR="$BUILD_DIR" \
|
|
26
|
+
bash "$SCRIPT_DIR/python-runtime-build.sh"
|
|
27
|
+
|
|
28
|
+
test -f "$BUILD_DIR/next-service-dist/scripts/report.py"
|
|
29
|
+
test ! -e "$BUILD_DIR/next-service-dist/.venv/should-not-copy.py"
|
|
30
|
+
|
|
31
|
+
PYPROJECT_DIR="$TEST_ROOT/pyproject-app"
|
|
32
|
+
PYPROJECT_BUILD="$TEST_ROOT/pyproject-build"
|
|
33
|
+
mkdir -p "$PYPROJECT_DIR" "$PYPROJECT_BUILD/next-service-dist"
|
|
34
|
+
cat >"$PYPROJECT_DIR/pyproject.toml" <<'EOF'
|
|
35
|
+
[project]
|
|
36
|
+
name = "deploy-test"
|
|
37
|
+
version = "0.1.0"
|
|
38
|
+
requires-python = ">=3.12"
|
|
39
|
+
dependencies = []
|
|
40
|
+
EOF
|
|
41
|
+
cat >"$PYPROJECT_DIR/app.py" <<'EOF'
|
|
42
|
+
print("pyproject app")
|
|
43
|
+
EOF
|
|
44
|
+
|
|
45
|
+
PROJECT_DIR="$PYPROJECT_DIR" BUILD_DIR="$PYPROJECT_BUILD" \
|
|
46
|
+
bash "$SCRIPT_DIR/python-runtime-build.sh"
|
|
47
|
+
test -f "$PYPROJECT_BUILD/next-service-dist/app.py"
|
|
48
|
+
test -f "$PYPROJECT_BUILD/python-runtime/requirements.txt"
|
|
49
|
+
|
|
50
|
+
NODE_ONLY_PROJECT="$TEST_ROOT/node-only"
|
|
51
|
+
NODE_ONLY_BUILD="$TEST_ROOT/node-only-build"
|
|
52
|
+
mkdir -p "$NODE_ONLY_PROJECT/mini-services/python-worker" "$NODE_ONLY_BUILD"
|
|
53
|
+
cat >"$NODE_ONLY_PROJECT/package.json" <<'EOF'
|
|
54
|
+
{"name": "node-only"}
|
|
55
|
+
EOF
|
|
56
|
+
cat >"$NODE_ONLY_PROJECT/mini-services/python-worker/main.py" <<'EOF'
|
|
57
|
+
print("unsupported preview-only service")
|
|
58
|
+
EOF
|
|
59
|
+
|
|
60
|
+
PROJECT_DIR="$NODE_ONLY_PROJECT" BUILD_DIR="$NODE_ONLY_BUILD" \
|
|
61
|
+
bash "$SCRIPT_DIR/python-runtime-build.sh"
|
|
62
|
+
test ! -e "$NODE_ONLY_BUILD/python-runtime"
|
|
63
|
+
|
|
64
|
+
echo "python runtime build tests passed"
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
|
|
3
|
+
set -euo pipefail
|
|
4
|
+
|
|
5
|
+
RUNNER_IMAGE="${RUNNER_IMAGE:-z-ai-python-deploy-runner:test}"
|
|
6
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")/../.zscripts" && pwd)"
|
|
7
|
+
TEST_ROOT="$(mktemp -d)"
|
|
8
|
+
trap 'rm -rf "$TEST_ROOT"' EXIT
|
|
9
|
+
|
|
10
|
+
PROJECT_DIR="$TEST_ROOT/project"
|
|
11
|
+
BUILD_DIR="$TEST_ROOT/build"
|
|
12
|
+
mkdir -p "$PROJECT_DIR" "$BUILD_DIR/next-service-dist"
|
|
13
|
+
|
|
14
|
+
cat >"$PROJECT_DIR/requirements.txt" <<'EOF'
|
|
15
|
+
idna==3.10
|
|
16
|
+
EOF
|
|
17
|
+
cat >"$PROJECT_DIR/check_runtime.py" <<'EOF'
|
|
18
|
+
import idna
|
|
19
|
+
|
|
20
|
+
assert idna.__version__ == "3.10"
|
|
21
|
+
print("python artifact import passed")
|
|
22
|
+
EOF
|
|
23
|
+
|
|
24
|
+
PROJECT_DIR="$PROJECT_DIR" BUILD_DIR="$BUILD_DIR" \
|
|
25
|
+
bash "$SCRIPT_DIR/python-runtime-build.sh"
|
|
26
|
+
|
|
27
|
+
docker run --rm \
|
|
28
|
+
--entrypoint sh \
|
|
29
|
+
-v "$BUILD_DIR:/app" \
|
|
30
|
+
"$RUNNER_IMAGE" \
|
|
31
|
+
-c 'PYTHONPATH=/app/python-runtime/site-packages:/app/next-service-dist python /app/next-service-dist/check_runtime.py'
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* routerotation.test.ts — honest unit tests for the weighted rotation,
|
|
3
|
+
* runnable with the vitest runner:
|
|
4
|
+
* pnpm test
|
|
5
|
+
*/
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import { describe, it } from "vitest";
|
|
8
|
+
import {
|
|
9
|
+
healthyroutes,
|
|
10
|
+
type ModelRoute,
|
|
11
|
+
newledger,
|
|
12
|
+
notefailure,
|
|
13
|
+
notesuccess,
|
|
14
|
+
pickroute,
|
|
15
|
+
ROTATIONDEFAULTS,
|
|
16
|
+
remainingcooldown,
|
|
17
|
+
} from "../routerotation.ts";
|
|
18
|
+
|
|
19
|
+
/** the three-route pool the tests rotate over. */
|
|
20
|
+
const POOL: ModelRoute[] = [
|
|
21
|
+
{ id: "r1", provider: "zai", model: "glm-5.3-flash", weight: 2 },
|
|
22
|
+
{ id: "r2", provider: "nvidia", model: "deepseek-v4", weight: 1 },
|
|
23
|
+
{ id: "r3", provider: "openrouter", model: "free-tag", weight: 1 },
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
describe("routerotation weighted pick", () => {
|
|
27
|
+
it("never answers a cooled-down or zero-weight route", () => {
|
|
28
|
+
const ledger = newledger();
|
|
29
|
+
const routes = [...POOL, { id: "r0", provider: "babeltown", model: "paused", weight: 0 }];
|
|
30
|
+
notefailure(ledger, "r1", { allowedFails: 0 }, 1000);
|
|
31
|
+
const picks = new Set(Array.from({ length: 200 }, () => pickroute(routes, ledger, Math.random, 1000)?.id));
|
|
32
|
+
assert.ok(!picks.has("r1"), "cooled route must not be picked");
|
|
33
|
+
assert.ok(!picks.has("r0"), "zero-weight route must not be picked");
|
|
34
|
+
assert.ok(picks.has("r2") && picks.has("r3"));
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("distributes the draws proportionally to the weights", () => {
|
|
38
|
+
const ledger = newledger();
|
|
39
|
+
const draws = Array.from({ length: 6000 }, () => pickroute(POOL, ledger, Math.random)?.id);
|
|
40
|
+
const r1 = draws.filter((id) => id === "r1").length;
|
|
41
|
+
const r2 = draws.filter((id) => id === "r2").length;
|
|
42
|
+
assert.ok(Math.abs(r1 / r2 - 2) < 0.2, `expected r1 ≈ 2×r2, got ${r1} vs ${r2}`);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("answers null when every route is cooling down", () => {
|
|
46
|
+
const ledger = newledger();
|
|
47
|
+
for (const route of POOL) notefailure(ledger, route.id, { allowedFails: 0 }, 1000);
|
|
48
|
+
assert.equal(pickroute(POOL, ledger, Math.random, 1000), null);
|
|
49
|
+
assert.equal(pickroute(POOL, ledger, () => 0, 1_000 + ROTATIONDEFAULTS.cooldownMs + 1)?.id, "r1");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("skips the ledger route through the weighted walk without a draw on it", () => {
|
|
53
|
+
const ledger = newledger();
|
|
54
|
+
notefailure(ledger, "r1", { allowedFails: 0 }, 1000);
|
|
55
|
+
// rand() = 0 always lands on the first healthy route: r2
|
|
56
|
+
assert.equal(pickroute(POOL, ledger, () => 0, 1000)?.id, "r2");
|
|
57
|
+
// rand() just below 1 always lands on the last healthy route: r3
|
|
58
|
+
assert.equal(pickroute(POOL, ledger, () => 0.9999, 1000)?.id, "r3");
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("routerotation cooldown ledger", () => {
|
|
63
|
+
it("tolerates allowedFails failures before cooling down", () => {
|
|
64
|
+
const ledger = newledger();
|
|
65
|
+
notefailure(ledger, "r1", {}, 1000);
|
|
66
|
+
notefailure(ledger, "r1", {}, 1000);
|
|
67
|
+
notefailure(ledger, "r1", {}, 1000);
|
|
68
|
+
assert.deepEqual(ledger.get("r1"), { until: 0, failures: 3 });
|
|
69
|
+
assert.equal(remainingcooldown(ledger.get("r1"), 1000), 0);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("buys the cooldown after the budget and escalates per further failure", () => {
|
|
73
|
+
const ledger = newledger();
|
|
74
|
+
for (let index = 0; index < 3; index++) notefailure(ledger, "r1", {}, 1000);
|
|
75
|
+
notefailure(ledger, "r1", {}, 1000);
|
|
76
|
+
assert.equal(remainingcooldown(ledger.get("r1"), 1000), ROTATIONDEFAULTS.cooldownMs);
|
|
77
|
+
notefailure(ledger, "r1", {}, 1000);
|
|
78
|
+
assert.equal(
|
|
79
|
+
remainingcooldown(ledger.get("r1"), 1000),
|
|
80
|
+
ROTATIONDEFAULTS.cooldownMs + ROTATIONDEFAULTS.cooldownStepMs,
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("clamps the escalation at maxCooldownMs", () => {
|
|
85
|
+
const ledger = newledger();
|
|
86
|
+
for (let index = 0; index < 20; index++) notefailure(ledger, "r1", {}, 1000);
|
|
87
|
+
assert.equal(remainingcooldown(ledger.get("r1"), 1000), ROTATIONDEFAULTS.maxCooldownMs);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("clears the ledger on success", () => {
|
|
91
|
+
const ledger = newledger();
|
|
92
|
+
notefailure(ledger, "r1", { allowedFails: 0 }, 1000);
|
|
93
|
+
notesuccess(ledger, "r1");
|
|
94
|
+
assert.equal(ledger.size, 0);
|
|
95
|
+
assert.equal(healthyroutes(POOL, ledger, 1000).length, 3);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* streamfallback.test.ts — honest unit tests for the stream fallback,
|
|
3
|
+
* runnable with the vitest runner:
|
|
4
|
+
* pnpm test
|
|
5
|
+
*/
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import { describe, it } from "vitest";
|
|
8
|
+
import type { ApiKeyRow } from "../db.ts";
|
|
9
|
+
import {
|
|
10
|
+
applyerrortokey,
|
|
11
|
+
classifystatus,
|
|
12
|
+
errorfromresponse,
|
|
13
|
+
FALLBACKDEFAULTS,
|
|
14
|
+
type FallbackRoute,
|
|
15
|
+
orderchain,
|
|
16
|
+
runwithfallback,
|
|
17
|
+
StreamError,
|
|
18
|
+
verdictfor,
|
|
19
|
+
} from "../streamfallback.ts";
|
|
20
|
+
|
|
21
|
+
/** the two-route chain the tests walk. */
|
|
22
|
+
const CHAIN: FallbackRoute[] = [
|
|
23
|
+
{ id: "primary", priority: 0, enabled: true },
|
|
24
|
+
{ id: "backup", priority: 1, enabled: true },
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
/** builds a key row for the applyerrortokey cases. */
|
|
28
|
+
function keyrow(overrides: Partial<ApiKeyRow> = {}): ApiKeyRow {
|
|
29
|
+
return {
|
|
30
|
+
id: "key-01",
|
|
31
|
+
provider: "nvidia",
|
|
32
|
+
key: "nvapi-sample-masked-0001",
|
|
33
|
+
label: "nim rotation 01",
|
|
34
|
+
active: true,
|
|
35
|
+
status: "active",
|
|
36
|
+
useCount: 10,
|
|
37
|
+
rotationCount: 2,
|
|
38
|
+
errorCount: 0,
|
|
39
|
+
rateLimitHit: false,
|
|
40
|
+
createdAt: "2026-09-12T10:00:00.000Z",
|
|
41
|
+
expiresAt: null,
|
|
42
|
+
...overrides,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe("streamfallback error catalog", () => {
|
|
47
|
+
it("classifies every status the house answers for", () => {
|
|
48
|
+
assert.equal(classifystatus(0), "transport");
|
|
49
|
+
assert.equal(classifystatus(401), "unauthorized");
|
|
50
|
+
assert.equal(classifystatus(403), "forbidden");
|
|
51
|
+
assert.equal(classifystatus(404), "notfound");
|
|
52
|
+
assert.equal(classifystatus(408), "timeout");
|
|
53
|
+
assert.equal(classifystatus(410), "gone");
|
|
54
|
+
assert.equal(classifystatus(422), "unprocessable");
|
|
55
|
+
assert.equal(classifystatus(429), "ratelimited");
|
|
56
|
+
assert.equal(classifystatus(529), "overloaded");
|
|
57
|
+
assert.equal(classifystatus(500), "server");
|
|
58
|
+
assert.equal(classifystatus(503), "server");
|
|
59
|
+
assert.equal(classifystatus(418), "unknown");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("maps retry for transient, fallback for dead-model and abort for unknown", () => {
|
|
63
|
+
for (const code of ["transport", "timeout", "server"] as const) assert.equal(verdictfor(code), "retry");
|
|
64
|
+
for (const code of [
|
|
65
|
+
"unauthorized",
|
|
66
|
+
"forbidden",
|
|
67
|
+
"notfound",
|
|
68
|
+
"gone",
|
|
69
|
+
"unprocessable",
|
|
70
|
+
"ratelimited",
|
|
71
|
+
"overloaded",
|
|
72
|
+
] as const) {
|
|
73
|
+
assert.equal(verdictfor(code), "fallback", `expected fallback for ${code}`);
|
|
74
|
+
}
|
|
75
|
+
assert.equal(verdictfor("unknown"), "abort");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("orders the chain by priority over enabled routes only", () => {
|
|
79
|
+
const chain = orderchain([
|
|
80
|
+
{ id: "third", priority: 2, enabled: true },
|
|
81
|
+
{ id: "disabled", priority: 0, enabled: false },
|
|
82
|
+
{ id: "first", priority: 0, enabled: true },
|
|
83
|
+
]);
|
|
84
|
+
assert.deepEqual(
|
|
85
|
+
chain.map((route) => route.id),
|
|
86
|
+
["first", "third"],
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("streamfallback runner", () => {
|
|
92
|
+
it("answers the first healthy route untouched", async () => {
|
|
93
|
+
const order: string[] = [];
|
|
94
|
+
const value = await runwithfallback(CHAIN, async (route) => {
|
|
95
|
+
order.push(route.id);
|
|
96
|
+
return `ok:${route.id}`;
|
|
97
|
+
});
|
|
98
|
+
assert.equal(value, "ok:primary");
|
|
99
|
+
assert.deepEqual(order, ["primary"]);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("falls back on the catalog codes and records the trail", async () => {
|
|
103
|
+
for (const status of [401, 403, 404, 410, 422, 429, 529]) {
|
|
104
|
+
const trail: string[] = [];
|
|
105
|
+
const value = await runwithfallback(
|
|
106
|
+
CHAIN,
|
|
107
|
+
async (route) => {
|
|
108
|
+
trail.push(route.id);
|
|
109
|
+
if (route.id === "primary")
|
|
110
|
+
throw errorfromresponse(new Response(null, { status, headers: { "retry-after": "0" } }));
|
|
111
|
+
return `ok:${route.id}`;
|
|
112
|
+
},
|
|
113
|
+
{ initialDelayMs: 1 },
|
|
114
|
+
);
|
|
115
|
+
assert.equal(value, `ok:backup`, `status ${status} must fall back`);
|
|
116
|
+
assert.deepEqual(trail, ["primary", "backup"], `status ${status} must walk primary then backup`);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("retries the same route first on timeout and transport failures", async () => {
|
|
121
|
+
let tries = 0;
|
|
122
|
+
const steps: string[] = [];
|
|
123
|
+
const value = await runwithfallback(
|
|
124
|
+
CHAIN,
|
|
125
|
+
async (route) => {
|
|
126
|
+
steps.push(`${route.id}#${++tries}`);
|
|
127
|
+
if (tries === 1) throw new StreamError("upstream timeout", 408, "timeout");
|
|
128
|
+
return `ok:${route.id}`;
|
|
129
|
+
},
|
|
130
|
+
{ initialDelayMs: 1 },
|
|
131
|
+
);
|
|
132
|
+
assert.equal(value, "ok:primary");
|
|
133
|
+
assert.deepEqual(steps, ["primary#1", "primary#2"]);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("moves on when the retry budget of a route runs out", async () => {
|
|
137
|
+
let primaryTries = 0;
|
|
138
|
+
const value = await runwithfallback(
|
|
139
|
+
CHAIN,
|
|
140
|
+
async (route) => {
|
|
141
|
+
if (route.id === "primary") {
|
|
142
|
+
primaryTries += 1;
|
|
143
|
+
throw new StreamError("upstream timeout", 408, "timeout");
|
|
144
|
+
}
|
|
145
|
+
return "ok:backup";
|
|
146
|
+
},
|
|
147
|
+
{ initialDelayMs: 1, maxDelayMs: 2 },
|
|
148
|
+
);
|
|
149
|
+
assert.equal(value, "ok:backup");
|
|
150
|
+
assert.equal(primaryTries, FALLBACKDEFAULTS.retriesPerRoute + 1);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("honors the Retry-After hint over the computed backoff", async () => {
|
|
154
|
+
let waited = -1;
|
|
155
|
+
await runwithfallback(
|
|
156
|
+
CHAIN,
|
|
157
|
+
async (route) => {
|
|
158
|
+
if (route.id === "primary") throw new StreamError("upstream timeout", 408, "timeout", 0);
|
|
159
|
+
return "ok";
|
|
160
|
+
},
|
|
161
|
+
{ initialDelayMs: 50, onstep: (step) => (waited = Math.max(waited, step.delayMs)) },
|
|
162
|
+
);
|
|
163
|
+
assert.equal(waited, 0, "the hint (0ms) must win over the computed 50ms backoff");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("aborts the chain on unknown failures and throws with the trail", async () => {
|
|
167
|
+
const value = runwithfallback(CHAIN, async () => {
|
|
168
|
+
throw new Error("bug in the parser");
|
|
169
|
+
});
|
|
170
|
+
await assert.rejects(value, (error: unknown) => {
|
|
171
|
+
assert.ok(error instanceof StreamError);
|
|
172
|
+
assert.equal((error as StreamError).code, "unknown");
|
|
173
|
+
return true;
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("throws a StreamFallbackError with the full trail when every route dies", async () => {
|
|
178
|
+
const value = runwithfallback(
|
|
179
|
+
CHAIN,
|
|
180
|
+
async () => {
|
|
181
|
+
throw errorfromresponse(new Response(null, { status: 529 }));
|
|
182
|
+
},
|
|
183
|
+
{ initialDelayMs: 1, retriesPerRoute: 0 },
|
|
184
|
+
);
|
|
185
|
+
await assert.rejects(value, (error: unknown) => {
|
|
186
|
+
assert.ok(error instanceof StreamError);
|
|
187
|
+
const fallback = error as StreamError;
|
|
188
|
+
assert.equal(fallback.code, "overloaded");
|
|
189
|
+
assert.ok("trail" in fallback);
|
|
190
|
+
assert.deepEqual(
|
|
191
|
+
(fallback as { trail: { routeId: string }[] }).trail.map((step) => step.routeId),
|
|
192
|
+
["primary", "backup"],
|
|
193
|
+
);
|
|
194
|
+
return true;
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
describe("streamfallback key wire", () => {
|
|
200
|
+
it("lands failures on the key counters without mutating the input", () => {
|
|
201
|
+
const row = keyrow();
|
|
202
|
+
const after429 = applyerrortokey(row, 429);
|
|
203
|
+
assert.equal(after429.errorCount, 1);
|
|
204
|
+
assert.equal(after429.rateLimitHit, true);
|
|
205
|
+
const after500 = applyerrortokey(row, 500);
|
|
206
|
+
assert.equal(after500.errorCount, 1);
|
|
207
|
+
assert.equal(after500.rateLimitHit, false);
|
|
208
|
+
assert.equal(row.errorCount, 0, "the input row must stay untouched");
|
|
209
|
+
const chain = applyerrortokey(after429, 500);
|
|
210
|
+
assert.equal(chain.errorCount, 2);
|
|
211
|
+
assert.equal(chain.rateLimitHit, true, "a prior 429 flag survives other failures");
|
|
212
|
+
});
|
|
213
|
+
});
|