Skip to content

Commit 7ffe46c

Browse files
committed
feat(image-generator): boot the binary on R2 in the smoke test
1 parent da887f4 commit 7ffe46c

7 files changed

Lines changed: 184 additions & 7 deletions

File tree

image-generator/README.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,9 +59,12 @@ AWS SDK against a fake S3 endpoint), and Workers AI (through real `fetch` agains
5959
a fake Cloudflare endpoint) all run over real sockets, and `pnpm smoke` boots the
6060
built `dist/main.js` against those fakes and walks the routes a client uses.
6161

62-
`CF_API_BASE` is what makes that possible: it overrides the Workers AI endpoint,
63-
so the shipped binary can be driven without credentials, and a Cloudflare-compatible
64-
gateway can be put in front in production.
62+
`CF_API_BASE` and `R2_ENDPOINT` are what make that possible: they override the
63+
Workers AI and R2 endpoints, so the shipped binary can be driven without
64+
credentials. Both have production uses too, a Cloudflare-compatible gateway and
65+
any S3-compatible store. `pnpm smoke` boots the binary twice, once on the
66+
filesystem store and once on R2, because production runs the R2 path and only the
67+
filesystem one had ever actually started.
6568

6669
What none of it can tell you is whether Cloudflare and R2 *accept* these requests.
6770
The request-shape tests spell out exactly what would be sent, so it can be compared

image-generator/env.example

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,12 @@ R2_SECRET_ACCESS_KEY=
9090
# Defaults to CF_ACCOUNT_ID; set only if the bucket lives in another account.
9191
# R2_ACCOUNT_ID=
9292

93+
# Overrides the R2 endpoint. Leave unset for real R2, which is derived from the
94+
# account id and addressed virtual-hosted style. Setting it switches to
95+
# path-style /bucket/key, which is what any S3-compatible stand-in expects, and
96+
# is how `pnpm smoke` boots the real binary against a fake bucket.
97+
# R2_ENDPOINT=http://127.0.0.1:9000
98+
9399
# Public bucket or custom-domain base URL. Set this and NFT metadata can point
94100
# image URLs straight at Cloudflare instead of routing bytes through this
95101
# service.

image-generator/scripts/smoke.mjs

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,30 @@ const main = async () => {
9393
});
9494
});
9595

96+
// Stands in for R2 so the binary's production store path actually runs.
97+
const bucket = new Map();
98+
const s3 = createServer((req, res) => {
99+
const chunks = [];
100+
req.on('data', (c) => chunks.push(c));
101+
req.on('end', () => {
102+
const key = new URL(req.url, 'http://x').pathname.replace('/pet-art/', '');
103+
if (req.method === 'PUT') {
104+
bucket.set(key, Buffer.concat(chunks));
105+
res.writeHead(200); res.end(); return;
106+
}
107+
const found = bucket.get(key);
108+
if (!found) {
109+
res.writeHead(404, { 'content-type': 'application/xml' });
110+
res.end('<?xml version="1.0"?><Error><Code>NoSuchKey</Code><Message>no</Message></Error>');
111+
return;
112+
}
113+
res.writeHead(200, { 'content-type': 'image/png' });
114+
res.end(found);
115+
});
116+
});
117+
96118
const aiPort = await listen(ai);
119+
const s3Port = await listen(s3);
97120
const rpcPort = await listen(rpc);
98121
const artRoot = await mkdtemp(join(tmpdir(), 'smoke-art-'));
99122
const port = 8931;
@@ -212,9 +235,55 @@ const main = async () => {
212235
const solana = await warm(['--chain=solana', '--from=1', '--to=3']);
213236
check('warming solana by id range fails', solana.code, 1);
214237
check('and says why', /not addressed by number/.test(solana.out), true);
238+
// Production runs on R2, and until now only the filesystem store had ever
239+
// booted: a mistake in the r2 branch of storeFactory or its config would
240+
// have surfaced for the first time in production.
241+
console.log('\nr2 store:');
242+
const r2Env = {
243+
...childEnv,
244+
PORT: String(port + 1),
245+
PUBLIC_BASE_URL: `http://127.0.0.1:${port + 1}`,
246+
IMAGE_STORE: 'r2',
247+
R2_BUCKET: 'pet-art',
248+
R2_ACCESS_KEY_ID: 'key',
249+
R2_SECRET_ACCESS_KEY: 'secret',
250+
R2_ENDPOINT: `http://127.0.0.1:${s3Port}`,
251+
R2_PUBLIC_BASE_URL: 'https://cdn.example',
252+
};
253+
const r2Server = spawn(process.execPath, ['dist/main.js'], { env: r2Env, stdio: ['ignore', 'pipe', 'pipe'] });
254+
const r2Logs = [];
255+
r2Server.stdout.on('data', (d) => r2Logs.push(String(d)));
256+
r2Server.stderr.on('data', (d) => r2Logs.push(String(d)));
257+
const r2Base = `http://127.0.0.1:${port + 1}`;
258+
259+
try {
260+
let alive = false;
261+
for (let i = 0; i < 60 && !alive; i++) {
262+
try { await fetch(`${r2Base}/health`); alive = true; } catch { await new Promise((r) => setTimeout(r, 100)); }
263+
}
264+
check('boots on the r2 store', alive, true);
265+
if (!alive) console.log(r2Logs.join(''));
266+
267+
check('readiness reaches the bucket', (await fetch(`${r2Base}/ready`)).status, 200);
268+
269+
const before = generations;
270+
// Redirect rather than proxy: with a public bucket the bytes never
271+
// pass through the service.
272+
const img = await fetch(`${r2Base}/image/evm/5.png`, { redirect: 'manual' });
273+
check('image 302s to the public bucket', img.status, 302);
274+
check('and points at the configured domain', (img.headers.get('location') ?? '').startsWith('https://cdn.example/art/v1/'), true);
275+
check('generated once into the bucket', generations - before, 1);
276+
check('bucket holds the image and its manifest', bucket.size, 2);
277+
278+
const again = await fetch(`${r2Base}/image/evm/5.png`, { redirect: 'manual' });
279+
check('second request still redirects', again.status, 302);
280+
check('and bills nothing more', generations - before, 1);
281+
} finally {
282+
r2Server.kill();
283+
}
215284
} finally {
216285
server.kill();
217-
await Promise.all([shut(ai), shut(rpc)]);
286+
await Promise.all([shut(ai), shut(rpc), shut(s3)]);
218287
await rm(artRoot, { recursive: true, force: true });
219288
}
220289

