Merge remote-tracking branch 'origin/main' into outside-reviews-end-with-a-verdict-line

This commit is contained in:
garrytan committed 2026-11-07 20:18:05 +00:00
commit f8914e9c2e
34 files changed
+2542 -13

No files matched your search

+386
View File
@@ -0,0 +1,386 @@
/**
* The app profile's contract without a browser (GST-1741 PR 1): usage errors
* before any render, state-file and waiver validation, the verdict and exit
* precedence, URL redaction, private trace files, RENDER_UNAVAILABLE for a
* --url nobody measured, and the scope guard that keeps app rules out of the
* page and component profiles. A fake runner stands in for the browser; the
* measured behavior is test/design-lint-app-profile.test.ts.
*/
import { afterAll, describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import type { RenderResult, RenderSpec } from '../lib/aside-render';
import { parseStateFile, parseViewport, parseWaiverFile, redactText, redactUrl, selectorError, stateNameOf } from '../lib/design-lint/app-input';
import { appMain, renderUnavailable, type AppCliArgs, type AppDeps } from '../lib/design-lint/app-cli';
import { lint } from '../lib/design-lint/index';
import { lintApp, APP_CODES } from '../lib/design-lint/render-app';
import { appInstallScript, composedWaitExpression } from '../lib/design-lint/render-probe-app';
import { installScript } from '../lib/design-lint/render-probe';
import { finalize, Reporter, type Finding } from '../lib/design-lint/report';
import { RULES } from '../lib/design-lint/rules/ids';
const ROOT = path.resolve(import.meta.dir, '..');
const BIN = path.join(ROOT, 'bin', 'gstack-design-lint');
const tmp: string[] = [];
afterAll(() => { for (const d of tmp) fs.rmSync(d, { recursive: true, force: true }); });
const tmpDir = (): string => { const d = fs.mkdtempSync(path.join(os.tmpdir(), 'design-lint-app-')); tmp.push(d); return d; };
function runBin(args: string[]) {
const r = Bun.spawnSync([BIN, ...args], { cwd: ROOT, stdout: 'pipe', stderr: 'pipe', timeout: 60_000 });
return { code: r.exitCode, out: r.stdout.toString(), err: r.stderr.toString() };
}
const APP_IDS = Object.entries(RULES).filter(([, m]) => m.app).map(([id]) => id);
const URL1 = 'http://127.0.0.1:5300/settings?as=owner';
interface Fake { setup?: unknown; measure?: (width: number, call: string) => unknown; fail?: string }
const ok = (findings: unknown[] = [], extra: Record<string, unknown> = {}) => ({ status: 'measured', matched: 1, roots: 1, lists: 1, findings, ...extra });
const f = (rule: string, locator: string, measured = 4, threshold = 8) => ({ rule, sentence: 'a 2-line row leaves ' + measured + 'px between its text and its top edge (under ' + threshold + 'px)', locator, measured, threshold });
/** A runner that answers every step the scheduler plans, and records each spec it was handed. */
function fakeRun(fake: Fake, specs: RenderSpec[] = []) {
return async (spec: RenderSpec): Promise<RenderResult> => {
specs.push(spec);
if (fake.fail) return { ok: false, outputs: [], evals: {}, stdout: '', error: fake.fail };
const evals: Record<number, string> = {};
spec.steps.forEach((s, i) => {
if (s.kind !== 'eval') return;
if (i === 0) evals[i] = '1';
else if (s.expression.includes('P.setup(P)')) evals[i] = JSON.stringify(fake.setup ?? { lostSheets: [], lostFonts: [] });
else evals[i] = JSON.stringify(fake.measure ? fake.measure(s.width ?? 0, s.expression) : ok());
});
return { ok: true, outputs: [], evals, stdout: '' };
};
}
const args = (over: Partial<AppCliArgs> = {}): AppCliArgs => ({ paths: [], urls: [URL1], within: '.sharing', viewports: [], verbose: false, json: false, ...over });
async function app(over: Partial<AppCliArgs>, deps: AppDeps) {
const out: string[] = [];
const err: string[] = [];
const code = await appMain(args(over), { out: (s) => out.push(s), err: (s) => err.push(s) }, { reach: async () => null, traceDir: tmpDir(), ...deps });
return { code, out: out.join('\n'), err: err.join('\n') };
}
const neverRun = async (): Promise<RenderResult> => { throw new Error('the runner must not be called for a usage error'); };
describe('app profile contract: usage errors exit 3 before any render (T24, T30)', () => {
test('the bin refuses paths, a missing --within, a bad selector, a bad viewport and both or neither target', () => {
for (const [argv, code] of [
[['--scope', 'app', 'x.html', '--within', 'main'], 'APP_PATHS'],
[['--scope', 'app', '--url', URL1], 'NO_WITHIN'],
[['--scope', 'app', '--url', URL1, '--within', '.rows['], 'BAD_SELECTOR'],
[['--scope', 'app', '--url', URL1, '--within', 'main', '--wait-for', 'a >'], 'BAD_SELECTOR'],
[['--scope', 'app', '--url', URL1, '--within', 'main', '--viewport', '100x900'], 'BAD_VIEWPORT'],
[['--scope', 'app', '--url', URL1, '--within', 'main', '--viewport', '1440x900x'], 'BAD_VIEWPORT'],
[['--scope', 'app', '--url', URL1, '--within', 'main', '--viewport', '4000x900'], 'BAD_VIEWPORT'],
[['--scope', 'app', '--within', 'main'], 'NO_TARGET'],
[['--scope', 'app', '--url', 'ftp://x.test/p', '--within', 'main'], 'BAD_URL'],
[['--scope', 'app', '--url', URL1, '--state-file', 'x.json', '--within', 'main'], 'BOTH_TARGETS'],
[['--scope', 'app', '--state-file', '/nowhere/states.json', '--within', 'main'], 'BAD_STATE_FILE'],
] as Array<[string[], string]>) {
const r = runBin(argv);
expect([argv.join(' '), r.code, r.out]).toEqual([argv.join(' '), 3, '']);
expect(r.err).toMatch(new RegExp('^gstack-design-lint: ' + code + ': .+ \\| fix: .+$', 'm'));
}
});
test('every app-only flag without --scope app exits 3 naming the flag; --verbose works anywhere', () => {
for (const flag of ['--within', '--viewport', '--wait-for', '--waivers', '--state-file']) {
const r = runBin(['test/fixtures/design-lint/clean', flag, 'x']);
expect([r.code, r.err.trim()]).toEqual([3, 'gstack-design-lint: APP_ONLY: ' + flag + ' only with --scope app | fix: add --scope app, or drop ' + flag]);
}
const plain = runBin(['test/fixtures/design-lint/clean']);
const verbose = runBin(['test/fixtures/design-lint/clean', '--verbose']);
expect([verbose.code, verbose.out]).toEqual([plain.code, plain.out]);
const usage = runBin(['--help']);
expect(usage.out).toContain('gstack-design-lint --scope app (--url <http://…> ... | --state-file <states.json>) --within <selector>');
});
test('an invalid selector never reaches the runner', async () => {
const r = await app({ within: 'div > > p' }, { run: neverRun });
expect(r.code).toBe(3);
expect(r.err).toContain('BAD_SELECTOR');
});
test('selector and viewport grammar', () => {
for (const ok of ['.sharing', 'qm-sharing', 'main .rows > .row:nth-of-type(2)', '[data-x="a]b"]', 'a, b', ':is(.a, .b) li', 'x-y\\:z', '#main']) expect([ok, selectorError(ok)]).toEqual([ok, null]);
for (const bad of ['', '.rows[', 'div >', '> div', 'a,,b', 'a {}', 'p:', '.', '#', '(a', 'a)', 'a !b', '"q"']) expect([bad, selectorError(bad)]).not.toEqual([bad, null]);
expect(parseViewport('375x812')).toEqual({ width: 375, height: 812 });
expect(parseViewport('3840x2400')).toEqual({ width: 3840, height: 2400 });
for (const bad of ['319x900', '1440x479', '1440x2401', '14400x900', '1440X900', ' 1440x900', '1440x900.5']) expect(() => parseViewport(bad)).toThrow(/--viewport/);
});
});
describe('app profile contract: state files and waivers (T12, T23, T30, T36)', () => {
test('a state file is validated whole: unknown keys, no url, a bad version or selector, a duplicate name; a non-loopback URL is noted', () => {
const good = parseStateFile(JSON.stringify({ version: 1, states: [{ name: 'owner', url: URL1, expect: '.owner-note' }, { url: 'https://staging.example.test/p?as=reader&token=abc' }] }));
expect(good.states.map((s) => s.name)).toEqual(['owner', '/p?as=reader&token=REDACTED']);
expect(good.notes).toEqual(['state /p?as=reader&token=REDACTED is not on a loopback address (https://staging.example.test/p?as=reader&token=REDACTED)']);
for (const [doc, why] of [
['{', 'not JSON'],
[{ version: 1, states: [], extra: 1 }, 'unknown key extra'],
[{ version: 2, states: [{ url: URL1 }] }, 'version must be 1'],
[{ version: 1, states: [] }, 'states is empty'],
[{ version: 1, states: [{ name: 'a' }] }, 'states[0] has no url'],
[{ version: 1, states: [{ url: 'file:///x.html' }] }, 'not an http or https address'],
[{ version: 1, states: [{ url: URL1, wait: 1 }] }, 'states[0] has unknown key wait'],
[{ version: 1, states: [{ url: URL1, expect: 'a >' }] }, 'not a valid selector'],
[{ version: 1, states: [{ url: URL1, name: 'a' }, { url: URL1, name: 'a' }] }, 'two states are named a'],
] as Array<[unknown, string]>) {
expect(() => parseStateFile(typeof doc === 'string' ? doc : JSON.stringify(doc))).toThrow(why);
}
});
test('a bad state file through the bin exits 3 with its reason', () => {
const dir = tmpDir();
fs.writeFileSync(path.join(dir, 's.json'), JSON.stringify({ version: 1, states: [{ url: URL1, color: 'red' }] }));
const r = runBin(['--scope', 'app', '--state-file', path.join(dir, 's.json'), '--within', 'main']);
expect([r.code, r.err.trim()]).toEqual([3, expect.stringMatching(/^gstack-design-lint: BAD_STATE_FILE: --state-file: states\[0\] has unknown key color \| fix: /)]);
});
test('a waiver file names an app rule, a locator and a reason of 8 characters or more; anything else exits 3', async () => {
const isApp = (id: string) => !!RULES[id]?.app;
expect(parseWaiverFile(JSON.stringify({ version: 1, waivers: [{ rule: 'small-hit-area', locator: 'button.x', reason: 'the glyph sits in a 44px label target' }] }), isApp)).toHaveLength(1);
for (const [w, why] of [
[{ rule: 'low-contrast', locator: 'a', reason: 'long enough reason' }, 'is not an app rule'],
[{ rule: 'nope', locator: 'a', reason: 'long enough reason' }, 'is not an app rule'],
[{ rule: 'small-hit-area', locator: 'a', reason: 'short' }, '8 characters or more'],
[{ rule: 'small-hit-area', locator: ' ', reason: 'long enough reason' }, 'locator must be'],
[{ rule: 'small-hit-area', locator: 'a', reason: 'long enough reason', page: 'x' }, 'unknown key page'],
] as Array<[unknown, string]>) expect(() => parseWaiverFile(JSON.stringify({ version: 1, waivers: [w] }), isApp)).toThrow(why);
const dir = tmpDir();
fs.writeFileSync(path.join(dir, 'w.json'), JSON.stringify({ version: 1, waivers: [{ rule: 'row-crowding', locator: 'a', reason: 'tiny' }] }));
const r = await app({ waivers: path.join(dir, 'w.json') }, { run: neverRun });
expect([r.code, r.err]).toEqual([3, expect.stringContaining('BAD_WAIVERS')]);
});
test('a matched waiver prints at waived with its reason and leaves the verdict clean; an unused one is a waiver finding (T6, T23, T28, T35)', async () => {
const dir = tmpDir();
const file = path.join(dir, 'w.json');
fs.writeFileSync(file, JSON.stringify({ version: 1, waivers: [
{ rule: 'small-hit-area', locator: 'button.remove', reason: 'the remove glyph sits inside a 44px label target' },
{ rule: 'row-crowding', locator: 'div.never', reason: 'kept to show an unused waiver' },
] }));
const run = fakeRun({ measure: (w) => ok(w === 375 ? [f('small-hit-area', 'qm-x >>> div.row > button.remove "Remove Dana"', 18, 32)] : []) });
const r = await app({ waivers: file }, { run });
expect(r.code).toBe(0);
expect(r.out).toMatch(/^LINT: waived small-hit-area .+ \| fix: waived \(the remove glyph sits inside a 44px label target\)$/m);
expect(r.out).toMatch(/^LINT: ask waiver --waivers:2 waiver on row-crowding at "div\.never" matched no finding \(unused\) \| fix: /m);
expect(r.out).toMatch(/^LINT_APP: verdict=clean code=OK states=1\/1 viewports=1440x900,375x812 within=\.sharing matched=1 open=0 waived=1$/m);
});
});
describe('app profile contract: verdict, codes and exit precedence (T8, T15, T16, T19, T27)', () => {
test('clean is 0 only on complete coverage with nothing open; any unwaived app finding is open (1) whatever its tier', async () => {
const clean = await app({}, { run: fakeRun({}) });
expect([clean.code, clean.out.split('\n').slice(-1)[0]]).toEqual([0, 'LINT_APP: verdict=clean code=OK states=1/1 viewports=1440x900,375x812 within=.sharing matched=1 open=0 waived=0']);
const open = await app({ json: true }, { run: fakeRun({ measure: () => ok([f('row-crowding', 'div.rows > div.row:nth-of-type(1) "Deploy key"')]) }) });
expect(open.code).toBe(1);
const json = JSON.parse(open.out);
expect(json.app).toMatchObject({ verdict: 'open', code: 'OPEN', open: 2, waived: 0, codes: [] });
expect(json.app.states.map((s: { viewport: string; status: string; code: string }) => [s.viewport, s.status, s.code])).toEqual([['1440x900', 'measured', 'OK'], ['375x812', 'measured', 'OK']]);
expect(json.summary.ask).toBe(2);
});
test('incomplete wins over open and is never printed clean: one viewport not measured exits 4', async () => {
const run = fakeRun({ measure: (w) => (w === 375 ? { status: 'incomplete', code: 'NOT_READY', detail: 'no list', matched: 1, roots: 1, lists: 0, findings: [] } : ok([f('row-crowding', 'div.row "A"')])) });
const r = await app({}, { run });
expect(r.code).toBe(4);
expect(r.out).toMatch(/^LINT_APP_STATE: state=\/settings\?as=owner viewport=375x812 status=incomplete code=NOT_READY matched=1 no list \| fix: pass --wait-for .+ \(docs\/design-lint\.md#app-code-not_ready\)$/m);
expect(r.out).toMatch(/^LINT_APP: verdict=incomplete code=NOT_READY states=0\/1 .* open=1 waived=0$/m);
});
test('several codes report the first in the documented order and list them all', async () => {
const result = await lintApp({
states: [{ name: 'a', url: 'http://127.0.0.1:1/a' }, { name: 'b', url: URL1 }, { name: 'c', url: 'http://127.0.0.1:5300/c' }],
within: 'main', traceDir: tmpDir(),
reach: async (u) => (u.endsWith('/a') ? 'connection refused' : null),
run: fakeRun({ measure: (w, call) => (call.includes('"expect":null') && w === 1440 ? { status: 'incomplete', code: 'STATE_MISMATCH', matched: 1, roots: 1, lists: 1, findings: [] } : { error: 'TypeError: x is not a function', stack: 'TypeError: x\n at http://127.0.0.1:5300/app.js?token=SECRET:1:1' }) }),
});
expect(result.exitCode).toBe(4);
expect(result.app.code).toBe('UNREACHABLE');
expect(result.app.codes).toEqual(['UNREACHABLE', 'STATE_MISMATCH', 'PROBE_ERROR']);
expect(APP_CODES.indexOf('UNREACHABLE')).toBeLessThan(APP_CODES.indexOf('PROBE_ERROR'));
});
test('a failed render maps to its code: no browser, timeout, the --wait-for never matching, anything else unreachable', async () => {
const codeOf = async (fail: string, waitFor?: string) => (await lintApp({ states: [{ name: 's', url: URL1 }], within: 'main', waitFor, reach: async () => null, run: fakeRun({ fail }) })).app.code;
expect(await codeOf('no browser available: run ./setup')).toBe('NO_BROWSER');
expect(await codeOf('aside repl did not run: timed out after 130000ms')).toBe('TIMEOUT');
expect(await codeOf('waitFor expression never became truthy: (() => …)() (waited 15000ms)', '.ready')).toBe('NOT_READY');
expect(await codeOf('browse goto failed: net::ERR_CONNECTION_REFUSED')).toBe('UNREACHABLE');
});
test('a stylesheet that did not load is ASSET_MISSING; a font that did not load is a note and the state stays measured', async () => {
const sheet = await lintApp({ states: [{ name: 's', url: URL1 }], within: 'main', reach: async () => null, run: fakeRun({ setup: { lostSheets: ['app.css'], lostFonts: [] } }) });
expect([sheet.app.code, sheet.app.states[0].detail]).toEqual(['ASSET_MISSING', 'stylesheets did not load: app.css']);
const font = await lintApp({ states: [{ name: 's', url: URL1 }], within: 'main', reach: async () => null, run: fakeRun({ setup: { lostSheets: [], lostFonts: ['Inter'] } }) });
expect([font.app.verdict, font.app.notes]).toEqual(['clean', ['s: fonts did not load (Inter); measured with a fallback face']]);
});
test('a probe answer the lint does not know is PROBE_ERROR, never a finding: a page rule, an unknown code, a malformed list', async () => {
for (const answer of [ok([f('low-contrast', 'p')]), ok([{ rule: 'row-crowding' }]), { status: 'incomplete', code: 'FINE', matched: 1, findings: [] }, [1, 2], 'x']) {
const r = await lintApp({ states: [{ name: 's', url: URL1 }], within: 'main', reach: async () => null, run: fakeRun({ measure: () => answer }) });
expect([JSON.stringify(answer), r.app.code, r.findings]).toEqual([JSON.stringify(answer), 'PROBE_ERROR', []]);
}
});
});
describe('app profile contract: the plan handed to the browser (T2, T25, T32)', () => {
test('one render per state: the app install, setup, one measure per viewport at its size; readiness waits on the section through shadow roots', async () => {
const specs: RenderSpec[] = [];
await lintApp({ states: [{ name: 's', url: URL1, expect: '.owner' }], within: 'qm-sharing', viewports: [{ width: 1280, height: 800 }, { width: 390, height: 844 }], reach: async () => null, run: fakeRun({}, specs) });
expect(specs).toHaveLength(1);
const [spec] = specs;
expect(spec.file).toBe(URL1);
expect(spec.steps.map((s) => (s.kind === 'eval' ? [s.width, s.height, s.expression.includes('P.setup(P)') ? 'setup' : s.expression.includes('P.measure(P') ? 'measure' : 'install'] : s.kind))).toEqual([[1280, 800, 'install'], [1280, 800, 'setup'], [1280, 800, 'measure'], [390, 844, 'measure']]);
const first = spec.steps[0];
expect(first.kind === 'eval' && first.expression).toBe(appInstallScript());
const measure = spec.steps[2];
expect(measure.kind === 'eval' && measure.expression).toContain('"within":"qm-sharing","expect":".owner","explicitWait":false,"readyMs":5000');
expect(spec.waitFor?.selector).toBeUndefined();
expect(spec.waitFor?.timeoutMs).toBe(16_000);
expect(spec.waitFor?.expression).toContain('"qm-sharing"');
expect(spec.waitFor?.expression).toContain('shadowRoot');
expect(spec.waitFor?.expression).toContain('> 15000');
const explicit: RenderSpec[] = [];
await lintApp({ states: [{ name: 's', url: URL1 }], within: 'main', waitFor: '.loaded', reach: async () => null, run: fakeRun({}, explicit) });
expect(explicit[0].waitFor?.timeoutMs).toBe(15_000);
expect(explicit[0].waitFor?.expression).not.toContain('> 15000');
expect(explicit[0].steps[2].kind === 'eval' && explicit[0].steps[2].expression).toContain('"explicitWait":true');
});
test('the scripted variants build the same Sharing section the static replica holds', () => {
const sandbox: { SHARING_HTML?: string } = {};
new Function('window', fs.readFileSync(path.join(ROOT, 'test/fixtures/ui-spacing/sharing.js'), 'utf8'))(sandbox);
expect(sandbox.SHARING_HTML!.length).toBeGreaterThan(1000);
expect(fs.readFileSync(path.join(ROOT, 'test/fixtures/ui-spacing/replica.html'), 'utf8')).toContain(sandbox.SHARING_HTML!);
});
test('the page probes are untouched: the page install script carries no app probe, and the app install no page probe', () => {
expect(installScript()).not.toContain('__gstackLintApp');
expect(appInstallScript()).not.toContain('__gstackLint =');
expect(appInstallScript()).toContain('window.__gstackLintApp = { H: (');
});
});
describe('app profile contract: findings, redaction and traces (T22, T29, T34, T19)', () => {
test('findings keep state, viewport, locator, measured px and threshold whole; distinct rows and viewports are never merged', async () => {
const long = 'qm-panel >>> ' + Array.from({ length: 12 }, (_, i) => 'div.level-' + i).join(' > ') + ' > div.row:nth-of-type(2) "Deploy key Read-only access to the repo"';
const run = fakeRun({ measure: () => ok([f('row-crowding', long), f('row-crowding', long.replace('nth-of-type(2)', 'nth-of-type(3)'))]) });
const r = await lintApp({ states: [{ name: 'owner', url: URL1 }], within: 'main', reach: async () => null, run });
const rows = r.findings.filter((x) => x.rule === 'row-crowding');
expect(rows).toHaveLength(4);
expect(rows[0].app).toEqual({ state: 'owner', viewport: '1440x900', locator: long, measured: 4, threshold: 8 });
expect(rows[0].evidence).toBe('[owner 1440px] a 2-line row leaves 4px between its text and its top edge (under 8px) at ' + long);
expect(new Set(rows.map((x) => x.app!.viewport + x.app!.locator)).size).toBe(4);
const dup: Finding = { ...rows[0] };
expect(finalize([rows[0], dup])).toHaveLength(1);
});
test('credentials in a URL never reach text, JSON, stderr or a trace; navigation and the reachability check use the real URL', async () => {
const userinfo = ['dana', 'PWSENTINEL'].join(':') + '@';
const secret = 'http://' + userinfo + '127.0.0.1:5300/settings?as=owner&token=TOKSENTINEL&sig=SIGSENTINEL&session_id=SESSENTINEL';
const reached: string[] = [];
const specs: RenderSpec[] = [];
const run = fakeRun({ measure: (w) => (w === 375 ? { error: 'Error: boom at ' + secret, stack: 'Error: boom\n at ' + secret + ':1:1' } : ok([f('row-crowding', 'div.row "x"')])) }, specs);
const traceDir = tmpDir();
for (const json of [false, true]) {
const out: string[] = [];
const err: string[] = [];
await appMain(args({ urls: [secret], json, verbose: true }), { out: (s) => out.push(s), err: (s) => err.push(s) }, { run, traceDir, reach: async (u) => { reached.push(u); return null; } });
const all = out.join('\n') + err.join('\n') + fs.readdirSync(traceDir).map((t) => fs.readFileSync(path.join(traceDir, t), 'utf8')).join('');
for (const s of ['PWSENTINEL', 'TOKSENTINEL', 'SIGSENTINEL', 'SESSENTINEL', 'dana:']) expect([json, s, all.includes(s)]).toEqual([json, s, false]);
expect(all).toContain('token=REDACTED');
}
expect(specs.map((s) => s.file)).toEqual([secret, secret]);
expect(reached).toEqual([secret, secret]);
expect(redactUrl('https://' + ['u', 'p'].join(':') + '@h.test/a?code=1&next=/b')).toBe('https://h.test/a?code=REDACTED&next=%2Fb');
expect(redactText('see http://h.test/?jwt=abc and https://k.test/x?api_key=1')).toBe('see http://h.test/?jwt=REDACTED and https://k.test/x?api_key=REDACTED');
expect(stateNameOf('http://h.test/p?as=owner&password=x')).toBe('/p?as=owner&password=REDACTED');
expect(redactUrl('http://127.0.0.1:5300/cb#access_token=FRAGSENTINEL&state=x')).toBe('http://127.0.0.1:5300/cb#access_token=REDACTED&state=x');
expect(redactText('at http://h.test/#id_token=FRAGSENTINEL')).toBe('at http://h.test/#id_token=REDACTED');
expect(redactUrl('http://h.test/p#rows')).toBe('http://h.test/p#rows');
const hashed = await app({ urls: ['http://127.0.0.1:5300/cb#access_token=FRAGSENTINEL'], json: true }, { run: fakeRun({ measure: () => ok([f('row-crowding', 'div.row "x"')]) }) });
expect(hashed.out + hashed.err).not.toContain('FRAGSENTINEL');
});
test('two --url states that share a redacted name stay two states; the same URL twice is DUPLICATE_STATE', async () => {
const seen: string[] = [];
const two = await app({ urls: ['http://127.0.0.1:5300/p?session=owner', 'http://127.0.0.1:5300/p?session=viewer'] }, { run: async (spec) => { seen.push(spec.file); return fakeRun({})(spec); } });
expect(two.code).toBe(0);
expect(seen).toEqual(['http://127.0.0.1:5300/p?session=owner', 'http://127.0.0.1:5300/p?session=viewer']);
expect(two.out).toMatch(/^LINT_APP: verdict=clean code=OK states=2\/2 /m);
expect(two.out).toContain('state=/p?session=REDACTED~2 viewport=1440x900');
const literal = await app({ urls: ['http://127.0.0.1:5300/p', 'http://127.0.0.1:5300/p#x', 'http://127.0.0.1:5300/p~2'] }, { run: fakeRun({}) });
expect(literal.out).toMatch(/^LINT_APP: verdict=clean code=OK states=3\/3 /m);
expect([...new Set(literal.out.split('\n').filter((l) => l.startsWith('LINT_APP_STATE:')).map((l) => l.split(' ')[1]))]).toEqual(['state=/p', 'state=/p~2', 'state=/p~2~2']);
const dup = await app({ urls: [URL1, URL1] }, { run: neverRun });
expect([dup.code, dup.err]).toEqual([3, 'gstack-design-lint: DUPLICATE_STATE: --url ' + URL1 + ' is given twice | fix: measure each URL once']);
});
test('a probe that throws writes a 0600 trace under a private directory, names it, and prints its stack only with --verbose', async () => {
const traceDir = path.join(tmpDir(), 'traces');
const run = fakeRun({ measure: (w) => (w === 375 ? { error: 'TypeError: rows is undefined', stack: 'TypeError: rows is undefined\n at glAppRows (eval:1:2)' } : ok()) });
const quiet = await app({}, { run, traceDir });
expect(quiet.code).toBe(4);
const [trace] = fs.readdirSync(traceDir).map((t) => path.join(traceDir, t));
if (process.platform !== 'win32') {
expect(fs.statSync(trace).mode & 0o777).toBe(0o600);
expect(fs.statSync(traceDir).mode & 0o777).toBe(0o700);
}
expect(JSON.parse(fs.readFileSync(trace, 'utf8'))).toEqual({ probe: 'measure', state: '/settings?as=owner', viewport: '375x812', stack: 'TypeError: rows is undefined\n at glAppRows (eval:1:2)', url: URL1 });
expect(quiet.out).toContain('code=PROBE_ERROR matched=0 the measure probe failed: TypeError: rows is undefined (trace: ' + trace + ')');
expect(quiet.err).toBe('gstack-design-lint: PROBE_ERROR: the measure probe threw at /settings?as=owner 375x812; trace ' + trace);
const loud = await app({ verbose: true }, { run, traceDir: path.join(tmpDir(), 'traces') });
expect(loud.err).toContain('at glAppRows (eval:1:2)');
const blocker = path.join(tmpDir(), 'not-a-dir');
fs.writeFileSync(blocker, '');
const unwritable = await app({ verbose: true }, { run, traceDir: path.join(blocker, 'traces') });
expect(unwritable.code).toBe(4);
expect(unwritable.out).toMatch(/^LINT_APP: verdict=incomplete code=PROBE_ERROR /m);
expect(unwritable.err).toContain('gstack-design-lint: PROBE_ERROR: the measure probe threw at /settings?as=owner 375x812; no trace written (');
});
test('a section selector the browser refuses never holds readiness: the soft wait goes on to the probe, an explicit --wait-for stays not ready', async () => {
const refuses = { querySelector: () => { throw new SyntaxError("'main:foo' is not a valid selector"); }, querySelectorAll: () => [] };
const evaluate = (expression: string) => new Function('document', 'window', 'return ' + expression)(refuses, {});
expect(evaluate(composedWaitExpression('main:foo', 15_000))).toBe(true);
expect(evaluate(composedWaitExpression('main:foo', null))).toBe(false);
const late = await lintApp({ states: [{ name: 's', url: URL1 }], within: 'main', reach: async () => null, run: fakeRun({ fail: 'waitFor expression never became truthy: x (waited 16000ms)' }) });
expect([late.app.code, late.app.states[0].detail]).toEqual(['NOT_READY', 'the section main did not match within 15s']);
});
});
describe('app profile contract: no browser and the scope guard (T15, T20, T33)', () => {
const noBrowser = () => ({ engine: null, probe: { ok: false as const, reason: 'NEEDS_ASIDE' as const, detail: 'test' }, error: 'no browser available: run ./setup (NEEDS_ASIDE: test)' });
test('--url with no browser under page scope: RENDER_UNAVAILABLE names the redacted URL; --render over files keeps today\'s skip', async () => {
const url = 'http://127.0.0.1:5300/p?token=SECRET';
const r = await lint([], { urls: [url], engine: noBrowser, warn: () => {} });
expect(r.summary.render).toBe('skipped: no browser available: run ./setup (NEEDS_ASIDE: test)');
expect(renderUnavailable([url], r.summary.render)).toBe('gstack-design-lint: RENDER_UNAVAILABLE: nothing was measured at http://127.0.0.1:5300/p?token=REDACTED (no browser available: run ./setup (NEEDS_ASIDE: test)) | fix: gstack-browser-ensure, then rerun');
const files = await lint([path.join(ROOT, 'test/fixtures/design-lint/clean')], { cwd: ROOT, render: true, engine: noBrowser, warn: () => {} });
expect(renderUnavailable([], files.summary.render)).toBeNull();
expect(files.exitCode).toBe(0);
expect(renderUnavailable([url], 'ran')).toBeNull();
});
test('--scope app with no browser: every state x viewport NO_BROWSER, exit 4 and RENDER_UNAVAILABLE on stderr', async () => {
const r = await app({}, { engine: noBrowser });
expect(r.code).toBe(4);
expect(r.err).toBe('gstack-design-lint: RENDER_UNAVAILABLE: nothing was measured at ' + URL1 + ' (no browser available: run ./setup (NEEDS_ASIDE: test)) | fix: gstack-browser-ensure, then rerun');
expect(r.out).toMatch(/LINT_SUMMARY: blocks=0 ask=0 waived=0 polish=0 render=skipped: no browser available/);
expect(r.out).toMatch(/^LINT_APP: verdict=incomplete code=NO_BROWSER states=0\/1 /m);
});
test('app rules are app-only: tier ask, render, never reported by a page or component reporter; nothing else carries the marker', () => {
expect(APP_IDS.sort()).toEqual(['copy-flush-under-list', 'note-in-row-weight', 'row-crowding', 'section-overflow', 'small-hit-area']);
for (const id of APP_IDS) expect([id, RULES[id].tier, RULES[id].render]).toEqual([id, 'ask', true]);
for (const scope of ['page', 'component'] as const) expect(new Reporter(scope).report('row-crowding', 'x.html', 1, 'e')).toBeNull();
expect(new Reporter('app').report('row-crowding', 'x.html', 1, 'e')?.rule).toBe('row-crowding');
});
});
+247
View File
@@ -0,0 +1,247 @@
/**
* The app profile measured (gstack-design-lint --scope app, GST-1741 PR 1):
* the qm#3186 replica and its variants (other markup, open, slotted, nested and
* closed shadow roots, rows that load late), the fixed copy, the negative
* controls, the threshold boundaries at +-0.5px, and the false-positive
* corpus. Fixtures are served from a loopback server, so every state goes
* through the URL path the CLI takes. Skipped with a named reason when no
* browser is available, except in CI, where a missing browser fails.
*/
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { pickEngine, resolveBrowseBin, type EngineChoice } from '../lib/aside-render';
import { lintApp, type AppOptions, type AppResult } from '../lib/design-lint/render-app';
import { asideAvailable } from './helpers/aside-available';
import { browserAvailable, NO_BROWSER_REASON } from './helpers/browser-available';
const ROOT = path.resolve(import.meta.dir, '..');
const FIX = 'test/fixtures/ui-spacing';
const hasBrowser = browserAvailable();
if (!hasBrowser && process.env.CI) throw new Error('design-lint-app-profile: CI needs a browser for the app profile tier: ' + NO_BROWSER_REASON);
if (!hasBrowser) process.stderr.write('design-lint-app-profile: SKIPPED — ' + NO_BROWSER_REASON + '\n');
const engine = (): EngineChoice => {
if (asideAvailable()) return pickEngine(true);
const bin = resolveBrowseBin();
return bin ? { engine: 'browse', bin } : { engine: null, probe: { ok: false, reason: 'NEEDS_ASIDE', detail: NO_BROWSER_REASON }, error: NO_BROWSER_REASON };
};
const FOUR = ['copy-flush-under-list', 'note-in-row-weight', 'row-crowding', 'small-hit-area'];
const gen = fs.mkdtempSync(path.join(os.tmpdir(), 'design-lint-app-gen-'));
let base = '';
let server: ReturnType<typeof Bun.serve> | null = null;
beforeAll(() => {
if (!hasBrowser) return;
server = Bun.serve({
hostname: '127.0.0.1', port: 0,
fetch(req) {
const rel = decodeURIComponent(new URL(req.url).pathname);
const file = rel.startsWith('/gen/') ? path.join(gen, rel.slice(5)) : path.join(ROOT, rel);
const inside = file.startsWith(gen + path.sep) || file.startsWith(ROOT + path.sep);
if (!inside || !fs.existsSync(file) || !fs.statSync(file).isFile()) return new Response('not found', { status: 404 });
return new Response(Bun.file(file));
},
});
base = 'http://127.0.0.1:' + server.port;
});
afterAll(() => { server?.stop(true); fs.rmSync(gen, { recursive: true, force: true }); });
async function measure(page: string, within: string, more: Partial<AppOptions> = {}): Promise<AppResult & { ids: string[] }> {
const url = page.startsWith('/') ? base + page : base + '/' + FIX + '/' + page;
const r = await lintApp({ states: [{ name: page, url }], within, engine, traceDir: gen, ...more });
return { ...r, ids: [...new Set(r.findings.filter((f) => f.grade !== 'waived').map((f) => f.rule))].sort() };
}
const genPage = (name: string, css: string, body: string): string => {
fs.writeFileSync(path.join(gen, name), '<!doctype html><html lang="en"><head><meta charset="utf-8"><title>' + name + '</title><style>* { box-sizing: border-box } body { margin: 0; font: 14px/20px system-ui, sans-serif; color: #1d2433 } main { max-width: 640px; margin: 0 auto; padding: 24px 16px } ' + css + '</style></head><body><main><section class="probe">' + body + '</section></main></body></html>');
return '/gen/' + name;
};
describe.skipIf(!hasBrowser)('app profile: the replica and its variants', () => {
test('the replica reports exactly row-crowding, copy-flush-under-list, note-in-row-weight and small-hit-area; open, exit 1', async () => {
const r = await measure('replica.html', '.sharing');
expect(r.ids).toEqual(FOUR);
expect([r.app.verdict, r.exitCode, r.app.matched, r.app.states.map((s) => s.status)]).toEqual(['open', 1, 1, ['measured', 'measured']]);
const crowd = r.findings.find((f) => f.rule === 'row-crowding')!;
expect(crowd.evidence).toMatch(/^\[replica\.html 1440px\] a 2-line row leaves 4px between its text and its top edge \(under 8px\) at .*div\.rows:nth-of-type\(1\) > div\.row:nth-of-type\(1\) "Deploy key Read-only access to the repos"$/);
expect(crowd.app).toMatchObject({ state: 'replica.html', viewport: '1440x900', measured: 4, threshold: 8 });
const hit = r.findings.find((f) => f.rule === 'small-hit-area')!;
expect([hit.app!.viewport, hit.app!.measured, hit.app!.threshold, hit.app!.locator]).toEqual(['375x812', 18, 32, expect.stringContaining('button.x "Remove')]);
expect(r.findings.filter((f) => f.rule === 'small-hit-area').every((f) => f.app!.viewport === '375x812')).toBe(true);
expect(r.findings.filter((f) => f.rule === 'copy-flush-under-list').map((f) => f.app!.measured)).toEqual([4, 4, 4, 4, 4, 4]);
expect(new Set(r.findings.map((f) => f.app!.viewport + f.app!.locator + f.rule)).size).toBe(r.findings.length);
}, 60_000);
test('the fixed copy is clean, exit 0', async () => {
const r = await measure('fixed.html', '.sharing');
expect([r.findings, r.app.verdict, r.exitCode]).toEqual([[], 'clean', 0]);
}, 60_000);
test("the replica's defects in other markup (ul/li, a grid per row, renamed classes, no test hooks) give the same four ids", async () => {
expect(fs.readFileSync(path.join(ROOT, FIX, 'structural.html'), 'utf8')).not.toContain('data-testid');
expect((await measure('structural.html', '.settings-sharing')).ids).toEqual(FOUR);
}, 60_000);
test('open, slotted and nested shadow roots match the light DOM; a closed one is incomplete CLOSED_SHADOW, exit 4', async () => {
for (const [page, within] of [['shadow-open.html', 'qm-sharing'], ['shadow-slotted.html', 'qm-card'], ['nested-shadow.html', '.sharing']]) {
const r = await measure(page, within);
expect([page, r.ids, r.app.verdict]).toEqual([page, FOUR, 'open']);
}
const open = await measure('shadow-open.html', 'qm-sharing');
expect(open.findings[0].app!.locator).toStartWith('qm-sharing >>> ');
const closed = await measure('shadow-closed.html', 'qm-sharing');
expect([closed.app.verdict, closed.app.code, closed.exitCode, closed.findings]).toEqual(['incomplete', 'CLOSED_SHADOW', 4, []]);
}, 120_000);
test('rows that load 800ms late are measured once ready and match the replica; rows that never load are NOT_READY', async () => {
const late = await measure('async.html', '.sharing');
expect([late.ids, late.app.verdict]).toEqual([FOUR, 'open']);
const replica = await measure('replica.html', '.sharing');
expect(late.findings.map((f) => f.rule + f.app!.viewport + f.app!.measured)).toEqual(replica.findings.map((f) => f.rule + f.app!.viewport + f.app!.measured));
const never = await measure('never-ready.html', '.sharing', { readyMs: 1_500 });
expect([never.app.code, never.exitCode, never.app.states[0].detail]).toEqual(['NOT_READY', 4, 'no list of two or more rows appeared inside .sharing within 2s']);
}, 90_000);
test('a section nothing matches is WITHIN_NO_MATCH naming the roots searched; a state whose expect selector is absent is STATE_MISMATCH', async () => {
const none = await measure('shadow-open.html', '.no-such-section', { sectionWaitMs: 1_000, readyMs: 500 });
expect([none.app.code, none.app.matched, none.app.states[0].detail]).toEqual(['WITHIN_NO_MATCH', 0, 'nothing matched .no-such-section in the document or its 1 open shadow roots']);
const url = base + '/' + FIX + '/replica.html?as=lastowner';
const wrong = await lintApp({ states: [{ name: 'lastowner', url, expect: '.only-owner-note' }], within: '.sharing', engine, readyMs: 1_000 });
expect([wrong.app.code, wrong.exitCode]).toEqual(['STATE_MISMATCH', 4]);
const right = await lintApp({ states: [{ name: 'owner', url, expect: '.sharing .row' }], within: '.sharing', engine });
expect(right.app.verdict).toBe('open');
}, 90_000);
});
describe.skipIf(!hasBrowser)('app profile: what must not fire', () => {
test('plain role labels beside names are never notes; a note louder than its name is', async () => {
const reader = await measure('reader.html', '.members');
expect(reader.findings.filter((f) => f.rule === 'note-in-row-weight')).toEqual([]);
expect(reader.app.verdict).toBe('clean');
expect((await measure('larger-note.html', '.integrations')).ids).toEqual(['note-in-row-weight']);
}, 60_000);
test('a list of one row is not a list; a three-line note on a phone only is not note-in-row-weight', async () => {
const one = await measure('one-row.html', '.credentials', { waitFor: '.credentials' });
expect([one.findings, one.app.verdict]).toEqual([[], 'clean']);
const phone = await measure('mobile-note.html', '.channels');
expect([phone.findings, phone.app.verdict]).toEqual([[], 'clean']);
}, 60_000);
test('a defect outside --within is not reported; the same section measured directly is', async () => {
const page = genPage('outside.html', '.list { border: 1px solid #dfe3ea } .row { padding: 3px 12px } .row + .row { border-top: 1px solid #dfe3ea } .row p { margin: 0 } .row p + p { color: #6b7385; font-size: 13px } .ok .row { padding: 10px 12px } .ok + .bad { margin-top: 24px }',
'<div class="ok"><div class="list"><div class="row"><p>Deploy key</p><p>Read-only access</p></div><div class="row"><p>Registry token</p><p>Pushes images</p></div></div></div>'
+ '<div class="bad"><div class="list"><div class="row"><p>Deploy key</p><p>Read-only access</p></div><div class="row"><p>Registry token</p><p>Pushes images</p></div></div></div>');
const inside = await measure(page, '.ok');
expect([inside.findings, inside.app.verdict]).toEqual([[], 'clean']);
expect((await measure(page, '.bad')).ids).toEqual(['row-crowding']);
}, 60_000);
test('small-hit-area counts an input\'s label toward its box and skips a link inside a run of text; a bare 16px checkbox fires', async () => {
const page = genPage('targets.html', '.list { border: 1px solid #dfe3ea } .row { display: flex; gap: 8px; align-items: center; padding: 10px 12px } .row + .row { border-top: 1px solid #dfe3ea } .row p { margin: 0 } label.big { display: flex; align-items: center; gap: 8px; min-height: 40px } input { width: 16px; height: 16px; margin: 0 }',
'<div class="list"><div class="row"><label class="big"><input type="checkbox"> Notify me</label></div>'
+ '<div class="row"><p>Read the <a href="#policy">sharing policy</a> before adding people.</p></div>'
+ '<div class="row"><p>Mute</p><input type="checkbox" aria-label="Mute"></div></div>');
const r = await measure(page, '.probe');
expect(r.findings.map((f) => [f.rule, f.app!.viewport, f.app!.locator])).toEqual([['small-hit-area', '375x812', expect.stringMatching(/input "Mute"$/)]]);
}, 60_000);
test('negative controls with trigger bait: a nav list, a table, a card grid and a list spaced right give zero app findings', async () => {
const r = await measure('negatives.html', '.negatives');
expect([r.findings, r.app.verdict, r.exitCode]).toEqual([[], 'clean', 0]);
}, 60_000);
});
describe.skipIf(!hasBrowser)('app profile: thresholds at +-0.5px', () => {
const rows = (pad: string, css = '') => genPage('rows-' + pad.replace(/\W/g, '_') + css.length + '.html',
'.list { border: 1px solid #dfe3ea } .row + .row { border-top: 1px solid #dfe3ea } .row { padding: ' + pad + ' } .row p { margin: 0 } .help { margin: 0 } button { width: 40px; height: 40px } ' + css,
'<div class="list"><div class="row"><p>Deploy key</p><p>Read-only access to the repository</p></div><div class="row"><p>Registry token</p><p>Pushes release images</p></div></div><p class="help" style="margin-top: 20px">Credentials are encrypted at rest.</p>');
const at = async (page: string, rule: string) => (await measure(page, '.probe')).findings.filter((f) => f.rule === rule).map((f) => f.app!.measured);
test('row-crowding: 7.5px to a drawn edge fires, 8.5px does not', async () => {
expect([...new Set(await at(rows('7.5px 12px'), 'row-crowding'))]).toEqual([7.5]);
expect(await at(rows('8.5px 12px'), 'row-crowding')).toEqual([]);
}, 60_000);
test('row-crowding: layout subpixel noise never fires (7.97px does not), 7.9px does', async () => {
expect(await at(rows('7.97px 12px'), 'row-crowding')).toEqual([]);
expect((await at(rows('7.9px 12px'), 'row-crowding')).length).toBeGreaterThan(0);
}, 60_000);
test('row-crowding reads an unboxed list of laid-out rows (avatar, text and control side by side); an unboxed list of prose items stays prose', async () => {
const people = genPage('people-unboxed.html',
'.row { display: flex; align-items: center; gap: 10px; padding: 4px 0 } .av { width: 28px; height: 28px; border-radius: 14px; background: #dbe7f0; flex: none } .text { flex: 1 } .text div { margin: 0 } button { width: 80px; height: 32px; flex: none } .help { margin: 24px 0 0 }',
'<div class="list"><div class="row"><span class="av"></span><div class="text"><div>Dana Whitfield</div><div>Can\'t use personas until an admin turns them on</div></div><button>Can chat</button></div><div class="row"><span class="av"></span><div class="text"><div>Design team</div><div>Personas is off for the organization</div></div><button>Can chat</button></div></div><p class="help">Owners change who has access.</p>');
const crowded = await at(people, 'row-crowding');
expect(crowded.length).toBeGreaterThan(0);
expect(crowded.every((m) => m < 16)).toBe(true);
const prose = genPage('prose-unboxed.html', 'ul { margin: 0; padding-left: 20px } li { margin: 0 0 4px } .help { margin: 24px 0 0 }',
'<ul><li>Read the docs before you start, then run the setup script once on each machine you use</li><li>Keep the token out of the repository and rotate it when someone leaves the team</li></ul><p class="help">Questions go to the platform channel.</p>');
expect(await at(prose, 'row-crowding')).toEqual([]);
}, 60_000);
test('row-crowding: 15.5px from a two-line row\'s text to the next row\'s fires, 16.5px does not', async () => {
const pair = (next: number) => genPage('next-' + next + '.html',
'.list { border: 1px solid #dfe3ea } .row { padding: 8px 12px } .row p { margin: 0 } .row + .row { padding-top: ' + next + 'px }',
'<div class="list"><div class="row"><p>Deploy key</p><p>Read-only access to the repository</p></div><div class="row"><p>Registry token</p></div></div>');
expect(await at(pair(7.5), 'row-crowding')).toEqual([15.5, 15.5]);
expect(await at(pair(8.5), 'row-crowding')).toEqual([]);
}, 60_000);
test('copy-flush-under-list: 11.5px fires, 12.5px does not', async () => {
const help = (gap: number) => genPage('help-' + gap + '.html',
'.list { border: 1px solid #dfe3ea } .row { padding: 10px 12px } .row + .row { border-top: 1px solid #dfe3ea } .row p { margin: 0 } .help { margin: ' + gap + 'px 0 0 }',
'<div class="list"><div class="row"><p>Deploy key</p></div><div class="row"><p>Registry token</p></div></div><p class="help">Credentials are encrypted at rest.</p>');
expect(await at(help(11.5), 'copy-flush-under-list')).toEqual([11.5, 11.5]);
expect(await at(help(12.5), 'copy-flush-under-list')).toEqual([]);
}, 60_000);
test('small-hit-area at 375 only: 31.5px fires, 32.5px does not', async () => {
const hit = (side: number) => genPage('hit-' + side + '.html',
'.list { border: 1px solid #dfe3ea } .row { display: flex; align-items: center; justify-content: space-between; padding: 10px 12px } .row + .row { border-top: 1px solid #dfe3ea } .row p { margin: 0 } button { width: ' + side + 'px; height: 40px; padding: 0 }',
'<div class="list"><div class="row"><p>Deploy key</p><button aria-label="Remove">×</button></div><div class="row"><p>Registry token</p><button aria-label="Remove">×</button></div></div><p class="help" style="margin-top: 16px">Credentials are encrypted at rest.</p>');
const small = (await measure(hit(31.5), '.probe')).findings;
expect(small.map((f) => [f.rule, f.app!.viewport, f.app!.measured])).toEqual([['small-hit-area', '375x812', 31.5], ['small-hit-area', '375x812', 31.5]]);
expect(await at(hit(32.5), 'small-hit-area')).toEqual([]);
}, 60_000);
});
describe.skipIf(!hasBrowser)('app profile: the false-positive corpus (decision 6, T10)', () => {
test('zero app findings on all pages but one, and at most one on that page', async () => {
const corpus = JSON.parse(fs.readFileSync(path.join(ROOT, FIX, 'corpus.json'), 'utf8')) as { pages: Array<{ path: string; within: string; waitFor: string }> };
expect(corpus.pages.length).toBeGreaterThanOrEqual(10);
const counts: Array<[string, number, string]> = [];
for (const p of corpus.pages) {
const r = await measure('/' + p.path, p.within, { waitFor: p.waitFor });
counts.push([p.path, r.findings.length, r.app.verdict === 'incomplete' ? r.app.code : 'measured']);
}
expect(counts.filter(([, , status]) => status !== 'measured')).toEqual([]);
const withAny = counts.filter(([, n]) => n > 0);
expect(withAny.length).toBeLessThanOrEqual(1);
expect(withAny.every(([, n]) => n <= 1)).toBe(true);
}, 300_000);
});
describe.skipIf(!hasBrowser)('app profile: the CLI end to end', () => {
/** Asynchronous on purpose: the fixture server runs in this process, and a synchronous spawn would block it from answering. */
async function runBin(args: string[]): Promise<{ code: number; out: string }> {
const child = Bun.spawn([path.join(ROOT, 'bin', 'gstack-design-lint'), ...args], { cwd: ROOT, stdout: 'pipe', stderr: 'pipe', timeout: 120_000 });
const out = await new Response(child.stdout).text();
return { code: await child.exited, out };
}
test('the documented invocation prints the four ids, the LINT_APP line and exits 1; --json carries the app block', async () => {
const url = base + '/' + FIX + '/replica.html';
const text = await runBin(['--scope', 'app', '--url', url, '--within', '.sharing', '--viewport', '1440x900', '--viewport', '375x812']);
const out = text.out;
expect(text.code).toBe(1);
expect([...new Set(out.split('\n').filter((l) => l.startsWith('LINT: ')).map((l) => l.split(' ')[2]))].sort()).toEqual(FOUR);
expect(out.trim().split('\n').slice(-2)).toEqual([expect.stringMatching(/^LINT_SUMMARY: blocks=0 ask=25 waived=0 polish=0 render=ran$/), 'LINT_APP: verdict=open code=OPEN states=1/1 viewports=1440x900,375x812 within=.sharing matched=1 open=25 waived=0']);
const json = await runBin(['--scope', 'app', '--url', url, '--within', '.sharing', '--json']);
const doc = JSON.parse(json.out);
expect([json.code, doc.app.verdict, doc.app.states.length, doc.findings[0].app.threshold]).toEqual([1, 'open', 2, expect.any(Number)]);
}, 120_000);
});
+40
View File
@@ -0,0 +1,40 @@
/**
* The scope guard for GST-1741's app profile: --scope page and --scope
* component print the same bytes and exit with the same codes on the existing
* design-lint fixtures as bin/gstack-design-lint did on origin/main before the
* app profile (e58be61). The golden holds each case's exit code, line count
* and sha256; test/fixtures/ui-spacing/scope-guard-golden.json says how it was
* produced. Static tier only: the render tier's numbers depend on the browser.
*/
import { describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as path from 'node:path';
const ROOT = path.resolve(import.meta.dir, '..');
const BIN = path.join(ROOT, 'bin', 'gstack-design-lint');
function runBin(args: string[]) {
const r = Bun.spawnSync([BIN, ...args], { cwd: ROOT, stdout: 'pipe', stderr: 'pipe', timeout: 60_000 });
return { code: r.exitCode, out: r.stdout.toString(), err: r.stderr.toString() };
}
describe.skipIf(process.platform === 'win32')('design-lint scope guard: page and component profiles unchanged by --scope app', () => {
test('page and component output and exit codes on the existing fixtures match origin/main byte for byte', () => {
const golden = JSON.parse(fs.readFileSync(path.join(ROOT, 'test/fixtures/ui-spacing/scope-guard-golden.json'), 'utf8')) as { cases: Array<{ args: string[]; code: number; sha256: string; lines: number }> };
expect(golden.cases.length).toBeGreaterThanOrEqual(10);
for (const c of golden.cases) {
const r = runBin(c.args);
expect([c.args.join(' '), r.code, r.out.split('\n').length - 1, new Bun.CryptoHasher('sha256').update(r.out).digest('hex')]).toEqual([c.args.join(' '), c.code, c.lines, c.sha256]);
}
});
test('the app rules never print under page or component scope, and --rules lists them as app rules at ask', () => {
const page = runBin(['test/fixtures/design-lint']);
const component = runBin(['--scope', 'component', 'test/fixtures/design-lint/tailwind-fail']);
for (const id of ['row-crowding', 'copy-flush-under-list', 'note-in-row-weight', 'small-hit-area', 'section-overflow']) {
expect(page.out).not.toContain(' ' + id + ' ');
expect(component.out).not.toContain(' ' + id + ' ');
expect(runBin(['--rules']).out).toMatch(new RegExp('^ask\\s+' + id + '\\s+app\\s', 'm'));
}
});
});
+2 -1
View File
@@ -216,8 +216,9 @@ describe('design-lint: the output contract', () => {
expect(r.out).toMatch(/^LINT: blocks render http:\/\/127\.0\.0\.1:9\/:1 could not reach http:\/\/127\.0\.0\.1:9\/: .+ \| fix: .+$/m);
expect(r.out).toMatch(/^LINT_SUMMARY: blocks=1 ask=0 waived=0 polish=0 render=ran$/m);
} else {
expect(r.code).toBe(0);
expect(r.code).toBe(4);
expect(r.out).toMatch(/^LINT_SUMMARY: blocks=0 ask=0 waived=0 polish=0 render=skipped: /m);
expect(r.err).toMatch(/^gstack-design-lint: RENDER_UNAVAILABLE: nothing was measured at http:\/\/127\.0\.0\.1:9\/ \(no browser available.+\) \| fix: gstack-browser-ensure, then rerun$/m);
}
});
});
+4
View File
@@ -13,6 +13,10 @@
{ "file": "lib/design-lint/render-probe.ts", "movedFrom": "(new: the bundled design lint, GSTA-105; ported from hallmark sloplint-render.mjs)" },
{ "file": "lib/design-lint/render-probe-space.ts", "movedFrom": "(new: the bundled design lint, GSTA-105; ported from hallmark sloplint-render.mjs)" },
{ "file": "lib/design-lint/render-probe-quality.ts", "movedFrom": "(new: the bundled design lint, GSTA-105; ported from hallmark sloplint-render.mjs)" },
{ "file": "lib/design-lint/render-app.ts", "movedFrom": "(new: the design lint's app profile scheduler, GST-1741)" },
{ "file": "lib/design-lint/render-probe-app.ts", "movedFrom": "(new: the design lint's app profile probes, GST-1741)" },
{ "file": "lib/design-lint/app-input.ts", "movedFrom": "(new: the design lint's app profile inputs and URL redaction, GST-1741)" },
{ "file": "lib/design-lint/app-cli.ts", "movedFrom": "(new: the design lint's --scope app CLI, GST-1741)" },
{ "file": "lib/design-lint/render-png.ts", "movedFrom": "(new: the bundled design lint, GSTA-105; ported from hallmark sloplint-render.mjs)" },
{ "file": "lib/design-lint/rules/assets.ts", "movedFrom": "(new: the bundled design lint, GSTA-105; ported from hallmark sloplint.mjs)" },
{ "file": "lib/design-lint/rules/chrome-copy.ts", "movedFrom": "(new: the bundled design lint, GSTA-105; ported from hallmark sloplint.mjs)" },
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Project settings: rows load late</title>
<link rel="stylesheet" href="replica.css">
<script src="sharing.js"></script>
</head>
<body>
<main>
<div id="mount"><section class="panel sharing"><h2>Sharing</h2><p>Loading…</p></section></div>
<script>setTimeout(() => { document.getElementById('mount').innerHTML = window.SHARING_HTML; }, 800);</script>
</main>
</body>
</html>
+95
View File
@@ -0,0 +1,95 @@
{
"about": "The app profile's false-positive corpus (GST-1741 decision 6): gstack-owned pages unrelated to the qm#3186 shape, each measured under --scope app at 1440x900 and 375x812 with the --within and --wait-for given. Bar (test/design-lint-app-profile.test.ts): zero app findings on all but one page, and at most one on that page. The pages carry no lists of the replica's kind, so --wait-for stands in for the list readiness wait.",
"pages": [
{
"path": "browse/src/welcome.html",
"within": "body",
"waitFor": "body",
"what": "the browse welcome page"
},
{
"path": "extension/popup.html",
"within": "body",
"waitFor": "body",
"what": "the extension popup"
},
{
"path": "browse/test/fixtures/forms.html",
"within": "body",
"waitFor": "body",
"what": "a login and profile form"
},
{
"path": "browse/test/fixtures/qa-eval-checkout.html",
"within": "body",
"waitFor": "body",
"what": "a checkout form"
},
{
"path": "browse/test/fixtures/qa-eval-spa.html",
"within": "body",
"waitFor": "body",
"what": "a single-page app shell"
},
{
"path": "browse/test/fixtures/states.html",
"within": "body",
"waitFor": "body",
"what": "controls in their states"
},
{
"path": "browse/test/fixtures/snapshot.html",
"within": "body",
"waitFor": "body",
"what": "a mixed app page"
},
{
"path": "browse/test/fixtures/responsive.html",
"within": "body",
"waitFor": "body",
"what": "a responsive layout"
},
{
"path": "browse/test/fixtures/basic.html",
"within": "body",
"waitFor": "body",
"what": "a basic page"
},
{
"path": "test/fixtures/design-lint/clean/index.html",
"within": "body",
"waitFor": "body",
"what": "the design lint's clean render fixture"
},
{
"path": "test/fixtures/design-lint/tailwind-pass/index.html",
"within": "body",
"waitFor": "body",
"what": "a Tailwind page with a bordered list"
},
{
"path": "test/fixtures/design-lint/eyebrow-pass/index.html",
"within": "body",
"waitFor": "body",
"what": "a docs page with a step list"
}
],
"excluded": [
{
"path": "browse/test/fixtures/qa-eval.html",
"why": "a QA fixture with planted defects; its stats line really overflows its box (section-overflow is a true positive)"
},
{
"path": "browse/test/fixtures/dropdown.html",
"why": "its search input is 320px inside a 300px container: a real overflow"
},
{
"path": "test/fixtures/design-motion-finalized.html",
"why": "absolutely positioned cards run 233px past a 375px screen: a real overflow"
},
{
"path": "extension/sidepanel.html",
"why": "links xterm.css, which ./setup builds and the repository does not hold, so it is incomplete (ASSET_MISSING), not measurable"
}
]
}
+54
View File
@@ -0,0 +1,54 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Project settings: the replica with its spacing fixed</title>
<style>
:root { --ink: #1d2433; --muted: #6b7385; --line: #dfe3ea; --paper: #ffffff; }
* { box-sizing: border-box; }
body { margin: 0; font: 14px/20px system-ui, -apple-system, "Segoe UI", sans-serif; color: var(--ink); background: #f5f6f8; }
main { max-width: 720px; margin: 0 auto; padding: 24px 16px; }
.panel { background: var(--paper); border: 1px solid var(--line); border-radius: 8px; padding: 16px 20px; }
.panel h2 { font-size: 18px; line-height: 24px; margin: 0 0 12px; }
.panel h3 { font-size: 15px; line-height: 20px; margin: 24px 0 8px; }
.rows { border: 1px solid var(--line); border-radius: 6px; }
.row { display: flex; align-items: center; gap: 12px; padding: 10px 12px; border-top: 1px solid var(--line); }
.row:first-child { border-top: 0; }
.row .text { flex: 1; min-width: 0; }
.row .name { font-weight: 600; }
.row .desc { color: var(--muted); font-size: 13px; }
.row .flag { display: flex; gap: 6px; align-items: center; color: #8a5a00; font-size: 13px; }
.row .flag svg { flex: none; width: 14px; height: 14px; }
.row .role { color: var(--muted); font-size: 13px; margin-left: 8px; }
.x { flex: none; width: 32px; height: 32px; margin: -7px -7px -7px 0; border: 0; padding: 0; background: transparent; color: var(--muted); font-size: 14px; line-height: 18px; cursor: pointer; }
.help { color: var(--muted); font-size: 13px; margin: 16px 0 0; }
</style>
</head>
<body>
<main>
<section class="panel sharing">
<h2>Sharing</h2>
<h3>Credentials</h3>
<div class="rows">
<div class="row"><div class="text"><div class="name">Deploy key</div><div class="desc">Read-only access to the repository, added by Dana on March 3</div></div><button class="x" aria-label="Remove Deploy key">×</button></div>
<div class="row"><div class="text"><div class="name">Registry token</div><div class="desc">Pushes release images, added by Omar on April 18</div></div><button class="x" aria-label="Remove Registry token">×</button></div>
</div>
<p class="help">Credentials are encrypted at rest and never shown again after you add them.</p>
<h3>Slack channels</h3>
<div class="rows">
<div class="row"><div class="text"><div class="name">#deploys</div><div class="flag"><svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 1 15 14H1z" fill="currentColor"/></svg><span>The bot is not in this channel yet, so posts will fail.</span></div></div><button class="x" aria-label="Remove #deploys">×</button></div>
<div class="row"><div class="text"><div class="name">#release-notes</div><div class="flag"><svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 1 15 14H1z" fill="currentColor"/></svg><span>Posting paused by a workspace admin.</span></div></div><button class="x" aria-label="Remove #release-notes">×</button></div>
</div>
<p class="help">Channels get a message when a deploy starts and when it finishes.</p>
<h3>Who has access</h3>
<div class="rows">
<div class="row"><div class="text"><span class="name">Dana Whitfield</span><span class="role">Owner</span></div><button class="x" aria-label="Remove Dana Whitfield">×</button></div>
<div class="row"><div class="text"><span class="name">Omar Haddad</span><span class="role">Editor</span></div><button class="x" aria-label="Remove Omar Haddad">×</button></div>
<div class="row"><div class="text"><span class="name">Lena Park</span><span class="role">Reader</span></div><button class="x" aria-label="Remove Lena Park">×</button></div>
</div>
<p class="help">Readers see deploys and logs; editors can change settings.</p>
</section>
</main>
</body>
</html>
+37
View File
@@ -0,0 +1,37 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Integrations: a note louder than its name</title>
<style>
:root { --ink: #1d2433; --muted: #6b7385; --line: #dfe3ea; --paper: #ffffff; }
* { box-sizing: border-box; }
body { margin: 0; font: 14px/20px system-ui, -apple-system, "Segoe UI", sans-serif; color: var(--ink); background: #f5f6f8; }
main { max-width: 720px; margin: 0 auto; padding: 24px 16px; }
.panel { background: var(--paper); border: 1px solid var(--line); border-radius: 8px; padding: 16px 20px; }
.panel h2 { font-size: 18px; line-height: 24px; margin: 0 0 12px; }
.rows { border: 1px solid var(--line); border-radius: 6px; }
.row { display: flex; align-items: center; gap: 12px; padding: 10px 12px; border-top: 1px solid var(--line); }
.row:first-child { border-top: 0; }
.row .text { flex: 1; min-width: 0; }
.row .name { font-weight: 600; }
.help { color: var(--muted); font-size: 13px; margin: 16px 0 0; }
.x { flex: none; width: 32px; height: 32px; border: 0; padding: 0; background: transparent; color: var(--muted); }
.row .name { font-weight: 400; font-size: 14px; }
.row .note { font-weight: 700; font-size: 16px; line-height: 22px; }
</style>
</head>
<body>
<main>
<section class="panel integrations">
<h2>Integrations</h2>
<div class="rows">
<div class="row"><div class="text"><div class="name">GitHub</div><div class="note">Reconnect: the token expired yesterday.</div></div><button class="x" aria-label="Disconnect GitHub">×</button></div>
<div class="row"><div class="text"><div class="name">PagerDuty</div><div class="note">Paging is off until a schedule is chosen.</div></div><button class="x" aria-label="Disconnect PagerDuty">×</button></div>
</div>
<p class="help">Integrations act with the permissions of the person who connected them.</p>
</section>
</main>
</body>
</html>
+36
View File
@@ -0,0 +1,36 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Slack channels: a note that wraps on a phone</title>
<style>
:root { --ink: #1d2433; --muted: #6b7385; --line: #dfe3ea; --paper: #ffffff; }
* { box-sizing: border-box; }
body { margin: 0; font: 14px/20px system-ui, -apple-system, "Segoe UI", sans-serif; color: var(--ink); background: #f5f6f8; }
main { max-width: 720px; margin: 0 auto; padding: 24px 16px; }
.panel { background: var(--paper); border: 1px solid var(--line); border-radius: 8px; padding: 16px 20px; }
.panel h2 { font-size: 18px; line-height: 24px; margin: 0 0 12px; }
.rows { border: 1px solid var(--line); border-radius: 6px; }
.row { display: flex; align-items: center; gap: 12px; padding: 10px 12px; border-top: 1px solid var(--line); }
.row:first-child { border-top: 0; }
.row .text { flex: 1; min-width: 0; }
.row .name { font-weight: 600; }
.help { color: var(--muted); font-size: 13px; margin: 16px 0 0; }
.x { flex: none; width: 32px; height: 32px; border: 0; padding: 0; background: transparent; color: var(--muted); }
.row .flag { color: var(--muted); font-size: 13px; }
</style>
</head>
<body>
<main>
<section class="panel channels">
<h2>Slack channels</h2>
<div class="rows">
<div class="row"><div class="text"><div class="name">#deploys</div><div class="flag">The bot is not in this channel yet, so the deploy summary and the rollback alerts will not post until someone invites it.</div></div><button class="x" aria-label="Remove #deploys">×</button></div>
<div class="row"><div class="text"><div class="name">#release-notes</div><div class="flag">Posting resumes after a workspace admin approves the app again for this channel and its threads.</div></div><button class="x" aria-label="Remove #release-notes">×</button></div>
</div>
<p class="help">Channels get a message when a deploy starts and when it finishes.</p>
</section>
</main>
</body>
</html>
+70
View File
@@ -0,0 +1,70 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Negative controls: a nav list, a table, a card grid and a list spaced right</title>
<style>
:root { --ink: #1d2433; --muted: #6b7385; --line: #dfe3ea; --paper: #ffffff; }
* { box-sizing: border-box; }
body { margin: 0; font: 14px/20px system-ui, -apple-system, "Segoe UI", sans-serif; color: var(--ink); background: #f5f6f8; }
main { max-width: 720px; margin: 0 auto; padding: 24px 16px; }
.panel { background: var(--paper); border: 1px solid var(--line); border-radius: 8px; padding: 16px 20px; }
.panel h2 { font-size: 18px; line-height: 24px; margin: 0 0 12px; }
.rows { border: 1px solid var(--line); border-radius: 6px; }
.row { display: flex; align-items: center; gap: 12px; padding: 10px 12px; border-top: 1px solid var(--line); }
.row:first-child { border-top: 0; }
.row .text { flex: 1; min-width: 0; }
.row .name { font-weight: 600; }
.help { color: var(--muted); font-size: 13px; margin: 16px 0 0; }
.x { flex: none; width: 32px; height: 32px; border: 0; padding: 0; background: transparent; color: var(--muted); }
.panel + .panel { margin-top: 24px; }
nav.side ul { list-style: none; margin: 0; padding: 0; border: 1px solid var(--line); }
nav.side li { padding: 2px 6px; }
nav.side a { color: var(--ink); font-size: 14px; }
nav.side small { display: block; }
table { border-collapse: collapse; width: 100%; }
td, th { padding: 4px; border: 1px solid var(--line); text-align: left; vertical-align: top; }
td small { display: block; color: var(--ink); }
td button { width: 18px; height: 18px; padding: 0; }
.table-help { margin: 2px 0 0; }
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(96px, 1fr)); gap: 12px; }
.tile { border: 1px solid var(--line); padding: 3px; }
.tile b { display: block; }
.row .desc { color: var(--muted); font-size: 13px; }
</style>
</head>
<body>
<main>
<section class="panel negatives">
<h2>Navigation</h2>
<nav class="side"><ul>
<li><a href="#general">General</a><small>Name, region and billing owner</small></li>
<li><a href="#sharing">Sharing</a><small>Credentials, channels and people</small></li>
<li><a href="#danger">Danger zone</a><small>Archive or delete the project</small></li>
</ul></nav>
<p class="table-help">Settings are saved as you go.</p>
<h2>Deploys</h2>
<table>
<tr><th>Service</th><th>Status</th><th></th></tr>
<tr><td>api<small>us-east</small></td><td>Live<small>2 min ago</small></td><td><button aria-label="Retry api">↻</button></td></tr>
<tr><td>web<small>us-west</small></td><td>Building<small>just now</small></td><td><button aria-label="Retry web">↻</button></td></tr>
</table>
<p class="table-help">Deploys older than 30 days are archived.</p>
<h2>Environments</h2>
<div class="cards">
<div class="tile"><b>Production</b>Three regions</div>
<div class="tile"><b>Staging</b>One region</div>
<div class="tile"><b>Preview</b>Per branch</div>
</div>
<p class="table-help">Each environment has its own secrets.</p>
<h2>Credentials</h2>
<div class="rows">
<div class="row"><div class="text"><div class="name">Deploy key</div><div class="desc">Read-only access to the repository, added by Dana on March 3</div></div><button class="x" aria-label="Remove Deploy key">×</button></div>
<div class="row"><div class="text"><div class="name">Registry token</div><div class="desc">Pushes release images, added by Omar on April 18</div></div><button class="x" aria-label="Remove Registry token">×</button></div>
</div>
<p class="help">Credentials are encrypted at rest and never shown again after you add them.</p>
</section>
</main>
</body>
</html>
+22
View File
@@ -0,0 +1,22 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Project settings: nested open shadow roots</title>
<script src="sharing.js"></script>
</head>
<body>
<main>
<qm-settings></qm-settings>
<script>
customElements.define('qm-sharing', class extends HTMLElement {
constructor() { super(); this.attachShadow({ mode: 'open' }).innerHTML = '<link rel="stylesheet" href="replica.css">' + window.SHARING_HTML; }
});
customElements.define('qm-settings', class extends HTMLElement {
constructor() { super(); this.attachShadow({ mode: 'open' }).innerHTML = '<div class="frame"><qm-sharing></qm-sharing></div>'; }
});
</script>
</main>
</body>
</html>
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Project settings: rows never load</title>
<link rel="stylesheet" href="replica.css">
</head>
<body>
<main>
<section class="panel sharing"><h2>Sharing</h2><p>Loading…</p></section>
</main>
</body>
</html>
+36
View File
@@ -0,0 +1,36 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Credentials: a list of one</title>
<style>
:root { --ink: #1d2433; --muted: #6b7385; --line: #dfe3ea; --paper: #ffffff; }
* { box-sizing: border-box; }
body { margin: 0; font: 14px/20px system-ui, -apple-system, "Segoe UI", sans-serif; color: var(--ink); background: #f5f6f8; }
main { max-width: 720px; margin: 0 auto; padding: 24px 16px; }
.panel { background: var(--paper); border: 1px solid var(--line); border-radius: 8px; padding: 16px 20px; }
.panel h2 { font-size: 18px; line-height: 24px; margin: 0 0 12px; }
.rows { border: 1px solid var(--line); border-radius: 6px; }
.row { display: flex; align-items: center; gap: 12px; padding: 10px 12px; border-top: 1px solid var(--line); }
.row:first-child { border-top: 0; }
.row .text { flex: 1; min-width: 0; }
.row .name { font-weight: 600; }
.help { color: var(--muted); font-size: 13px; margin: 16px 0 0; }
.x { flex: none; width: 32px; height: 32px; border: 0; padding: 0; background: transparent; color: var(--muted); }
.row { padding: 3px 12px; }
.row .desc { color: var(--muted); font-size: 13px; }
</style>
</head>
<body>
<main>
<section class="panel credentials">
<h2>Credentials</h2>
<div class="rows">
<div class="row"><div class="text"><div class="name">Deploy key</div><div class="desc">Read-only access to the repository, added by Dana on March 3</div></div><button class="x" aria-label="Remove Deploy key">×</button></div>
</div>
<p class="help">Credentials are encrypted at rest and never shown again after you add them.</p>
</section>
</main>
</body>
</html>
+39
View File
@@ -0,0 +1,39 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Project members: role labels beside names</title>
<style>
:root { --ink: #1d2433; --muted: #6b7385; --line: #dfe3ea; --paper: #ffffff; }
* { box-sizing: border-box; }
body { margin: 0; font: 14px/20px system-ui, -apple-system, "Segoe UI", sans-serif; color: var(--ink); background: #f5f6f8; }
main { max-width: 720px; margin: 0 auto; padding: 24px 16px; }
.panel { background: var(--paper); border: 1px solid var(--line); border-radius: 8px; padding: 16px 20px; }
.panel h2 { font-size: 18px; line-height: 24px; margin: 0 0 12px; }
.rows { border: 1px solid var(--line); border-radius: 6px; }
.row { display: flex; align-items: center; gap: 12px; padding: 10px 12px; border-top: 1px solid var(--line); }
.row:first-child { border-top: 0; }
.row .text { flex: 1; min-width: 0; }
.row .name { font-weight: 600; }
.help { color: var(--muted); font-size: 13px; margin: 16px 0 0; }
.x { flex: none; width: 32px; height: 32px; border: 0; padding: 0; background: transparent; color: var(--muted); }
.row .role { font-size: 14px; margin-left: 8px; }
.row .mail { color: var(--muted); font-size: 13px; }
.pick { height: 32px; font: inherit; }
</style>
</head>
<body>
<main>
<section class="panel members">
<h2>Members</h2>
<div class="rows">
<div class="row"><div class="text"><div><span class="name">Dana Whitfield</span><span class="role">Owner</span></div><div class="mail">dana@example.test</div></div><button class="x" aria-label="Remove Dana Whitfield">×</button></div>
<div class="row"><div class="text"><div><span class="name">Omar Haddad</span><span class="role">Reader</span></div><div class="mail">omar@example.test</div></div><button class="x" aria-label="Remove Omar Haddad">×</button></div>
<div class="row"><div class="text"><div><span class="name">Lena Park</span><span class="role">Reader</span></div><div class="mail">lena@example.test</div></div><select class="pick" aria-label="Role for Lena Park"><option>Reader</option><option>Editor</option><option>Owner</option></select><button class="x" aria-label="Remove Lena Park">×</button></div>
</div>
<p class="help">Readers see deploys and logs.</p>
</section>
</main>
</body>
</html>
+19
View File
@@ -0,0 +1,19 @@
/* The qm#3186 shape (GST-1741): a Sharing panel whose rows crowd, whose help copy sits flush under its lists, whose status notes read at the name's weight, and whose remove controls are 18px. */
:root, :host { --ink: #1d2433; --muted: #6b7385; --line: #dfe3ea; --paper: #ffffff; }
:host { display: block; font: 14px/20px system-ui, -apple-system, "Segoe UI", sans-serif; color: var(--ink); }
* { box-sizing: border-box; }
body { margin: 0; font: 14px/20px system-ui, -apple-system, "Segoe UI", sans-serif; color: var(--ink); background: #f5f6f8; }
main { max-width: 720px; margin: 0 auto; padding: 24px 16px; }
.panel { background: var(--paper); border: 1px solid var(--line); border-radius: 8px; padding: 16px 20px; }
.panel h2 { font-size: 18px; line-height: 24px; margin: 0 0 12px; }
.panel h3 { font-size: 15px; line-height: 20px; margin: 20px 0 8px; }
.rows { border: 1px solid var(--line); border-radius: 6px; }
.row { display: flex; align-items: center; gap: 12px; padding: 4px 12px; border-top: 1px solid var(--line); }
.row:first-child { border-top: 0; }
.row .text { flex: 1; min-width: 0; }
.row .name { font-weight: 600; }
.row .desc { color: var(--muted); font-size: 13px; }
.row .flag { color: var(--ink); font-size: 14px; }
.row .role { color: var(--muted); font-size: 13px; margin-left: 8px; }
.x { flex: none; width: 18px; height: 18px; border: 0; padding: 0; background: transparent; color: var(--muted); font-size: 14px; line-height: 18px; cursor: pointer; }
.help { color: var(--muted); font-size: 13px; margin: 4px 0 0; }
+35
View File
@@ -0,0 +1,35 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Project settings: replica of qm#3186</title>
<link rel="stylesheet" href="replica.css">
</head>
<body>
<main>
<section class="panel sharing">
<h2>Sharing</h2>
<h3>Credentials</h3>
<div class="rows">
<div class="row"><div class="text"><div class="name">Deploy key</div><div class="desc">Read-only access to the repository, added by Dana on March 3</div></div><button class="x" aria-label="Remove Deploy key">×</button></div>
<div class="row"><div class="text"><div class="name">Registry token</div><div class="desc">Pushes release images, added by Omar on April 18</div></div><button class="x" aria-label="Remove Registry token">×</button></div>
</div>
<p class="help">Credentials are encrypted at rest and never shown again after you add them.</p>
<h3>Slack channels</h3>
<div class="rows">
<div class="row"><div class="text"><div class="name">#deploys</div><div class="flag">The bot is not in this channel yet, so posts will fail.</div></div><button class="x" aria-label="Remove #deploys">×</button></div>
<div class="row"><div class="text"><div class="name">#release-notes</div><div class="flag">Posting paused by a workspace admin.</div></div><button class="x" aria-label="Remove #release-notes">×</button></div>
</div>
<p class="help">Channels get a message when a deploy starts and when it finishes.</p>
<h3>Who has access</h3>
<div class="rows">
<div class="row"><div class="text"><span class="name">Dana Whitfield</span><span class="role">Owner</span></div><button class="x" aria-label="Remove Dana Whitfield">×</button></div>
<div class="row"><div class="text"><span class="name">Omar Haddad</span><span class="role">Editor</span></div><button class="x" aria-label="Remove Omar Haddad">×</button></div>
<div class="row"><div class="text"><span class="name">Lena Park</span><span class="role">Reader</span></div><button class="x" aria-label="Remove Lena Park">×</button></div>
</div>
<p class="help">Readers see deploys and logs; editors can change settings.</p>
</section>
</main>
</body>
</html>
+131
View File
@@ -0,0 +1,131 @@
{
"generatedFrom": "bin/gstack-design-lint at e58be61 (origin/main before GST-1741 PR 1), run from the repository root",
"cases": [
{
"args": [
"test/fixtures/design-lint/slopped.html"
],
"code": 2,
"lines": 46,
"sha256": "295bbc0c354f8064459d30e389aa230eb53f257ad781ba844159230ddcf91515"
},
{
"args": [
"test/fixtures/design-lint/clean"
],
"code": 0,
"lines": 1,
"sha256": "90680fde67b703ba3f12bbae8638f8ff7053561d76b9722cf7cb0dda37cc6ea1"
},
{
"args": [
"test/fixtures/design-lint/eyebrow-fail"
],
"code": 2,
"lines": 18,
"sha256": "61350c90e70845368095ef0b61b650fbbef9f9206a70c8e70ff3cbda966c183c"
},
{
"args": [
"test/fixtures/design-lint/eyebrow-pass"
],
"code": 0,
"lines": 3,
"sha256": "9da5caa6d3bea8f7624f2938e22466e60791ca61afbddfeba560f69c4d2bdf28"
},
{
"args": [
"test/fixtures/design-lint/tailwind-fail"
],
"code": 2,
"lines": 41,
"sha256": "e6901b66a090638abca7e22ec89843de64e719900618b7c37e01c3cad39e0f90"
},
{
"args": [
"test/fixtures/design-lint/tailwind-pass"
],
"code": 0,
"lines": 2,
"sha256": "727a01dee14590f39de8607303db1fbe20a1236bc06bf753395b169a6fbbb326"
},
{
"args": [
"test/fixtures/design-lint/waiver"
],
"code": 1,
"lines": 12,
"sha256": "e6c850be2214066f74469e3483b4a3ee73bc0da4ff6da1e5319225df5a516d15"
},
{
"args": [
"test/fixtures/design-lint"
],
"code": 2,
"lines": 110,
"sha256": "71e69ecf0e2b93437ff29bc5f2e94bfb85089fc64f8e318191f93331cdf1626f"
},
{
"args": [
"--scope",
"component",
"test/fixtures/design-lint/tailwind-fail"
],
"code": 2,
"lines": 33,
"sha256": "2c18f9e7e5ca30bae2543d5b7aaf54c958feadecada1be9aa19e47e598231279"
},
{
"args": [
"--scope",
"component",
"test/fixtures/design-lint/tailwind-pass"
],
"code": 0,
"lines": 1,
"sha256": "90680fde67b703ba3f12bbae8638f8ff7053561d76b9722cf7cb0dda37cc6ea1"
},
{
"args": [
"--scope",
"component",
"test/fixtures/design-lint/slopped.html"
],
"code": 2,
"lines": 37,
"sha256": "e6305053b6de5a3ce42eb165875b1a135636fe6daf903b18a05078bc469aca20"
},
{
"args": [
"--scope",
"page",
"--job",
"book",
"test/fixtures/design-lint/slopped.html"
],
"code": 2,
"lines": 46,
"sha256": "295bbc0c354f8064459d30e389aa230eb53f257ad781ba844159230ddcf91515"
},
{
"args": [
"--json",
"test/fixtures/design-lint/slopped.html"
],
"code": 2,
"lines": 462,
"sha256": "61a824bec090e297fdbadd2a753892c2fa1e0ae232d1dee0ff05922fa167a18f"
},
{
"args": [
"--json",
"--scope",
"component",
"test/fixtures/design-lint/tailwind-fail"
],
"code": 2,
"lines": 332,
"sha256": "2c8c88c80449a9cefbadacfbc4886b7404378087a525fd627cc9cd0607e836a8"
}
]
}
+19
View File
@@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Project settings: a closed shadow root</title>
<script src="sharing.js"></script>
</head>
<body>
<main>
<qm-sharing></qm-sharing>
<script>
customElements.define('qm-sharing', class extends HTMLElement {
constructor() { super(); this.attachShadow({ mode: 'closed' }).innerHTML = '<link rel="stylesheet" href="replica.css"><style>:host { display: block }</style>' + window.SHARING_HTML; }
});
</script>
</main>
</body>
</html>
+19
View File
@@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Project settings: an open shadow root</title>
<script src="sharing.js"></script>
</head>
<body>
<main>
<qm-sharing></qm-sharing>
<script>
customElements.define('qm-sharing', class extends HTMLElement {
constructor() { super(); this.attachShadow({ mode: 'open' }).innerHTML = '<link rel="stylesheet" href="replica.css">' + window.SHARING_HTML; }
});
</script>
</main>
</body>
</html>
+21
View File
@@ -0,0 +1,21 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Project settings: rows slotted into a shadow root</title>
<link rel="stylesheet" href="replica.css">
<script src="sharing.js"></script>
</head>
<body>
<main>
<qm-card id="card"></qm-card>
<script>
customElements.define('qm-card', class extends HTMLElement {
constructor() { super(); this.attachShadow({ mode: 'open' }).innerHTML = '<style>:host { display: block }</style><div class="card-frame"><slot></slot></div>'; }
});
document.getElementById('card').innerHTML = window.SHARING_HTML;
</script>
</main>
</body>
</html>
+2
View File
@@ -0,0 +1,2 @@
// The replica's Sharing section as one string, for the variants that build it with script (async, shadow). test/design-lint-app-profile.test.ts holds it equal to replica.html's markup.
window.SHARING_HTML = "<section class=\"panel sharing\">\n <h2>Sharing</h2>\n <h3>Credentials</h3>\n <div class=\"rows\">\n <div class=\"row\"><div class=\"text\"><div class=\"name\">Deploy key</div><div class=\"desc\">Read-only access to the repository, added by Dana on March 3</div></div><button class=\"x\" aria-label=\"Remove Deploy key\">×</button></div>\n <div class=\"row\"><div class=\"text\"><div class=\"name\">Registry token</div><div class=\"desc\">Pushes release images, added by Omar on April 18</div></div><button class=\"x\" aria-label=\"Remove Registry token\">×</button></div>\n </div>\n <p class=\"help\">Credentials are encrypted at rest and never shown again after you add them.</p>\n <h3>Slack channels</h3>\n <div class=\"rows\">\n <div class=\"row\"><div class=\"text\"><div class=\"name\">#deploys</div><div class=\"flag\">The bot is not in this channel yet, so posts will fail.</div></div><button class=\"x\" aria-label=\"Remove #deploys\">×</button></div>\n <div class=\"row\"><div class=\"text\"><div class=\"name\">#release-notes</div><div class=\"flag\">Posting paused by a workspace admin.</div></div><button class=\"x\" aria-label=\"Remove #release-notes\">×</button></div>\n </div>\n <p class=\"help\">Channels get a message when a deploy starts and when it finishes.</p>\n <h3>Who has access</h3>\n <div class=\"rows\">\n <div class=\"row\"><div class=\"text\"><span class=\"name\">Dana Whitfield</span><span class=\"role\">Owner</span></div><button class=\"x\" aria-label=\"Remove Dana Whitfield\">×</button></div>\n <div class=\"row\"><div class=\"text\"><span class=\"name\">Omar Haddad</span><span class=\"role\">Editor</span></div><button class=\"x\" aria-label=\"Remove Omar Haddad\">×</button></div>\n <div class=\"row\"><div class=\"text\"><span class=\"name\">Lena Park</span><span class=\"role\">Reader</span></div><button class=\"x\" aria-label=\"Remove Lena Park\">×</button></div>\n </div>\n <p class=\"help\">Readers see deploys and logs; editors can change settings.</p>\n</section>";
+51
View File
@@ -0,0 +1,51 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Project settings: the replica's defects in other markup</title>
<style>
:root { --ink: #1d2433; --muted: #6b7385; --line: #dfe3ea; --paper: #ffffff; }
* { box-sizing: border-box; }
body { margin: 0; font: 14px/20px system-ui, -apple-system, "Segoe UI", sans-serif; color: var(--ink); background: #f5f6f8; }
main { max-width: 720px; margin: 0 auto; padding: 24px 16px; }
.card { background: var(--paper); border: 1px solid var(--line); border-radius: 8px; padding: 16px 20px; }
.card h2 { font-size: 18px; line-height: 24px; margin: 0 0 12px; }
.card h3 { font-size: 15px; line-height: 20px; margin: 20px 0 8px; }
.entries { list-style: none; margin: 0; padding: 0; border: 1px solid var(--line); border-radius: 6px; }
.entry + .entry { border-top: 1px solid var(--line); }
.cell-grid { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; column-gap: 12px; padding: 5px 12px; }
.title { margin: 0; font-weight: 600; }
.sub { margin: 0; color: var(--muted); font-size: 13px; }
.status { margin: 0; font-size: 14px; }
.badge { color: var(--muted); font-size: 13px; margin-left: 8px; }
.del { width: 20px; height: 20px; padding: 0; border: 0; background: none; color: var(--muted); }
.aside-copy { color: var(--muted); font-size: 13px; margin-top: 6px; }
</style>
</head>
<body>
<main>
<article class="card settings-sharing">
<h2>Sharing</h2>
<h3>Credentials</h3>
<ul class="entries">
<li class="entry"><div class="cell-grid"><div><p class="title">Deploy key</p><p class="sub">Read-only access to the repository, added by Dana on March 3</p></div><button class="del" aria-label="Remove Deploy key">×</button></div></li>
<li class="entry"><div class="cell-grid"><div><p class="title">Registry token</p><p class="sub">Pushes release images, added by Omar on April 18</p></div><button class="del" aria-label="Remove Registry token">×</button></div></li>
</ul>
<div class="aside-copy">Credentials are encrypted at rest and never shown again after you add them.</div>
<h3>Slack channels</h3>
<ul class="entries">
<li class="entry"><div class="cell-grid"><div><p class="title">#deploys</p><p class="status">The bot is not in this channel yet, so posts will fail.</p></div><button class="del" aria-label="Remove #deploys">×</button></div></li>
<li class="entry"><div class="cell-grid"><div><p class="title">#release-notes</p><p class="status">Posting paused by a workspace admin.</p></div><button class="del" aria-label="Remove #release-notes">×</button></div></li>
</ul>
<div class="aside-copy">Channels get a message when a deploy starts and when it finishes.</div>
<h3>Who has access</h3>
<ul class="entries">
<li class="entry"><div class="cell-grid"><div><b>Dana Whitfield</b><i class="badge">Owner</i></div><button class="del" aria-label="Remove Dana Whitfield">×</button></div></li>
<li class="entry"><div class="cell-grid"><div><b>Omar Haddad</b><i class="badge">Editor</i></div><button class="del" aria-label="Remove Omar Haddad">×</button></div></li>
</ul>
<div class="aside-copy">Readers see deploys and logs; editors can change settings.</div>
</article>
</main>
</body>
</html>
+2 -1
View File
@@ -78,7 +78,7 @@ export const FREE_FIXTURES: Readonly<Record<string, readonly string[]>> = {
'test/fixtures/design-count-native-8525.json': ['test/plan-count-completion.test.ts'],
'test/fixtures/design-count-native-issue-fields.json': ['test/plan-count-completion.test.ts'],
'test/fixtures/design-detector-source-public.json': ['test/design-detector-source-fixture.test.ts'],
'test/fixtures/design-lint/**': ['test/design-lint.test.ts', 'test/design-lint-color.test.ts', 'test/design-lint-type.test.ts', 'test/design-lint-structure.test.ts', 'test/design-lint-motion.test.ts', 'test/design-lint-chrome.test.ts', 'test/design-lint-assets.test.ts', 'test/design-lint-finish.test.ts', 'test/design-lint-tailwind.test.ts', 'test/design-lint-render.test.ts', 'test/design-lint-fourth-cycle.test.ts', 'test/design-lint-fifth-cycle.test.ts'],
'test/fixtures/design-lint/**': ['test/design-lint.test.ts', 'test/design-lint-color.test.ts', 'test/design-lint-type.test.ts', 'test/design-lint-structure.test.ts', 'test/design-lint-motion.test.ts', 'test/design-lint-chrome.test.ts', 'test/design-lint-assets.test.ts', 'test/design-lint-finish.test.ts', 'test/design-lint-tailwind.test.ts', 'test/design-lint-render.test.ts', 'test/design-lint-fourth-cycle.test.ts', 'test/design-lint-fifth-cycle.test.ts', 'test/design-lint-app-profile.test.ts', 'test/design-lint-scope-guard.test.ts'],
'test/fixtures/design-floor-focus-36597762183.json': ['test/plan-design-floor-fixture.test.ts'],
'test/fixtures/design-floor-focus-cover-all.json': ['test/plan-design-floor-fixture.test.ts'],
'test/fixtures/design-floor-focus-review-focus.json': ['test/plan-design-floor-fixture.test.ts'],
@@ -259,6 +259,7 @@ export const FREE_FIXTURES: Readonly<Record<string, readonly string[]>> = {
'test/fixtures/trial-cause/replays.json': ['test/trial-cause.test.ts'],
'test/fixtures/trial-cause/thinking-captures-37198445662.json': ['test/trial-cause.test.ts'],
'test/fixtures/trial-record-v1-reader.ts': ['test/helpers/eval-store.test.ts'],
'test/fixtures/ui-spacing/**': ['test/design-lint-app-contract.test.ts', 'test/design-lint-app-profile.test.ts', 'test/design-lint-scope-guard.test.ts'],
'test/fixtures/windows-bash-resolution.json': ['test/script-invocation.test.ts'],
'test/fixtures/workflow-required-steps.json': ['test/workflow-required-steps.test.ts'],
};