Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
2 changes: 1 addition & 1 deletion packages/command-surface/src/capture/screenshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ export const screenshotCommandFacet = defineCommandFacet({
text: {
Comment thread
thymikee marked this conversation as resolved.
Resolved
summary: 'Capture a screenshot',
cliDetail:
"Web defaults to the viewport; use --fullscreen, --full, or -f for the entire page. iOS simulators default to 1x logical-point output; use --pixel-density to request a different screenshot density. macOS app sessions default to the app window; use --fullscreen for full desktop, --scale to downscale, --crop-on <selector> to crop the capture to the frame the selector resolves on the same screen (currently iOS simulators, Android emulators, and CoreDevice physical iPhones), --overlay-refs to annotate current refs, --normalize-status-bar for deterministic iOS simulator or Android chrome. Physical iPhone crop verification covers portrait CoreDevice/devicectl captures only. XCTest, runner fallback, and physical iPad crops are refused; landscape iPhone crop is unverified. Android captures immediately by default; --normalize-status-bar also waits for transient UI to fade, and --no-stabilize keeps capture immediate. With the native macOS backend, the front window is the app's frontmost visible, nonempty normal-level (layer-0) window on the current Space: floating palettes and app-modal alerts at window level 8 are skipped, and a sheet is captured as its own window. On macOS, any --surface other than app (desktop, menubar, frontmost-app) always captures the main display and refuses an explicit --fullscreen. On single-display iOS simulators and Android devices, the result reports displayRotation, the rotation the display rendered the capture in, when the device returns a reading in time; otherwise the field is absent.",
"Web defaults to the viewport; use --fullscreen, --full, or -f for the entire page. iOS simulators default to 1x logical-point output; use --pixel-density to request a different screenshot density. macOS app sessions default to the app window; use --fullscreen for full desktop, --scale to downscale, --crop-on <selector> to crop the capture to the frame the selector resolves on the same screen (currently iOS simulators, Android emulators, and CoreDevice physical iPhones), --overlay-refs to annotate current refs, --normalize-status-bar for deterministic iOS simulator or Android chrome. Physical iPhone crop verification covers portrait CoreDevice/devicectl captures only. XCTest, runner fallback, and physical iPad crops are refused; landscape iPhone crop is unverified. Android captures immediately by default; --normalize-status-bar also waits for transient UI to fade, and --no-stabilize keeps capture immediate. With the native macOS backend, the front window is the app's frontmost visible, nonempty normal-level (layer-0) window on the current Space: floating palettes and app-modal alerts at window level 8 are skipped, and a sheet is captured as its own window. On macOS, any --surface other than app (desktop, menubar, frontmost-app) always captures the main display and refuses an explicit --fullscreen. On single-display iOS simulators and Android devices, the result reports displayRotation, the rotation the display rendered the capture in, when the device returns a reading in time; otherwise the field is absent. On iOS simulators a screenshot waits for that simulator's status bar to be free, and a request its caller cancelled while it waits reports the cancellation instead of capturing.",
},
metadata: screenshotCommandMetadata,
run: (client, input) => client.capture.screenshot(input),
Expand Down
2 changes: 2 additions & 0 deletions packages/command-surface/src/capture/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,8 @@
cliDetail: [
Comment thread
thymikee marked this conversation as resolved.
Resolved
Comment thread
thymikee marked this conversation as resolved.
Resolved
'A bare readable setting reads the value the target holds. A target that does not serve a setting refuses it with UNSUPPORTED_OPERATION before anything changes.',
'',
'On an iOS simulator, settings wifi and settings airplane wait for screenshots and other status bar writes. The request timeout includes this wait (90 seconds for a standalone CLI request). Once the write starts, its status bar command has a separate 10-second timeout. A request or write that times out may already have changed the bar; check the bar before relying on it. Cancelling while still queued guarantees nothing was written.',
Comment thread
thymikee marked this conversation as resolved.
Resolved
'',

Check notice on line 107 in packages/command-surface/src/capture/settings.ts

View check run for this annotation

apex-sentinel-staging / Apex Sentinel

nit: Status-bar timeout budgets are spelled out in three places

The 10-second/30-second/90-second figures introduced by this change are written once as the constants `STATUS_BAR_COMMAND_TIMEOUT_MS` / `STATUS_BAR_RESTORE_TIMEOUT_MS` and then repeated as literal prose in three separate strings: `screenshotCommandFacet.text.cliDetail`, `settingsCommandFacet.text.cliDetail` and `website/docs/docs/commands.md`. Nothing ties the prose to the constants, so the next change to either budget leaves three user-facing numbers behind — and the two CLI detail strings are the text the versioned help owns, which is what the docs page copies. Interpolating the exported constants into the two `cliDetail` strings (as this file already does for computed content via `settingsHelpLines()`) keeps at least the owned help honest. ### Also raised here Also raised at this location by javascript: The new status-bar wait/timeout prose is duplicated in four places that can drift — The same facts (write waits behind screenshots, 90 s request envelope, separate 10 s per-write status-bar command timeout, cancellation only guaranteed while queued) are now hand-written in four separate files: website/docs/docs/commands.md:761 (settings section), website/docs/docs/commands.md:1045-1046 (screenshot section, which states different budgets — 10 s per read/prepare and 30 s per restore), packages/command-surface/src/capture/settings.ts:106-107 (cliDetail), and packages/command-surface/src/capture/screenshot.ts:77 (cliDetail, which states only the wait/cancellation half). Nothing links them, so a future change to STATUS\_BAR\_COMMAND\_TIMEOUT\_MS / STATUS\_BAR\_RESTORE\_TIMEOUT\_MS or to DAEMON\_REQUEST\_TIMEOUT\_MS updates one copy and silently contradicts the others; the settings copy already omits the restore budget the screenshot copy states. …
'Settings:',
...settingsHelpLines().map((line) => ` ${line}`),
].join('\n'),
Expand Down
34 changes: 34 additions & 0 deletions packages/kernel/src/keyed-lock.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,40 @@ test('heldKeyedLockKeys lists only the locks of the map held by the current cont
});
});