image-generator/src/config.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ export interface StoreSelection {
8686
secretAccessKey: string;
8787
bucket: string;
8888
publicBaseUrl?: string;
89+
endpoint?: string;
8990
};
9091
}
9192

@@ -103,6 +104,7 @@ export const loadStoreSelection = (fallback: StoreKind = 'r2'): StoreSelection =
103104
if (raw !== 'r2') return { kind: raw, root };
104105

105106
const publicBaseUrl = process.env.R2_PUBLIC_BASE_URL;
107+
const endpoint = process.env.R2_ENDPOINT;
106108
return {
107109
kind: raw,
108110
root,
@@ -112,6 +114,7 @@ export const loadStoreSelection = (fallback: StoreKind = 'r2'): StoreSelection =
112114
secretAccessKey: readRequired('R2_SECRET_ACCESS_KEY'),
113115
bucket: readRequired('R2_BUCKET'),
114116
...(publicBaseUrl ? { publicBaseUrl } : {}),
117+
...(endpoint ? { endpoint } : {}),
115118
},
116119
};
117120
};

image-generator/src/r2Store.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,12 @@ export interface R2Config {
2222
bucket: string;
2323
/** Public bucket or custom-domain base URL, when one is configured. */
2424
publicBaseUrl?: string;
25+
/**
26+
* Overrides the account endpoint. Without this the R2 path in the shipped
27+
* binary cannot be exercised anywhere but production, since the hostname is
28+
* derived from the account id. Also lets any S3-compatible store stand in.
29+
*/
30+
endpoint?: string;
2531
}
2632

2733
/** R2 ignores the region but the S3 client requires one. */
@@ -30,11 +36,15 @@ const R2_REGION = 'auto';
3036
export const createR2Client = (config: R2Config): S3Client =>
3137
new S3Client({
3238
region: R2_REGION,
33-
endpoint: `https://${config.accountId}.r2.cloudflarestorage.com`,
39+
endpoint: config.endpoint ?? `https://${config.accountId}.r2.cloudflarestorage.com`,
3440
credentials: {
3541
accessKeyId: config.accessKeyId,
3642
secretAccessKey: config.secretAccessKey,
3743
},
44+
// R2 is addressed virtual-hosted style, bucket as a subdomain of the
45+
// account endpoint. A custom endpoint is a host that has no such
46+
// subdomains, so it gets /bucket/key instead.
47+
forcePathStyle: config.endpoint !== undefined,
3848
});
3949

