mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
Fix/sdk screenshot gate parity (#2164)
* fix(sdk): grade docs/screenshot.png exactly, like the registry gate * docs(wiki): document plugin MCP tools and the exact screenshot gate * test(sdk): cover the preflight screenshot probe
This commit is contained in:
@@ -25,7 +25,7 @@ import { listZipNames } from '../../zip.js';
|
||||
import { verifyAuthorSignature, checkSignatureShape, SignatureError } from '../verify-signature.js';
|
||||
import type { NetworkCheck, CheckContext, RegistryEntry, RegistryEntryVersion } from './types.js';
|
||||
import { pass, fail, skip } from './types.js';
|
||||
import { REQUIRED_SECTIONS, MIN_PROSE_CHARS, missingSections, placeholders, proseLength, images, undocumentedPermissions } from './readme.js';
|
||||
import { REQUIRED_SECTIONS, MIN_PROSE_CHARS, missingSections, placeholders, proseLength, undocumentedPermissions } from './readme.js';
|
||||
|
||||
export const DEFAULT_REGISTRY = 'liketrek/TREK-Plugins';
|
||||
|
||||
@@ -246,32 +246,26 @@ const readmeAtCommit: NetworkCheck = {
|
||||
const n = proseLength(md);
|
||||
if (n < MIN_PROSE_CHARS) problems.push(`• only ${n}/${MIN_PROSE_CHARS} chars of prose`);
|
||||
|
||||
// Resolve every image against the pinned commit. A relative path that exists in your tree
|
||||
// but was never committed 404s here — which is exactly the failure this catches.
|
||||
const imgs = images(md);
|
||||
if (!imgs.length) problems.push('• no screenshot (the registry requires at least one real image)');
|
||||
else {
|
||||
let anyOk = false;
|
||||
const reasons: string[] = [];
|
||||
for (const src of imgs) {
|
||||
const url = /^https?:\/\//.test(src)
|
||||
? src.includes('github.com') && src.includes('/blob/')
|
||||
? src.replace('github.com', 'raw.githubusercontent.com').replace('/blob/', '/')
|
||||
: src
|
||||
: rawUrl(c.entry.repo, v.commitSha, src);
|
||||
try {
|
||||
const r = await fetch(url, { headers: { 'User-Agent': 'trek-plugin-preflight', Range: 'bytes=0-2047' } });
|
||||
const ct = r.headers.get('content-type') || '';
|
||||
if (r.ok && ct.startsWith('image/')) {
|
||||
anyOk = true;
|
||||
break;
|
||||
}
|
||||
reasons.push(`${src} → ${r.status} ${ct || 'no content-type'}`);
|
||||
} catch {
|
||||
reasons.push(`${src} → unreachable`);
|
||||
}
|
||||
// The store cover, at the pinned commit. The registry's check-readme.mjs fetches EXACTLY
|
||||
// `docs/screenshot.png` here — that precise path is what the store card loads — so a file
|
||||
// that exists in your tree but was never committed 404s, which is exactly the failure this
|
||||
// catches. README image links are irrelevant to this gate; don't scan them.
|
||||
try {
|
||||
const r = await fetch(rawUrl(c.entry.repo, v.commitSha, 'docs/screenshot.png'), {
|
||||
headers: { 'User-Agent': 'trek-plugin-preflight', Range: 'bytes=0-2047' },
|
||||
});
|
||||
const ct = r.headers.get('content-type') || '';
|
||||
if (!r.ok || !ct.startsWith('image/')) {
|
||||
problems.push(
|
||||
`• docs/screenshot.png does not resolve to an image at ${v.commitSha.slice(0, 8)} ` +
|
||||
`(got ${r.status} ${ct || 'no content-type'}) — this exact file is the store cover; ` +
|
||||
'run `trek-plugin shot`, commit it, and re-tag',
|
||||
);
|
||||
}
|
||||
if (!anyOk) problems.push('• no screenshot resolved to a real image:\n ' + reasons.join('\n '));
|
||||
} catch {
|
||||
problems.push(
|
||||
`• docs/screenshot.png is unreachable at ${v.commitSha.slice(0, 8)} — this exact file is the store cover`,
|
||||
);
|
||||
}
|
||||
|
||||
// Permission parity, against the manifest AT THE COMMIT (not the tree — they can differ).
|
||||
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
missingSections,
|
||||
placeholders,
|
||||
proseLength,
|
||||
images,
|
||||
undocumentedPermissions,
|
||||
} from './readme.js';
|
||||
|
||||
@@ -405,44 +404,29 @@ const docsReadmeProse: OfflineCheck = {
|
||||
};
|
||||
|
||||
/**
|
||||
* A screenshot that RESOLVES.
|
||||
* The store cover: `docs/screenshot.png`, ON DISK.
|
||||
*
|
||||
* The old check regexed the README for an image *link* and passed if it found one. The scaffold
|
||||
* writes `` and never creates docs/ — so the old check passed
|
||||
* on a link to a file that does not exist, and the author learned the truth from CI, after the
|
||||
* release was immutable. Resolve the path on disk instead.
|
||||
* Two generations of this check drifted lenient. The first regexed the README for an image
|
||||
* *link* and passed on the scaffold's dangling ``. The
|
||||
* second resolved README image paths — but the registry (check-readme.mjs, TREK-Plugins #40)
|
||||
* does not read the README for this gate at all: it fetches **exactly `docs/screenshot.png`**
|
||||
* at the pinned commit, because that precise path is what the store card loads
|
||||
* (`raw…/<commit>/docs/screenshot.png`). A README whose only images had other names went
|
||||
* green here and red in CI, after the release was immutable. Check the file the store reads.
|
||||
*/
|
||||
const docsScreenshot: OfflineCheck = {
|
||||
id: 'docs.screenshot',
|
||||
stage: 'docs',
|
||||
depth: 'offline',
|
||||
severity: 'error',
|
||||
title: 'README screenshot resolves to a real image',
|
||||
title: 'docs/screenshot.png exists (the store cover)',
|
||||
run: (c) => {
|
||||
if (c.readme === undefined) return skip();
|
||||
const imgs = images(c.readme);
|
||||
if (!imgs.length) {
|
||||
return fail(
|
||||
'no images in the README',
|
||||
'The store card shows this image, and the registry rejects a plugin without one. Capture a 16:9 shot\n' +
|
||||
'(1600×900 is ideal — the card crops the edges) and commit it as docs/screenshot.png.',
|
||||
'trek-plugin shot',
|
||||
);
|
||||
}
|
||||
const remote = imgs.filter((u) => /^https?:\/\//.test(u));
|
||||
const local = imgs.filter((u) => !/^https?:\/\//.test(u));
|
||||
const resolved = local.filter((u) => c.exists(u.replace(/^\.?\//, '')));
|
||||
|
||||
if (resolved.length) return pass(resolved[0]);
|
||||
// Nothing local resolves. A remote URL might still satisfy CI, but we cannot know offline —
|
||||
// don't claim a pass we haven't earned, and don't claim a failure we can't prove.
|
||||
if (remote.length) return pass(`${remote.length} remote image(s) — preflight verifies they resolve`);
|
||||
|
||||
const missing = local.filter((u) => !c.exists(u.replace(/^\.?\//, '')));
|
||||
if (c.exists('docs/screenshot.png')) return pass('docs/screenshot.png');
|
||||
return fail(
|
||||
`${missing.join(', ')} ${missing.length === 1 ? 'does' : 'do'} not exist`,
|
||||
'The README links this image but the file is not there, so the store card would be a broken image and the\n' +
|
||||
'registry rejects it. Capture a 16:9 shot (1600×900 is ideal — the card crops the edges).',
|
||||
'docs/screenshot.png does not exist',
|
||||
'The registry fetches exactly this path at the pinned commit — it is the store card image, and a README\n' +
|
||||
'that links other image names does not satisfy the gate. Capture a 16:9 shot (1600×900 is ideal — the\n' +
|
||||
'card crops the edges) and commit it as docs/screenshot.png.',
|
||||
'trek-plugin shot',
|
||||
);
|
||||
},
|
||||
|
||||
@@ -85,13 +85,6 @@ export function proseLength(md: string): number {
|
||||
.trim().length;
|
||||
}
|
||||
|
||||
/** Every image src the README references — markdown and <img>, minus data: URIs (CI ignores those). */
|
||||
export function images(md: string): string[] {
|
||||
const mdImgs = [...md.matchAll(/!\[[^\]]*\]\(\s*([^)\s]+)/g)].map((m) => m[1]);
|
||||
const htmlImgs = [...md.matchAll(/<img[^>]+src\s*=\s*["']([^"']+)["']/gi)].map((m) => m[1]);
|
||||
return [...new Set([...mdImgs, ...htmlImgs])].filter((u) => !u.startsWith('data:'));
|
||||
}
|
||||
|
||||
/** Manifest permissions that are never named anywhere in the README. CI rejects these. */
|
||||
export function undocumentedPermissions(md: string, permissions: string[]): string[] {
|
||||
const lower = md.toLowerCase();
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { runOffline } from '../src/cli/checks/index.js';
|
||||
import { blocking, type CheckContext, type CheckReport } from '../src/cli/checks/types.js';
|
||||
import { proseLength, missingSections, placeholders, images, undocumentedPermissions } from '../src/cli/checks/readme.js';
|
||||
import { proseLength, missingSections, placeholders, undocumentedPermissions } from '../src/cli/checks/readme.js';
|
||||
|
||||
/** A manifest that passes everything, so each test can break exactly one thing. */
|
||||
const GOOD_MANIFEST = {
|
||||
@@ -188,19 +188,20 @@ describe('README checks', () => {
|
||||
});
|
||||
|
||||
/**
|
||||
* The old check regexed for an image LINK and passed if it found one — so the scaffold's
|
||||
* ``, pointing at a file that was never created, satisfied
|
||||
* it. Resolve the path instead.
|
||||
* The registry's check-readme.mjs fetches EXACTLY docs/screenshot.png at the pinned commit —
|
||||
* README image links are irrelevant to this gate. (A previous version of this check scanned
|
||||
* them instead, which passed a README whose only images had other names and failed it in CI,
|
||||
* after the release was immutable.)
|
||||
*/
|
||||
it('fails a screenshot link whose file does not exist', () => {
|
||||
it('fails when docs/screenshot.png does not exist, whatever the README links', () => {
|
||||
const r = runOffline(ctx({ exists: (rel) => rel === 'server/index.js' }));
|
||||
expect(failed(r, 'docs.screenshot')).toBe(true);
|
||||
expect(r.errors.find((e) => e.id === 'docs.screenshot')?.next).toBe('trek-plugin shot');
|
||||
});
|
||||
|
||||
it('fails a README with no image at all', () => {
|
||||
it('passes on docs/screenshot.png alone — README image links are not what CI checks', () => {
|
||||
const r = runOffline(ctx({ readme: GOOD_README.replace('', '') }));
|
||||
expect(failed(r, 'docs.screenshot')).toBe(true);
|
||||
expect(failed(r, 'docs.screenshot')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -259,11 +260,6 @@ describe('README helpers mirror the registry exactly', () => {
|
||||
expect(performance.now() - started).toBeLessThan(1000);
|
||||
});
|
||||
|
||||
it('ignores data: URIs when looking for a screenshot, like the registry does', () => {
|
||||
expect(images('')).toEqual([]);
|
||||
expect(images('<img src="shot.png">')).toEqual(['shot.png']);
|
||||
});
|
||||
|
||||
it('matches a permission anywhere in the README, case-insensitively', () => {
|
||||
expect(undocumentedPermissions('we use `DB:OWN` for caching', ['db:own'])).toEqual([]);
|
||||
expect(undocumentedPermissions('nothing here', ['db:own'])).toEqual(['db:own']);
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* The network checks talk to GitHub only through the global `fetch`, so they run here against a
|
||||
* stubbed fetch and no network. These tests pin the README-at-commit gate — in particular its
|
||||
* screenshot half: the registry's check-readme.mjs fetches EXACTLY `docs/screenshot.png` at the
|
||||
* pinned commit (that precise path is what the store card loads), and preflight must grade the
|
||||
* same path the same way. The offline twin of this gate lives in checks.test.ts.
|
||||
*/
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||
import { NETWORK_CHECKS } from '../src/cli/checks/network.js';
|
||||
import { makeContext } from '../src/cli/checks/context.js';
|
||||
import type { RegistryEntry } from '../src/cli/checks/types.js';
|
||||
|
||||
const readmeAtCommit = NETWORK_CHECKS.find((c) => c.id === 'network.readme-at-commit');
|
||||
if (!readmeAtCommit) throw new Error('network.readme-at-commit is not registered');
|
||||
|
||||
/** Passes every registry README rule: four sections, no placeholders, ≥400 chars of prose,
|
||||
* and the manifest's one permission (`db:own`) named. */
|
||||
const GOOD_README = `# Flight Tracker
|
||||
|
||||

|
||||
|
||||
## What it does
|
||||
|
||||
This plugin shows your next flight on the trip dashboard, so you never have to dig through your
|
||||
inbox for a boarding pass again. It reads the trip you are looking at, finds the next reservation
|
||||
of type flight, and renders it as a widget with the gate, the terminal and a countdown. There is
|
||||
nothing to configure and nothing to sign up for, and it works offline once the trip has synced.
|
||||
|
||||
## Screenshots
|
||||
|
||||
The image above shows the widget on a trip dashboard.
|
||||
|
||||
## Permissions
|
||||
|
||||
| Permission | Why |
|
||||
|---|---|
|
||||
| \`db:own\` | Stores the cached flight lookup so the widget renders instantly. |
|
||||
|
||||
## Setup
|
||||
|
||||
Install it and enable it. There is nothing to configure.
|
||||
`;
|
||||
|
||||
const SHA = 'a'.repeat(40);
|
||||
|
||||
const ENTRY: RegistryEntry = {
|
||||
id: 'flight-tracker',
|
||||
name: 'Flight Tracker',
|
||||
author: 'You',
|
||||
description: 'Live flight status on the dashboard.',
|
||||
repo: 'you/trek-plugin-flight-tracker',
|
||||
type: 'widget',
|
||||
versions: [
|
||||
{
|
||||
version: '1.0.0',
|
||||
gitTag: 'v1.0.0',
|
||||
commitSha: SHA,
|
||||
downloadUrl: 'https://github.com/you/trek-plugin-flight-tracker/releases/download/v1.0.0/plugin.zip',
|
||||
sha256: '0'.repeat(64),
|
||||
trek: '>=4.0.0 <5.0.0',
|
||||
size: 1234,
|
||||
apiVersion: 1,
|
||||
nativeModules: false,
|
||||
publishedAt: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const MANIFEST_AT_COMMIT = JSON.stringify({ id: 'flight-tracker', permissions: ['db:own'] });
|
||||
|
||||
type Route = { ok: boolean; status: number; contentType?: string; body?: string } | 'throw';
|
||||
|
||||
/** Serve each URL by suffix; anything unrouted is a test bug, not a silent 404. */
|
||||
function stubFetch(routes: Record<string, Route>): ReturnType<typeof vi.fn> {
|
||||
const fn = vi.fn(async (url: unknown, init?: { headers?: Record<string, string> }) => {
|
||||
void init;
|
||||
const key = Object.keys(routes).find((k) => String(url).endsWith(k));
|
||||
if (!key) throw new Error(`unexpected fetch in test: ${String(url)}`);
|
||||
const r = routes[key];
|
||||
if (r === 'throw') throw new Error('network down');
|
||||
return {
|
||||
ok: r.ok,
|
||||
status: r.status,
|
||||
headers: { get: (name: string) => (name.toLowerCase() === 'content-type' ? (r.contentType ?? null) : null) },
|
||||
text: async () => r.body ?? '',
|
||||
};
|
||||
});
|
||||
vi.stubGlobal('fetch', fn);
|
||||
return fn;
|
||||
}
|
||||
|
||||
const ctx = () => makeContext({ entry: ENTRY });
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('network.readme-at-commit — the README graded at the pinned commit', () => {
|
||||
it('passes a good README whose docs/screenshot.png serves an image at the commit', async () => {
|
||||
const fetchFn = stubFetch({
|
||||
'/README.md': { ok: true, status: 200, body: GOOD_README },
|
||||
'/docs/screenshot.png': { ok: true, status: 200, contentType: 'image/png' },
|
||||
'/trek-plugin.json': { ok: true, status: 200, body: MANIFEST_AT_COMMIT },
|
||||
});
|
||||
|
||||
const r = await readmeAtCommit.run(ctx());
|
||||
expect(r.status).toBe('pass');
|
||||
|
||||
// The screenshot probe mirrors the registry gate byte-for-byte: the exact path at the
|
||||
// pinned commit, first 2 KB only.
|
||||
const shot = fetchFn.mock.calls.find(([u]) => String(u).endsWith('/docs/screenshot.png'));
|
||||
expect(shot?.[0]).toBe(`https://raw.githubusercontent.com/${ENTRY.repo}/${SHA}/docs/screenshot.png`);
|
||||
expect(shot?.[1]?.headers?.Range).toBe('bytes=0-2047');
|
||||
});
|
||||
|
||||
it('fails naming docs/screenshot.png when the file 404s at the commit — README links to other images do not count', async () => {
|
||||
stubFetch({
|
||||
'/README.md': { ok: true, status: 200, body: GOOD_README },
|
||||
'/docs/screenshot.png': { ok: false, status: 404, contentType: 'text/plain' },
|
||||
'/trek-plugin.json': { ok: true, status: 200, body: MANIFEST_AT_COMMIT },
|
||||
});
|
||||
|
||||
const r = await readmeAtCommit.run(ctx());
|
||||
expect(r.status).toBe('fail');
|
||||
expect(r.fix).toMatch(/docs\/screenshot\.png does not resolve to an image/);
|
||||
expect(r.fix).toMatch(/404/);
|
||||
expect(r.fix).toMatch(/trek-plugin shot/);
|
||||
});
|
||||
|
||||
it('fails when the file resolves but is not an image (200 with no content-type)', async () => {
|
||||
stubFetch({
|
||||
'/README.md': { ok: true, status: 200, body: GOOD_README },
|
||||
'/docs/screenshot.png': { ok: true, status: 200 },
|
||||
'/trek-plugin.json': { ok: true, status: 200, body: MANIFEST_AT_COMMIT },
|
||||
});
|
||||
|
||||
const r = await readmeAtCommit.run(ctx());
|
||||
expect(r.status).toBe('fail');
|
||||
expect(r.fix).toMatch(/no content-type/);
|
||||
});
|
||||
|
||||
it('fails calling the screenshot unreachable when the fetch itself throws', async () => {
|
||||
stubFetch({
|
||||
'/README.md': { ok: true, status: 200, body: GOOD_README },
|
||||
'/docs/screenshot.png': 'throw',
|
||||
'/trek-plugin.json': { ok: true, status: 200, body: MANIFEST_AT_COMMIT },
|
||||
});
|
||||
|
||||
const r = await readmeAtCommit.run(ctx());
|
||||
expect(r.status).toBe('fail');
|
||||
expect(r.fix).toMatch(/docs\/screenshot\.png is unreachable/);
|
||||
});
|
||||
|
||||
it('fails on a README missing at the commit — the tree is not what the registry grades', async () => {
|
||||
stubFetch({
|
||||
'/README.md': { ok: false, status: 404 },
|
||||
});
|
||||
|
||||
const r = await readmeAtCommit.run(ctx());
|
||||
expect(r.status).toBe('fail');
|
||||
expect(r.detail).toMatch(/README\.md not found/);
|
||||
});
|
||||
|
||||
it('reports the README problems and the permission parity together, like the registry does', async () => {
|
||||
stubFetch({
|
||||
'/README.md': { ok: true, status: 200, body: '# Stub\n\nDescribe what it does. {{TAGLINE}}\n' },
|
||||
'/docs/screenshot.png': { ok: true, status: 200, contentType: 'image/png' },
|
||||
'/trek-plugin.json': { ok: true, status: 200, body: MANIFEST_AT_COMMIT },
|
||||
});
|
||||
|
||||
const r = await readmeAtCommit.run(ctx());
|
||||
expect(r.status).toBe('fail');
|
||||
expect(r.fix).toMatch(/missing required section/);
|
||||
expect(r.fix).toMatch(/template placeholder/);
|
||||
expect(r.fix).toMatch(/chars of prose/);
|
||||
expect(r.fix).toMatch(/permissions not explained in the README: db:own/);
|
||||
});
|
||||
|
||||
it('skips without an entry — nothing to grade against', async () => {
|
||||
stubFetch({});
|
||||
const r = await readmeAtCommit.run(makeContext({}));
|
||||
expect(r.status).toBe('skip');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user