test('heldKeyedLockKeys reports exactly the keys withKeyedLock would re-enter for that context', async () => {
// A caller may decide behaviour from `heldKeyedLockKeys` before calling `withKeyedLock`, as the
// iOS status bar owner does. Both must answer from the same scan of the current context, or a
// caller that reads "held" could be queued behind, or reject at the boundary of, its own lock.
// Each reported key re-enters here while the frames that report it are still open, which a queued
// task could not do.
const locks = new Map<string, Promise<unknown>>();
const reEntered: string[] = [];
await withKeyedLock(locks, 'host', async () => {
await withKeyedLock(locks, 'host:app', async () => {
for (const key of heldKeyedLockKeys(locks)) {
await withKeyedLock(locks, key, async () => {
reEntered.push(key);
});
}
});
});
assert.deepEqual(reEntered.sort(), ['host', 'host:app']);

const holderReEntered: string[] = [];
await withKeyedLockHolder(async (holder) => {
const taking = holder.hold(locks, 'device:app');
// While the hold is still being taken it already reads as held, and a nested step must not queue
// behind it, so the report and the re-entry are both checked before anything is awaited.
assert.deepEqual(heldKeyedLockKeys(locks), ['device:app']);
await withKeyedLock(locks, 'device:app', async () => {
holderReEntered.push('device:app');
});
await taking;
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved
});
assert.deepEqual(holderReEntered, ['device:app']);
assert.equal(locks.size, 0);
});