4050
export class R2ImageStore implements ImageStore {
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
/**
2+
* Checks the trait name tables against the game's own lists.
3+
*
4+
* Two claims in traits.ts are about *agreement with something else*, and nothing
5+
* verified either:
6+
*
7+
* - `BODY_NAMES` is indexed to match the eight passive skill archetypes, so a
8+
* pet's silhouette reads as its skill. Both are keyed by `speciesId % 8`, so if
9+
* the archetype list is ever reordered, a pet drawn as a Phoenix would have the
10+
* Tank skill. Nothing would fail; the art would just quietly stop meaning
11+
* anything.
12+
* - `ELEMENT_NAMES` is in the element wheel's order, which is what decides a
13+
* pet's palette and the `Element` trait in its metadata.
14+
*
15+
* The service keeps its own copies so it stays standalone, which is exactly why
16+
* they need checking against the originals rather than against themselves.
17+
*
18+
* Skipped when the monorepo is absent. Test-time reads, not build dependencies.
19+
*/
20+
21+
import { existsSync, readFileSync } from 'node:fs';
22+
import { join } from 'node:path';
23+
import { describe, expect, it } from 'vitest';
24+
import { BODY_NAMES, ELEMENT_NAMES } from './traits.js';
25+
26+
const SKILLS = join('..', 'shared', 'src', 'utils', 'pets', 'skills.ts');
27+
const PET_CARD = join('..', 'shared', 'src', 'utils', 'ethereum', 'petCard.ts');
28+
29+
const describeIf = (...paths: string[]) => (paths.every(existsSync) ? describe : describe.skip);
30+
31+
/**
32+
* The intended pairing, written down so a reorder on either side fails loudly
33+
* instead of silently mismatching art to skill. Index is `speciesId % 8` for both.
34+
*/
35+
const BODY_FOR_SKILL: readonly [skill: string, body: string][] = [
36+
['Tank', 'Bulwark'],
37+
['Shell', 'Shelled'],
38+
['Swift', 'Sleek'],
39+
['Cunning', 'Sly'],
40+
['Fury', 'Brute'],
41+
['Sage', 'Mystic'],
42+
['Rebirth', 'Phoenix'],
43+
['Bloodlust', 'Fanged'],
44+
];
45+
46+
describeIf(SKILLS)('body silhouettes vs the skill archetypes', () => {
47+
const source = readFileSync(SKILLS, 'utf8');
48+
const skills = [...source.matchAll(/name:\s*'([A-Za-z]+)'/g)].map((m) => m[1]!);
49+
50+
it('found the archetype list', () => {
51+
expect(skills.length).toBeGreaterThan(4);
52+
});
53+
54+
it('has one silhouette per archetype', () => {
55+
expect(BODY_NAMES).toHaveLength(skills.length);
56+
});
57+
58+
// Both are indexed by speciesId % 8, so position is the whole contract.
59+
it('pairs each silhouette with the archetype at the same index', () => {
60+
expect(skills).toEqual(BODY_FOR_SKILL.map(([skill]) => skill));
61+
expect([...BODY_NAMES]).toEqual(BODY_FOR_SKILL.map(([, body]) => body));
62+
});
63+
});
64+
65+
describeIf(PET_CARD)('element names vs the game element wheel', () => {
66+
const source = readFileSync(PET_CARD, 'utf8');
67+
const match = /const elements = \[([^\]]+)\]/.exec(source);
68+
69+
it('found the element list', () => {
70+
expect(match).not.toBeNull();
71+
});
72+
73+
// Order is the palette: element 1 must mean the same thing in both places, or
74+
// a pet's metadata would name one element while the app names another.
75+
it('is the same list in the same order', () => {
76+
const theirs = match![1]!.split(',').map((s) => s.trim().replace(/'/g, ''));
77+
const capitalised = theirs.map((name) => name[0]!.toUpperCase() + name.slice(1));
78+
79+
expect([...ELEMENT_NAMES]).toEqual(capitalised);
80+
});
81+
});

image-generator/src/traits.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,10 @@
2121
*/
2222

2323
/** Body archetypes, indexed to match the game's 8 passive skill archetypes
24-
* (body = speciesId % 8), so a pet's silhouette reads as its skill. */
24+
* (body = speciesId % 8), so a pet's silhouette reads as its skill. The pairing
25+
* is checked against shared/src/utils/pets/skills.ts in traitAlignment.test.ts:
26+
* a reorder on either side would otherwise draw a Phoenix with the Tank skill
27+
* and fail nothing. */
2528
export const BODY_NAMES = [
2629
'Bulwark',
2730
'Shelled',
@@ -39,7 +42,9 @@ export const EYE_NAMES = ['Round', 'Sharp', 'Sleepy', 'Blazing'] as const;
3942

4043
export const MARKING_NAMES = ['None', 'Mask', 'Blaze', 'Cheeks', 'Crown'] as const;
4144

42-
/** Element order matches DnaLib's element wheel. */
45+
/** Element order matches DnaLib's element wheel, and is checked against the
46+
* game's own list in traitAlignment.test.ts: the index decides both the palette
47+
* and the Element trait, so the two must name element 1 the same thing. */
4348
export const ELEMENT_NAMES = ['Fire', 'Water', 'Electric', 'Nature', 'Shadow', 'Cosmic'] as const;
4449

4550
export const RARITY_NAMES = ['Common', 'Uncommon', 'Rare', 'Epic', 'Legendary'] as const;

0 commit comments

Comments
 (0)