Files
sprites/tools/deploy/test/api.test.ts
Christopher Monsanto 8bf512480a Replace the .env deploy command with deploy.json5
An untracked json5 config maps deploy names to a buildFile plus
(subset globs, cmd) entries: the deploy verb builds the named
buildFiles, finishes each, routes the outputs through the subset
globs, and pipes one tar per entry to its command. Every glob must
match an output and every output must be covered by some entry.

The tar no longer carries __key; the upload command names the asset
set itself (smogonctl assets upload sprites).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 01:13:13 -04:00

155 lines
5.6 KiB
TypeScript

import assert from 'node:assert/strict';
import {createHash} from 'node:crypto';
import fs from 'node:fs';
import os from 'node:os';
import pathlib from 'node:path';
import {beforeEach, test} from 'node:test';
import b32encode from 'base32-encode';
import tar from 'tar-stream';
import {resetDecls, rule} from '../../build/artifact.ts';
import {casPath} from '../../build/cas.ts';
import {makeCtx} from '../api.ts';
import {ActionQueue} from '../queue.ts';
beforeEach(resetDecls);
function tmpdir() : string {
return fs.mkdtempSync(pathlib.join(os.tmpdir(), 'deploy-api-test-'));
}
function shortHash(data : Buffer | string) : string {
return b32encode(createHash('sha256').update(data).digest(), 'RFC4648').slice(0, 8);
}
// Stage `content` as a built artifact in a scratch CAS.
function makeArtifact(casDir : string, content : string, ext : string) {
const digest = createHash('sha256').update(content).digest('hex');
const artifact = rule('in.png', ['t %f %o'], `art.${ext}`);
artifact.resolve(digest);
const obj = casPath(casDir, digest, ext);
fs.mkdirSync(pathlib.dirname(obj), {recursive: true});
fs.writeFileSync(obj, content);
return artifact;
}
test('ctx.hash matches the historical single-file stamp for artifacts and files', () => {
const dir = tmpdir();
const file = pathlib.join(dir, 'f.png');
fs.writeFileSync(file, 'stamp-me');
const artifact = makeArtifact(pathlib.join(dir, 'cas'), 'stamp-me', 'png');
const ctx = makeCtx(pathlib.join(dir, 'cas'), new ActionQueue());
assert.equal(ctx.hash(file), shortHash('stamp-me'));
assert.equal(ctx.hash(artifact), shortHash('stamp-me'));
});
test('multi-source ctx.hash is order-insensitive and content-sensitive', () => {
const dir = tmpdir();
const a = pathlib.join(dir, 'a.png');
const b = pathlib.join(dir, 'b.png');
fs.writeFileSync(a, 'aaa');
fs.writeFileSync(b, 'bbb');
const ctx = makeCtx(pathlib.join(dir, 'cas'), new ActionQueue());
const before = ctx.hash(a, b);
assert.equal(before, ctx.hash(b, a));
assert.notEqual(before, ctx.hash(a));
fs.writeFileSync(b, 'changed');
assert.notEqual(ctx.hash(a, b), before);
});
test('ctx queues artifact copies from the CAS, writes and reads', () => {
const dir = tmpdir();
const casDir = pathlib.join(dir, 'cas');
const artifact = makeArtifact(casDir, 'bytes', 'webp');
const aq = new ActionQueue();
const ctx = makeCtx(casDir, aq);
ctx.write('__key', 'sprites');
ctx.copy(artifact, 'sprites/x.webp');
assert.equal(ctx.read(artifact), 'bytes');
const ops = aq.log.filter(e => e.type === 'Op');
assert.deepEqual(ops.map(e => e.dst), ['__key', 'sprites/x.webp']);
assert.equal((ops[1] as {op : {src : string}}).op.src, casPath(casDir, artifact.hash, 'webp'));
});
test('ctx.list sorts, parses extensions, skips dotfiles and directories', () => {
const dir = tmpdir();
fs.writeFileSync(pathlib.join(dir, 'b.png'), '');
fs.writeFileSync(pathlib.join(dir, 'a.gif'), '');
fs.writeFileSync(pathlib.join(dir, 'noext'), '');
fs.writeFileSync(pathlib.join(dir, '.hidden'), '');
fs.mkdirSync(pathlib.join(dir, 'subdir'));
const ctx = makeCtx('cas', new ActionQueue());
assert.deepEqual(ctx.list(dir), [
{dir, name: 'a', ext: 'gif', path: pathlib.join(dir, 'a.gif')},
{dir, name: 'b', ext: 'png', path: pathlib.join(dir, 'b.png')},
{dir, name: 'noext', ext: null, path: pathlib.join(dir, 'noext')},
]);
});
function packedEntries(aq : ActionQueue, filter? : (dst : string) => boolean)
: Promise<{name : string, data : string}[]> {
return new Promise((resolve, reject) => {
const extract = tar.extract();
const entries : {name : string, data : string}[] = [];
extract.on('entry', (header, stream, next) => {
const chunks : Buffer[] = [];
stream.on('data', c => chunks.push(c));
stream.on('end', () => {
entries.push({name: header.name, data: Buffer.concat(chunks).toString()});
next();
});
});
extract.on('finish', () => resolve(entries));
extract.on('error', reject);
aq.pack(filter).pipe(extract);
});
}
test('pack preserves op order', async () => {
const aq = new ActionQueue();
aq.write('zzz', 'z.txt');
aq.write('aaa', 'a.txt');
assert.deepEqual(await packedEntries(aq), [
{name: 'z.txt', data: 'zzz'},
{name: 'a.txt', data: 'aaa'},
]);
});
test('pack with a filter packs only matching entries in order', async () => {
const aq = new ActionQueue();
aq.write('1', 'xy/a.png');
aq.write('2', 'meta/m.json');
aq.write('3', 'xy/b.png');
const subset = await packedEntries(aq, dst => dst.startsWith('xy/'));
assert.deepEqual(subset, [
{name: 'xy/a.png', data: '1'},
{name: 'xy/b.png', data: '3'},
]);
});
test('duplicate and absolute destinations invalidate the queue', () => {
const dup = new ActionQueue();
dup.write('a', 'x.txt');
dup.write('b', 'x.txt');
assert.ok(!dup.valid);
assert.throws(() => dup.pack(), /Invalid ActionQueue/);
const abs = new ActionQueue();
abs.write('a', '/etc/passwd');
assert.ok(!abs.valid);
});
test('copy-mode materialization restores 0644 on read-only sources', async () => {
const dir = tmpdir();
const src = pathlib.join(dir, 'obj');
fs.writeFileSync(src, 'x');
fs.chmodSync(src, 0o444);
const aq = new ActionQueue();
aq.copy(src, 'out/x.png');
const out = pathlib.join(dir, 'deploy');
await aq.run(out, 'copy');
assert.equal(fs.statSync(pathlib.join(out, 'out/x.png')).mode & 0o777, 0o644);
});