Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 19 additions & 9 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -208,30 +208,36 @@ jobs:
run: |
parts="$RUNNER_TEMP/parts"
mkdir -p "$parts"
doc="$parts/${PART}.attempt-${GITHUB_RUN_ATTEMPT}.json"
status=0
bun run scripts/verify.ts --only "$ONLY" ${SHARD:+--shard "$SHARD"} --json \
> "$parts/${PART}.json" || status=$?
echo "the part's document: $(wc -c < "$parts/${PART}.json") bytes, uploaded as verify-part-${PART}"
> "$doc" || status=$?
echo "the part's document: $(wc -c < "$doc") bytes, uploaded as verify-part-${PART}-attempt-${GITHUB_RUN_ATTEMPT}"
table="$(jq -r '
(.steps // [])[]
| "\(if .skipped then "-" elif .ok then "✓" else "✗" end) \(.name) \(.durationMs)ms\(if .tests then " \(.tests.ran) ran, \(.tests.skipped) skipped\(if .tests.errors then ", \(.tests.errors) errored" else "" end)" else "" end)",
(.findings[]? | " \(.code)\(if .at then " (\(.at))" else "" end)\n cause: \(.cause)\n fix: \(.fix)"),
(select(.ok | not) | .output // empty | split("\n") | map(select(test("^\\s*\\(fail\\)"))) | unique | select(length > 0) | " failed tests:\n\(map(" " + ltrimstr(" ")) | join("\n"))"),
(select(.ok | not) | .output // empty | " output (last lines):\n\(split("\n")[-40:] | map(" " + .) | join("\n"))")
' "$parts/${PART}.json" || true)"
' "$doc" || true)"
echo "$table"
echo "### gate ($PART)" >> "$GITHUB_STEP_SUMMARY"
bun run scripts/verify-summary.ts "$parts/${PART}.json" \
"{shown} of {total} finding(s) shown. Every finding and each red step's output: this job's log, and the verify-part-${PART} artifact." \
bun run scripts/verify-summary.ts "$doc" \
"{shown} of {total} finding(s) shown. Every finding and each red step's output: this job's log, and the verify-part-${PART}-attempt-${GITHUB_RUN_ATTEMPT} artifact." \
>> "$GITHUB_STEP_SUMMARY" || echo "the step summary could not be rendered; the part's verdict is the log above"
exit "$status"
# Uploaded when the part is RED too: the verdict is the merge's, and a part that failed
# without a document would read there as a step nobody ran rather than a step that failed.
#
# Named by the run ATTEMPT, artifact and file both: a "re-run failed jobs" keeps the earlier
# attempt's artifacts in the run, and under one name `verify` downloaded the RED document the
# rerun replaced (run 37918316075, attempt 2) — a fixed run stayed red until the artifact was
# deleted by hand. `scripts/verify-parts.ts` merges each part's newest attempt.
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: ${{ !cancelled() }}
with:
name: verify-part-${{ matrix.part }}
path: ${{ runner.temp }}/parts/${{ matrix.part }}.json
name: verify-part-${{ matrix.part }}-attempt-${{ github.run_attempt }}
path: ${{ runner.temp }}/parts/${{ matrix.part }}.attempt-${{ github.run_attempt }}.json
if-no-files-found: error
retention-days: 7

Expand Down Expand Up @@ -277,10 +283,14 @@ jobs:
- name: x verify merge — every step, from exactly one part, green
run: |
status=0
bun run scripts/verify.ts merge ${{ runner.temp }}/parts/*.json > "$RUNNER_TEMP/verdict.txt" 2>&1 \
# Each part's newest attempt only: an earlier attempt's document of a re-run part is
# still in the download, and merged beside its replacement it is the old verdict.
bun run scripts/verify-parts.ts ${{ runner.temp }}/parts
mapfile -t docs < <(bun run scripts/verify-parts.ts ${{ runner.temp }}/parts --json | jq -r '.data[]')
bun run scripts/verify.ts merge "${docs[@]}" > "$RUNNER_TEMP/verdict.txt" 2>&1 \
|| status=$?
cat "$RUNNER_TEMP/verdict.txt"
bun run scripts/verify.ts merge ${{ runner.temp }}/parts/*.json --json > "$RUNNER_TEMP/verdict.json" \
bun run scripts/verify.ts merge "${docs[@]}" --json > "$RUNNER_TEMP/verdict.json" \
|| true
echo "### verify — the verdict" >> "$GITHUB_STEP_SUMMARY"
bun run scripts/verify-summary.ts "$RUNNER_TEMP/verdict.json" \
Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ Tier 0 — core. Tier 1 — i18n. Tier 3 — jobs, query. Tier 4 — ui. Tier 5
- **A catalog slice crosses into an island (#710, owner decision 19).** `catalogSubset(t, keys)` (`@ultimat3/i18n`) resolves the named templates, plural variants included, into JSON; `subsetTranslator(subset)` (`@ultimat3/i18n/subset`, an entry that installs no framework catalog) is the island's full translator over it. `uiCatalog(t)` (`@ultimat3/ui`) is the design system's own slice: every `UI_KEYS` template, so `<UiProvider t={subsetTranslator(props.ui)}>` renders no `⟦ui.*⟧`. The reference app's `shared/ui-strings.ts`, `shared/ui-strings-server.ts` and `shared/wire.ts` are deleted.
- **The generator's directory form is the one layout (#710, owner decision 10).** One primitive per file, in its kind's directory — `<slice>/actions/<name>.ts`, `live/`, `queries/`, `jobs/`, `tasks/` — what `x g` writes. `x verify`'s `boundaries` step now refuses a primitive module beside its directory in an app's surfaces — `actions.ts` with `actions/`, or `live`, `queries`, `jobs`, `tasks` (`X_LAYOUT_SIBLING_MODULE`); a component module beside its folder (`ui.tsx` + `ui/`, what `x g resource` writes) is not one: `./actions` resolves to the file and never the directory, so the two homes disagree silently. Both tracked apps migrated: `examples/dummy`'s 56 primitives and the demo's 11 each in their own file (`api/tasks.ts`'s two tasks moved into their slices' `tasks/`), registered module by module in `api/index.ts`. `docs/architecture/12-generated-app.md`, `wiki/Project-Layout.md` and `wiki/Actions.md` say so. An app that keeps `actions.ts` with no `actions/` beside it is not refused.

### Fixed

Tier 5 — testing.

- **A launched Chrome runs no component-extension page (`--disable-component-extensions-with-background-pages`).** `--disable-extensions` left Google Chrome's built-in extensions running, each with a background page; the hangouts one loading `thunk.js` at start-up was paused by `x shot`'s browser-wide interception and logged as an off-list request of the page under test. Held by `packages/testing/e2e/cdp-launch-targets.e2e.test.ts`: the only target of a fresh launch is `page about:blank`.
- **The framework's CI no longer goes red on four flakes.** `scaffold-helm-retire.test.ts` awaits `helm template` instead of `Bun.spawnSync`, whose wait inside a `bun test --parallel` worker missed the child's exit (exit 0 and empty stdout at exactly its 30 s timeout, or no wake at all until the `unit` step's 480 s deadline). A "re-run failed jobs" merges each gate part's newest attempt (`scripts/verify-parts.ts`, `X_VERIFY_PART_UNNAMED`), never the red document the rerun replaced. The late-profile-writer test drives `remove` with a settle wider than its writer, instead of racing a forking 5 ms poller against the 50 ms one on a loaded runner.

## 27.3.0 - 2026-10-09

### Added
Expand Down
1 change: 1 addition & 0 deletions docs/architecture/guards.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ hand-edited; `--check` refuses drift (`X_GUARDS_DOC_DRIFT`). A guard is a runnab
| `bun run scripts/to-throw-returns.ts` | Enforce, as a gate step, that `expect(fn).toThrow(...)` is given a function that THROWS. | `X_TEST_THROW_NOT_THROWN` |
| `bun run scripts/trust-publishers.ts` | Attaches the OIDC trusted publisher to every published package, and verifies it stayed attached. | `X_TRUST_2FA_REQUIRED` `X_TRUST_ENVIRONMENT_EMPTY` `X_TRUST_NPM_TOO_OLD` `X_TRUST_PUBLISHER_FAILED` `X_TRUST_PUBLISHER_MISSING` |
| `bun run url-pathname` | Enforce that no source reads `.pathname` off a URL built from `import.meta.url` as a filesystem path. | `X_URL_PATHNAME_AS_PATH` |
| `bun run scripts/verify-parts.ts` | The gate-part documents the `verify` CI job merges: each part's newest attempt, from the directory `download-artifact` filled. | `X_CLI_BAD_FLAG` `X_VERIFY_PART_UNNAMED` |
| `bun run verify` | The gate for the framework repo itself: `x verify`, run at the repo root. | `X_CONFIG_IMPORT_OUTSIDE_LOADER` `X_MANIFEST_DRIFT` `X_VERIFY_FAILED` |
| `bun run scripts/version-stamps.ts` | Enforce, as a gate step, the two version facts this repo states in prose and checked nowhere. | `X_LOCKFILE_STALE` `X_VERSION_LOCKSTEP_BROKEN` `X_VERSION_STAMP_DUPLICATE` `X_VERSION_STAMP_PIN_STALE` `X_VERSION_STAMP_STALE` `X_VERSION_STAMP_UNSCANNED` |
| `bun run wiki-anchors` | Enforce, as a gate rule, that every `Page#anchor` and `#anchor` link on a `wiki/` page lands on a heading of its target page. | `X_WIKI_ANCHOR_DEAD` `X_WIKI_ANCHOR_UNSCANNED` |
Expand Down
7 changes: 6 additions & 1 deletion framework.manifest.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

51 changes: 35 additions & 16 deletions packages/cli/src/templates/scaffold-helm-retire.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,14 @@ const HELM = Bun.which('helm');
* first render took over Bun's default 5 s while the child itself was allowed 30 s, so the TEST
* died first (CI, 2026-10-07). Each test renders once; the scaffold's chart is written once.
*
* The child is killed with SIGKILL, never the default SIGTERM: `helm template` runs helm's install
* path, which traps SIGTERM to cancel a context and keeps running, so a wedged render outlived its
* 30 s budget — and `spawnSync` holds the event loop, so the test's own timeout never fired either.
* The `unit` step's 480 s deadline was the first thing that could stop it (CI, 2026-10-08).
* Awaited, never `Bun.spawnSync`. Inside a `bun test --parallel` worker the synchronous wait missed
* its child's exit: run 37882529833 got exit 0 and EMPTY stdout back at exactly 30000 ms — helm had
* rendered and exited, and only the timeout woke the wait — and five runs on 2026-10-09 never woke
* at all, so the worker sat until the `unit` step's 480 s deadline killed it, with no helm left
* running to kill. A synchronous wait also holds the event loop, so this file's own timeout could
* not fire either. Awaited, the child keeps its own deadline (SIGKILL, so nothing it traps can
* stretch it), and the test's timeout stays live behind it. Nothing here touches the network: the
* charts have no dependencies and `--kube-version` stands in for a cluster.
*/
const HELM_RENDER_MS = 30_000;
const TEST_MS = 2 * HELM_RENDER_MS + 5_000;
Expand Down Expand Up @@ -64,8 +68,8 @@ function scaffoldChart(): Promise<string> {
}

/** Each Deployment by role, `<release>-<chart>-<role>` cut down to the role. */
function render(chart: string, extra: readonly string[]): Map<string, Deployment> {
const result = Bun.spawnSync(
async function render(chart: string, extra: readonly string[]): Promise<Map<string, Deployment>> {
const child = Bun.spawn(
[
HELM ?? 'helm',
'template',
Expand All @@ -79,15 +83,30 @@ function render(chart: string, extra: readonly string[]): Map<string, Deployment
'image.repository=registry.example.com/app',
...extra,
],
{ stdout: 'pipe', stderr: 'pipe', timeout: HELM_RENDER_MS, killSignal: 'SIGKILL' },
{
stdin: 'ignore',
stdout: 'pipe',
stderr: 'pipe',
timeout: HELM_RENDER_MS,
killSignal: 'SIGKILL',
},
);
if (result.exitCode !== 0) {
const how = result.exitedDueToTimeout
? `killed after ${HELM_RENDER_MS} ms`
: (result.signalCode ?? `exit ${result.exitCode}`);
return expect.unreachable(`helm (${how}): ${result.stderr.toString()}`);
const stdout = new Response(child.stdout).text();
const stderr = new Response(child.stderr).text();
// The exit, not the pipes' EOF, ends the wait: a pipe a stray grandchild still holds open would
// keep the deadline from ever being reported.
const code = await child.exited;
if (code !== 0) {
const how =
child.signalCode === 'SIGKILL'
? `killed after its ${HELM_RENDER_MS} ms deadline`
: (child.signalCode ?? `exit ${code}`);
const said = await Promise.race([stderr, Bun.sleep(1_000).then(() => '(stderr still open)')]);
return expect.unreachable(`helm template ${chart} (${how}): ${said}`);
}
const parsed: unknown = Bun.YAML.parse(result.stdout.toString());
const parsed: unknown = Bun.YAML.parse(await stdout);
if (parsed === null)
return expect.unreachable(`helm template ${chart} printed nothing: ${await stderr}`);
const docs = (Array.isArray(parsed) ? parsed : [parsed]) as Deployment[];
return new Map(docs.map((doc) => [doc.metadata.name.split('-').at(-1) ?? '', doc]));
}
Expand All @@ -101,7 +120,7 @@ describe.skipIf(HELM === null)('unit · roles.worker.retireSeconds, in both char
`${which}: off by default — no retire preStop, the drain-only grace`,
async () => {
const chart = which === 'framework' ? FRAMEWORK : await scaffoldChart();
const worker = render(chart, []).get('worker');
const worker = (await render(chart, [])).get('worker');
expect(preStopOf(worker)).toBeUndefined();
expect(worker?.spec.template.spec.terminationGracePeriodSeconds).toBe(35);
},
Expand All @@ -112,7 +131,7 @@ describe.skipIf(HELM === null)('unit · roles.worker.retireSeconds, in both char
`${which}: set, the worker sends PID 1 the retire signal and waits it out`,
async () => {
const chart = which === 'framework' ? FRAMEWORK : await scaffoldChart();
const rendered = render(chart, ['--set', 'roles.worker.retireSeconds=7200']);
const rendered = await render(chart, ['--set', 'roles.worker.retireSeconds=7200']);
const worker = rendered.get('worker');
const command = preStopOf(worker);
expect(command?.[0]).toBe('bun');
Expand All @@ -130,7 +149,7 @@ describe.skipIf(HELM === null)('unit · roles.worker.retireSeconds, in both char
`${which}: drain.workerDeadlineSeconds sizes the worker's grace alone`,
async () => {
const chart = which === 'framework' ? FRAMEWORK : await scaffoldChart();
const rendered = render(chart, [
const rendered = await render(chart, [
'--set',
'drain.workerDeadlineSeconds=7500',
'--set',
Expand Down
49 changes: 49 additions & 0 deletions packages/testing/e2e/cdp-launch-targets.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// A launched browser runs the page it was handed and nothing else. Google Chrome's component
// extensions survive `--disable-extensions`, each with a page of its own, and one of them loading
// `thunk.js` at start-up was paused by `x shot`'s browser-wide interception and logged as the page's
// off-list request (CI, 2026-10-09). Skips with no Chrome, and refuses to skip under
// `E2E_BROWSER_REQUIRED=1`.

import { describe, expect, test } from 'bun:test';
import { E2E_BROWSER_OPEN_MS } from '../src/cdp-browser';
import { findChrome, launchChrome } from '../src/cdp-launch';

const chrome = await findChrome(process.env);
const required = process.env['E2E_BROWSER_REQUIRED'] === '1';

interface TargetInfo {
readonly type: string;
readonly url: string;
}

const isTargetInfo = (value: unknown): value is TargetInfo =>
typeof value === 'object' &&
value !== null &&
typeof (value as { type?: unknown }).type === 'string' &&
typeof (value as { url?: unknown }).url === 'string';

describe.skipIf(chrome === undefined && !required)('launchChrome — the browser it starts', () => {
test(
'runs no extension page or worker, and the one page it was handed',
async () => {
if (chrome === undefined)
expect.unreachable('E2E_BROWSER_REQUIRED=1 and no Chrome was found');
const browser = await launchChrome({ executable: chrome, timeoutMs: 20_000 });
try {
const answer = await browser.connection.send('Target.getTargets');
const infos = (answer.result as { targetInfos?: unknown } | undefined)?.targetInfos;
const targets = (Array.isArray(infos) ? infos : []).filter(isTargetInfo);
// Chrome's own browser UI (`browser_ui chrome://omnibox-popup…` on CI's build) loads no
// web resource; an extension's page or worker does, and it is the one interception pauses.
const extensions = targets.filter((target) => target.url.startsWith('chrome-extension://'));
expect(extensions.map((target) => `${target.type} ${target.url}`)).toEqual([]);
expect(targets.filter((target) => target.type === 'page').map((t) => t.url)).toEqual([
'about:blank',
]);
} finally {
await browser.close();
}
},
E2E_BROWSER_OPEN_MS,
);
});
49 changes: 31 additions & 18 deletions packages/testing/src/cdp-launch-profile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { tmpdir } from 'node:os';
// why: joining the script and its profile-log paths.
import { join } from 'node:path';
import { launchChrome } from './cdp-launch';
import { throwawayProfile } from './cdp-launch-profile';

/** Record `--user-data-dir`, answer the first call, then run `rest`. */
const script = (rest: string): string => `#!/bin/bash
Expand Down Expand Up @@ -74,24 +75,36 @@ describe.skipIf(WINDOWS)('launchChrome — the profile outlives no close', () =>
});
}, 20_000);

test('a writer OUTSIDE the process group that re-creates the profile does not outlast the close', async () => {
// Its own process group (`set -m`), so the group reap cannot see it — Chrome's crashpad
// handler is one — and it re-creates the profile each time it goes, three times. A close that
// removes once after the reap leaves the third.
const late = `set -m
( for n in 1 2 3; do
for _ in $(seq 400); do [ -d "$PROFILE" ] || break; sleep 0.005; done
mkdir -p "$PROFILE/Default/Cache/Cache_Data"
done ) &
set +m`;
await withFake(late, async (fake) => {
const launched = await launchChrome({ executable: fake, timeoutMs: 5_000 });
const profile = await profileOf(fake);
await launched.close();
// Long past the writer's 5 ms poll: a profile it re-created after the close has reappeared.
await Bun.sleep(250);
expect(existsSync(profile)).toBe(false);
});
// The profile's own removal, not a launch: the writer is outside the browser's process group, so
// the reap never sees it, and what outlasts it is `remove` alone. Driven through `remove` with a
// settle wider than the writer's deliberate 100 ms: through `close()` the settle is
// PROFILE_SETTLE_MS (50 ms), and a 5 ms poller forking `sleep` and `mkdir` on a loaded 4-CPU
// runner reacted slower than that — the profile came back after the close (CI, 2026-10-09).
test('a writer OUTSIDE the process group that re-creates the profile does not outlast the remove', async () => {
const profile = throwawayProfile();
// Re-creates the profile each time it goes, three times, each 100 ms after it saw it gone. A
// remove that looks once after the first removal leaves the third.
const writer = Bun.spawn(
[
'bash',
'-c',
`for n in 1 2 3; do
for _ in $(seq 2000); do [ -d "$1" ] || break; sleep 0.005; done
sleep 0.1; mkdir -p "$1/Default/Cache/Cache_Data"
done`,
'writer',
profile.path,
],
{ stdout: 'ignore', stderr: 'ignore' },
);
try {
await profile.remove(undefined, 15_000, 1_000);
expect(await writer.exited).toBe(0);
expect(existsSync(profile.path)).toBe(false);
} finally {
writer.kill('SIGKILL');
await rm(profile.path, { recursive: true, force: true });
}
}, 20_000);

test("Chrome's singleton socket directory beside the profile goes with it", async () => {
Expand Down
Loading
Loading