test('a scope wanting a key its enclosing holder is still taking re-enters it once taken', async () => {
const locks = new Map<string, Promise<unknown>>();
let releaseOwner: () => void = () => {};
Expand Down
175 changes: 175 additions & 0 deletions packages/platform-apple/src/core/__tests__/fake-status-bar-simctl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import type { FakeAppleToolResponse } from '../../__tests__/fake-apple-tool.ts';

/**
* `simctl status_bar` with the simulator's own override state behind it. The production reader asks
* the tool what the status bar currently shows, so a test can only prove what a capture would
* restore to when the tool answers from state rather than from one fixed listing.
*/

export type StatusBarOverrideState = {
time?: string;
dataNetwork?: string;
wifiMode?: string;
wifiBars?: string;
cellularMode?: string;
cellularBars?: string;
operatorName?: string;
batteryState?: string;
batteryLevel?: string;
};

export type FakeStatusBarSimctl = {
/** Acts as the `withFakeAppleTool` script. */
script(args: string[]): FakeAppleToolResponse;
/** The override values the simulator holds now. */
overrides(): StatusBarOverrideState;
/** The state each `list` served, in order: one entry per snapshot a capture took. */
servedSnapshots(): StatusBarOverrideState[];
};

const DATA_NETWORK_CODE: Record<string, number> = {
hide: 0,
wifi: 1,
'3g': 6,
'4g': 7,
lte: 8,
'lte-a': 9,
'lte+': 10,
'5g': 11,
'5g+': 12,
'5g-uwb': 13,
'5g-uc': 14,
};

const WIFI_MODE_CODE: Record<string, number> = {
searching: 1,
failed: 2,
active: 3,
};

const CELLULAR_MODE_CODE: Record<string, number> = {
notSupported: 0,
searching: 1,
failed: 2,
active: 3,
};

const OVERRIDE_OPTION_FIELDS = {
'--time': 'time',
'--dataNetwork': 'dataNetwork',
'--wifiMode': 'wifiMode',
'--wifiBars': 'wifiBars',
'--cellularMode': 'cellularMode',
'--cellularBars': 'cellularBars',
'--operatorName': 'operatorName',
'--batteryState': 'batteryState',
'--batteryLevel': 'batteryLevel',
} as const satisfies Record<string, keyof StatusBarOverrideState>;

function parseOverrideOptions(args: string[]): Record<string, string> {
const options: Record<string, string> = {};
for (let index = 0; index < args.length; index += 2) {
const option = args[index];
if (option === undefined) break;
options[option] = args[index + 1] ?? '';
}
return options;
}

/** One `list` line, rendered from the state fields it reads, or nothing when the state omits them. */
type StatusBarOverrideLine = {
reads: (keyof StatusBarOverrideState)[];
render: (state: StatusBarOverrideState) => string;
};

/** The listing code for one override value; an unmapped value is the fake's bug, not a silent `hide`. */
function codeOf(codes: Record<string, number>, value: string | undefined, field: string): number {
if (value === undefined) return 0;
const code = codes[value];
if (code === undefined) {
throw new Error(`fake simctl status_bar cannot list ${field}: ${value}`);
}
return code;
}

const STATUS_BAR_OVERRIDES_LISTING: StatusBarOverrideLine[] = [
{
reads: ['time'],
render: (state) => `Time: ${state.time}`,
},
{
reads: ['dataNetwork'],
render: (state) =>
`DataNetworkType: ${codeOf(DATA_NETWORK_CODE, state.dataNetwork, 'dataNetwork')}`,
},
{
reads: ['wifiMode', 'wifiBars'],
render: (state) =>
`WiFi Mode: ${codeOf(WIFI_MODE_CODE, state.wifiMode, 'wifiMode')}, WiFi Bars: ${state.wifiBars ?? 0}`,
},
{
reads: ['cellularMode', 'cellularBars'],
render: (state) =>
`Cell Mode: ${codeOf(CELLULAR_MODE_CODE, state.cellularMode, 'cellularMode')}, Cell Bars: ${state.cellularBars ?? 0}`,
},
{
reads: ['operatorName'],
render: (state) => `Operator Name: ${state.operatorName}`,
},
{
reads: ['batteryState', 'batteryLevel'],
render: (state) =>
`Battery State: ${state.batteryState ? Number(state.batteryState === 'unplugged') : 0}, Battery Level: ${state.batteryLevel ?? 100}, Not Charging: 0`,
},
];

/** Renders state the way `simctl status_bar <udid> list` does, codes and all. */
function renderStatusBarOverrides(state: StatusBarOverrideState): string {
const lines = ['Current Status Bar Overrides:', '============================='];
for (const entry of STATUS_BAR_OVERRIDES_LISTING) {
if (entry.reads.every((field) => state[field] === undefined)) continue;
lines.push(entry.render(state));
}
return lines.join('\n');
}

export function createFakeStatusBarSimctl(
udid: string,
initialOverrides: StatusBarOverrideState,
): FakeStatusBarSimctl {
let state: StatusBarOverrideState = { ...initialOverrides };
const servedSnapshots: StatusBarOverrideState[] = [];

return {
script: (args): FakeAppleToolResponse => {
const unexpected = (detail: string): FakeAppleToolResponse => ({
stderr: `unexpected status bar call: ${detail}`,
exitCode: 1,
});
if (args[0] !== 'simctl' || args[1] !== 'status_bar' || args[2] !== udid) {
return unexpected(args.join(' '));
}
const verb = args[3];
if (verb === 'list') {
servedSnapshots.push({ ...state });
return renderStatusBarOverrides(state);
}
if (verb === 'clear') {
state = {};
return '';
}
if (verb !== 'override') return unexpected(args.join(' '));

const applied: StatusBarOverrideState = {};
for (const [option, value] of Object.entries(parseOverrideOptions(args.slice(4)))) {
const field = OVERRIDE_OPTION_FIELDS[option as keyof typeof OVERRIDE_OPTION_FIELDS];
if (field === undefined) return unexpected(`unknown option ${option}`);
applied[field] = value;
}
state = { ...state, ...applied };
return '';
},
overrides: () => ({ ...state }),
servedSnapshots: () => servedSnapshots.map((snapshot) => ({ ...snapshot })),
};
}
Loading
Loading