staysfixed 0.11.1 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/CHANGELOG.md +108 -2
  2. package/README.md +77 -19
  3. package/docs/design-v2.md +8 -7
  4. package/docs/getting-started.md +5 -3
  5. package/docs/guards.md +18 -0
  6. package/docs/how-v2-works.md +43 -10
  7. package/docs/mcp.md +6 -4
  8. package/docs/settings.md +11 -2
  9. package/package.json +1 -1
  10. package/src/cli/approve.js +4 -1
  11. package/src/cli/flake.js +4 -1
  12. package/src/cli/mark.js +5 -1
  13. package/src/cli/status.js +53 -1
  14. package/src/cli/trace.js +27 -2
  15. package/src/core/config.js +136 -25
  16. package/src/core/stop-tree.js +109 -0
  17. package/src/drive/browser.js +20 -31
  18. package/src/drive/page.js +74 -2
  19. package/src/guard/api.js +14 -9
  20. package/src/types.js +1 -1
  21. package/src/v2/adapters/android.js +220 -11
  22. package/src/v2/adapters/child.js +15 -17
  23. package/src/v2/adapters/contract.js +122 -1
  24. package/src/v2/adapters/extension.js +1988 -0
  25. package/src/v2/adapters/http.js +152 -30
  26. package/src/v2/adapters/ios-driver.js +95 -12
  27. package/src/v2/adapters/ios.js +220 -10
  28. package/src/v2/adapters/isolate.js +169 -14
  29. package/src/v2/adapters/linux-driver.js +1028 -0
  30. package/src/v2/adapters/linux.js +1324 -0
  31. package/src/v2/adapters/macos-driver.js +913 -0
  32. package/src/v2/adapters/macos.js +1374 -0
  33. package/src/v2/adapters/process.js +72 -8
  34. package/src/v2/adapters/source.js +254 -7
  35. package/src/v2/adapters/web.js +69 -19
  36. package/src/v2/browsers.js +145 -25
  37. package/src/v2/cause.js +46 -5
  38. package/src/v2/check.js +465 -47
  39. package/src/v2/cli.js +21 -1
  40. package/src/v2/coverage.js +556 -19
  41. package/src/v2/detect.js +742 -42
  42. package/src/v2/doctor.js +125 -18
  43. package/src/v2/escalate.js +57 -11
  44. package/src/v2/init.js +574 -23
  45. package/src/v2/journeys/answers-probe.js +376 -0
  46. package/src/v2/journeys/from-exports.js +456 -0
  47. package/src/v2/journeys/from-suite.js +9 -1
  48. package/src/v2/journeys/index.js +3 -3
  49. package/src/v2/journeys/record-session.js +839 -0
  50. package/src/v2/journeys/record.js +12 -0
  51. package/src/v2/mcp/tools.js +193 -27
  52. package/src/v2/observation.js +145 -0
  53. package/src/v2/run.js +133 -9
  54. package/src/v2/selfcheck.js +297 -11
  55. package/src/v2/store.js +16 -1
  56. package/src/v2/types.js +1 -1
  57. package/src/v2/watch/events.js +6 -0
@@ -33,7 +33,7 @@ import net from 'node:net';
33
33
  import path from 'node:path';
34
34
  import {
35
35
  defineAdapter, joinPath, notCovered, observation, sizeBucket, stableValue,
36
- howLongItTook, timeBucket, trimForStorage, undoOurFootprint,
36
+ howLongItTook, timeBucket, trimForStorage, undoOurFootprint, whatItSaid,
37
37
  } from './contract.js';
38
38
  import {
39
39
  compareTrees, copyForScratch, frozenEnvironment, readWatcher, snapshotTree, watcherScript,
@@ -533,7 +533,16 @@ export const httpAdapter = defineAdapter({
533
533
  const framework = ['express', 'fastify', 'hono', 'koa', 'next', 'polka', '@hapi/hapi']
534
534
  .find((name) => name in dependencies);
535
535
 
536
- const reading = await readContract({ root: project.root });
536
+ // The folders the settings name, not the built-in guess. Route discovery read only src,
537
+ // lib, app, bin, server, pages, api, electron, main and packages, so a project that keeps
538
+ // its routes in a folder outside that list had them read as not existing — measured
539
+ // 2026-08-31 with routes in a top-level `routes/` folder, where `GET /api/orders` and
540
+ // `POST /api/orders` were both invisible and the run reported one route where there were
541
+ // three. NOTE, and this is a real limit of the fix: an adapter is only handed the settings
542
+ // under its OWN name, so this reads `http.folders` and cannot see `source.folders`, which
543
+ // is where `staysfixed init` writes them today. Naming them under `http` works now; making
544
+ // `source.folders` reach here needs one line in check.js, which this lane does not own.
545
+ const reading = await readContract({ root: project.root, folders: project.config?.folders });
537
546
  const routes = [...reading.doors.filter((d) => d.kind === 'route'), ...(await readFileRoutes(project.root)).doors];
538
547
 
539
548
  if (!config.start) {
@@ -582,7 +591,16 @@ export const httpAdapter = defineAdapter({
582
591
  async journeys(project) {
583
592
  const config = project.config ?? {};
584
593
  const samples = config.samples ?? {};
585
- const reading = await readContract({ root: project.root });
594
+ // The folders the settings name, not the built-in guess. Route discovery read only src,
595
+ // lib, app, bin, server, pages, api, electron, main and packages, so a project that keeps
596
+ // its routes in a folder outside that list had them read as not existing — measured
597
+ // 2026-08-31 with routes in a top-level `routes/` folder, where `GET /api/orders` and
598
+ // `POST /api/orders` were both invisible and the run reported one route where there were
599
+ // three. NOTE, and this is a real limit of the fix: an adapter is only handed the settings
600
+ // under its OWN name, so this reads `http.folders` and cannot see `source.folders`, which
601
+ // is where `staysfixed init` writes them today. Naming them under `http` works now; making
602
+ // `source.folders` reach here needs one line in check.js, which this lane does not own.
603
+ const reading = await readContract({ root: project.root, folders: project.config?.folders });
586
604
  const routes = [...reading.doors.filter((d) => d.kind === 'route'), ...(await readFileRoutes(project.root)).doors];
587
605
 
588
606
  /** @type {Map<string, import('./contract.js').Journey>} */
@@ -733,9 +751,21 @@ export const httpAdapter = defineAdapter({
733
751
 
734
752
  if (!up.up) {
735
753
  await stopServer(child);
754
+ // WHAT THE SERVER SAID GOES FIRST, ahead of the wait's own account of the port it knocked
755
+ // on. Measured 2026-08-31: a server whose source has a syntax error prints `SyntaxError`
756
+ // and the failing line on its standard error, and this sentence used to bury that behind
757
+ // a hundred and fifty characters about loopback addresses. `staysfixed coverage` prints
758
+ // the sentence built from this with a 160 character budget, so buried meant gone: the
759
+ // owner of a product that would not boot was never told why, on any surface, even with
760
+ // `--verbose`. The port is the tool's own business; the syntax error is the product's,
761
+ // and it is the only line here that anybody can act on.
762
+ const printed = Buffer.concat(bootErr).toString('utf8') || Buffer.concat(bootOut).toString('utf8');
763
+ const headline = whatItSaid(printed, { mostLines: 1 });
736
764
  return {
737
765
  build, root: work, ready: false,
738
- why: `${up.why} What it printed while trying: ${trimForStorage(Buffer.concat(bootErr).toString('utf8') || Buffer.concat(bootOut).toString('utf8'), 1500).text || '(nothing)'}`,
766
+ why: `${headline ? `It said: ${headline}. ` : ''}${up.why} What it printed while trying, in full: ${
767
+ trimForStorage(printed, 1500).text || '(nothing)'
768
+ }`,
739
769
  dispose: async () => { await stopServer(child); await fsp.rm(base, { recursive: true, force: true }); },
740
770
  };
741
771
  }
@@ -807,7 +837,20 @@ export const httpAdapter = defineAdapter({
807
837
  channel: 'effects',
808
838
  path: joinPath('api', journey.name, 'answered at all'),
809
839
  reason: 'irreversible',
810
- says: `${detail.method} ${detail.route} was left alone. The project marked it as spending money, sending a message or destroying data, and nothing is watching this server from the inside, so there is no way to stop it happening for real. This is a hole in what was checked, not a pass.`,
840
+ // The sentence names the setting, because this is the one place where the guess costs
841
+ // something. The list under "irreversible" is written by `staysfixed init` by matching
842
+ // WORDS IN A ROUTE'S NAME, and a name can be wrong: measured 2026-08-31, a pure
843
+ // arithmetic route called `/api/invoice/estimate` was put on that list because the word
844
+ // "invoice" is in it. On a Node server that costs nothing — the route is walked anyway,
845
+ // behind a refusal boundary that is proven to be in force — but here nothing is
846
+ // watching, and a wrong guess costs the whole route silently. Telling somebody a
847
+ // control exists without telling them where it is leaves them exactly where they were.
848
+ says:
849
+ `${detail.method} ${detail.route} was left alone. It is on the "irreversible" list under "http" in the ` +
850
+ `settings — routes that spend money, send a message or destroy data — and nothing is watching this server ` +
851
+ `from the inside, so there is no way to stop it happening for real. This is a hole in what was checked, not ` +
852
+ `a pass. That list is first written by matching words in a route's name, which is a guess: if this route ` +
853
+ `only reads or works something out, take it off the list and it starts being checked.`,
811
854
  })];
812
855
  }
813
856
 
@@ -877,6 +920,23 @@ async function snapshotForFolders(root, folders) {
877
920
  // Turning one request into observations
878
921
  // ---------------------------------------------------------------------------
879
922
 
923
+ /**
924
+ * The verbs that carry a body. Asking one of these with no body at all is asking a question
925
+ * the route was never designed to answer, so whatever comes back is about the question.
926
+ */
927
+ const CARRIES_A_BODY = new Set(['POST', 'PUT', 'PATCH']);
928
+
929
+ /**
930
+ * What a route answers when what it was handed is not what it needs: 400 Bad Request, 411
931
+ * Length Required, 415 Unsupported Media Type, 422 Unprocessable Content.
932
+ *
933
+ * Deliberately short, and deliberately NOT including 401 or 403. Those mean "you are not
934
+ * signed in", which is a different hole with a different fix, and folding them in here would
935
+ * quietly stop comparing every route of every product behind a login wall on the strength of a
936
+ * guess made in this file.
937
+ */
938
+ const NOT_WHAT_IT_NEEDS = new Set([400, 411, 415, 422]);
939
+
880
940
  /**
881
941
  * @param {object} input
882
942
  * @param {import('./contract.js').Journey} input.journey
@@ -907,39 +967,95 @@ export function describeRequest(input) {
907
967
  return out;
908
968
  }
909
969
 
910
- out.push(observation({
911
- channel: 'results',
912
- path: joinPath('api', id, 'status'),
913
- value: answer.status,
914
- says: `${asked} answered ${answer.status}${answer.status >= 400 ? ', which is a refusal' : ''}.`,
915
- }));
916
-
917
- const headers = headersThatMatter(answer.headers);
918
- for (const [name, value] of Object.entries(headers)) {
970
+ // A ROUTE THAT REFUSED THE CALL IS NOT A ROUTE THAT WAS WALKED.
971
+ //
972
+ // Measured 2026-08-31 on a small quote API. The route list comes out of the source, and the
973
+ // source says a route's address and its verb and nothing whatever about the body it expects —
974
+ // so `POST /api/quote` was asked with no body at all. It answered 400, correctly, and this
975
+ // function wrote that 400 down as the route's behaviour. From then on every run compared the
976
+ // new build's 400 against the old build's 400, agreed, and reported the route as walked in
977
+ // the coverage ledger. The route's real work — the arithmetic that decides what a customer is
978
+ // charged — had never once been run, and a rounding bug in it was invisible for ever, under a
979
+ // result that said the route was covered.
980
+ //
981
+ // A 400 to a request this tool got wrong is an observation of THIS TOOL, not of the product.
982
+ // So it is recorded as what it is: a door found and not opened, with the reason. Everything
983
+ // about this request is marked refused, which is what puts the route in the ledger's
984
+ // never-opened list instead of its walked list — see `walkFromCapture` in coverage.js, where
985
+ // a capture whose whole non-contract record is refusals counts as a walk that did nothing.
986
+ //
987
+ // NARROW ON PURPOSE, in three ways at once, because the cost of getting this wrong is that a
988
+ // real answer stops being compared. It only applies to a verb that carries a body; only when
989
+ // this tool sent no body, so a body somebody wrote into the settings themselves is always the
990
+ // product's own answer and is always compared; and only to the four codes that mean "that is
991
+ // not what I need". A route that answers 400 to a request that was properly formed is
992
+ // untouched by this and goes on being compared exactly as before.
993
+ const verb = String(detail.method ?? 'GET').toUpperCase();
994
+ const weSentNoBody = detail.body === undefined;
995
+ const ourOwnFault = CARRIES_A_BODY.has(verb) && weSentNoBody && NOT_WHAT_IT_NEEDS.has(answer.status);
996
+ if (ourOwnFault) {
997
+ // What the route said about it, in the route's own words. It is usually the exact list of
998
+ // fields it wanted, which is the fastest way for somebody to write the body it needs — so
999
+ // it is quoted rather than summarised, for the same reason a crash's own words are.
1000
+ const complained = whatItSaid(undoOurFootprint(input.text, footprint), { mostLines: 2 });
1001
+ out.push(notCovered({
1002
+ channel: 'results',
1003
+ path: joinPath('api', id, 'answered at all'),
1004
+ reason: 'needs a sample',
1005
+ says:
1006
+ `${asked} was not really walked. It answered ${answer.status}, and that is this tool's request being turned ` +
1007
+ `away rather than anything the route does: it was asked with no body at all, because a route's address and ` +
1008
+ `verb can be read out of the source and the body it expects cannot.${complained ? ` It said: ${complained}.` : ''} ` +
1009
+ `Whatever is behind that check has never run, so a bug in the route's own working — the kind that charges ` +
1010
+ `somebody the wrong amount — would not be seen here, and this route is counted as a door found and not ` +
1011
+ `opened. Put a real body under "requests" in the "http" settings — ` +
1012
+ `{ name: '${asked}', method: '${verb}', url: '${detail.route}', body: { ... } } — and it starts being checked.`,
1013
+ detail: complained,
1014
+ }));
1015
+ } else {
919
1016
  out.push(observation({
920
1017
  channel: 'results',
921
- path: joinPath('api', id, 'header', name),
922
- value: undoOurFootprint(Array.isArray(value) ? value.join(', ') : value, footprint),
923
- says: `${asked} answered with ${name}: ${Array.isArray(value) ? value.join(', ') : value}.`,
1018
+ path: joinPath('api', id, 'status'),
1019
+ value: answer.status,
1020
+ says: `${asked} answered ${answer.status}${answer.status >= 400 ? ', which is a refusal' : ''}.`,
924
1021
  }));
925
1022
  }
926
1023
 
927
- const body = readBody(answer.headers.get('content-type') ?? '', undoOurFootprint(input.text, footprint));
928
- out.push(observation({
929
- channel: 'results',
930
- path: joinPath('api', id, 'body'),
931
- value: body.value,
932
- says: body.truncated
933
- ? `What ${asked} sent back, with the middle left out the whole of it is ${sizeBucket(body.bytes)}.`
934
- : `What ${asked} sent back.`,
935
- }));
936
- if (body.shape !== undefined) {
1024
+ // The headers, the body and its shape describe the answer to a request the route rejected,
1025
+ // so on a rejected request they describe this tool and not the product. They are left out
1026
+ // rather than compared, which is what every other never-really-tried branch in this adapter
1027
+ // does — and what the route said for itself is quoted in the sentence above, where somebody
1028
+ // will actually read it. Comparing them would be worse than useless: two builds whose
1029
+ // validation message was reworded would report a difference nobody caused, at an address
1030
+ // standing in for a route neither run has ever been inside.
1031
+ if (!ourOwnFault) {
1032
+ const headers = headersThatMatter(answer.headers);
1033
+ for (const [name, value] of Object.entries(headers)) {
1034
+ out.push(observation({
1035
+ channel: 'results',
1036
+ path: joinPath('api', id, 'header', name),
1037
+ value: undoOurFootprint(Array.isArray(value) ? value.join(', ') : value, footprint),
1038
+ says: `${asked} answered with ${name}: ${Array.isArray(value) ? value.join(', ') : value}.`,
1039
+ }));
1040
+ }
1041
+
1042
+ const body = readBody(answer.headers.get('content-type') ?? '', undoOurFootprint(input.text, footprint));
937
1043
  out.push(observation({
938
1044
  channel: 'results',
939
- path: joinPath('api', id, 'shape'),
940
- value: body.shape,
941
- says: `The fields ${asked} sends back and what type each one is. This stays the same while the values change, so a renamed or dropped field shows up on its own instead of buried in a diff of the whole body.`,
1045
+ path: joinPath('api', id, 'body'),
1046
+ value: body.value,
1047
+ says: body.truncated
1048
+ ? `What ${asked} sent back, with the middle left out — the whole of it is ${sizeBucket(body.bytes)}.`
1049
+ : `What ${asked} sent back.`,
942
1050
  }));
1051
+ if (body.shape !== undefined) {
1052
+ out.push(observation({
1053
+ channel: 'results',
1054
+ path: joinPath('api', id, 'shape'),
1055
+ value: body.shape,
1056
+ says: `The fields ${asked} sends back and what type each one is. This stays the same while the values change, so a renamed or dropped field shows up on its own instead of buried in a diff of the whole body.`,
1057
+ }));
1058
+ }
943
1059
  }
944
1060
 
945
1061
  for (const change of input.changes) {
@@ -950,6 +1066,12 @@ export function describeRequest(input) {
950
1066
  says: change.what === 'deleted'
951
1067
  ? `Answering ${asked} deleted ${change.file}.`
952
1068
  : `Answering ${asked} ${change.what} ${change.file}. A route that still answers correctly but has stopped writing this file is broken, and only this line sees it.`,
1069
+ // A file written while the route was turning our request away is a real thing the product
1070
+ // did, and it is kept on the record — but it is not evidence that the route works, and if
1071
+ // it were left as a plain observation this one line would put the route back in the
1072
+ // ledger's walked column and undo the whole fix above.
1073
+ covered: ourOwnFault ? false : undefined,
1074
+ reason: ourOwnFault ? 'needs a sample' : undefined,
953
1075
  }));
954
1076
  }
955
1077
 
@@ -373,16 +373,54 @@ export async function ensureDevice(opts = {}) {
373
373
  const typeId = await pickDeviceType(opts.deviceType, { signal: opts.signal });
374
374
  if (!typeId.ok) return { ok: false, device: null, why: typeId.why };
375
375
 
376
- const made = await simctl(['create', wanted, typeId.id, runtime.id], { timeoutMs: 120_000, signal: opts.signal });
377
- if (!made.ok) return { ok: false, device: null, why: `A simulator called ${wanted} could not be made: ${firstLine(made.stderr) || made.why}` };
378
- const udid = made.stdout.trim();
376
+ // EVERY kind of iPhone is tried, newest first, not just the first one.
377
+ //
378
+ // Not every phone runs on every version of iOS, and Apple says so with a number and no
379
+ // words: measured on this Mac on 2026-08-31, asking for an iPhone 6s on iOS 27.0 came back
380
+ // as `SimError 403` with an empty message. One attempt meant one number, and the sentence
381
+ // handed to a person was "a simulator could not be made" on a Mac that could perfectly well
382
+ // make several. So the loop walks down the list, and if every phone this Mac has is refused
383
+ // by every runtime it has, the refusal that comes back names what was tried and how many.
384
+ //
385
+ // SIX, not all of them. A refusal comes back instantly, but a simulator tool that has
386
+ // wedged does not — it takes the full two minutes before this gives up on it — and this Mac
387
+ // lists forty kinds of iPhone. Forty of those in a row is eighty minutes of a check that
388
+ // looks like it has hung, which is worse than a clear failure. Six covers every real case:
389
+ // if the six newest phones a Mac has all refuse a runtime, the seventh will too.
390
+ const MOST_TRIED = 6;
391
+ /** @type {string[]} */
392
+ const refused = [];
393
+ let udid = '';
394
+ let label = '';
395
+ for (const candidate of typeId.candidates.slice(0, MOST_TRIED)) {
396
+ const made = await simctl(['create', wanted, candidate.id, runtime.id], { timeoutMs: 120_000, signal: opts.signal });
397
+ if (made.ok && made.stdout.trim() !== '') {
398
+ udid = made.stdout.trim();
399
+ label = candidate.label;
400
+ break;
401
+ }
402
+ refused.push(`${candidate.label} (${firstLine(made.stderr) || made.why})`);
403
+ }
404
+ if (udid === '') {
405
+ return {
406
+ ok: false,
407
+ device: null,
408
+ why:
409
+ `A simulator called ${wanted} could not be made. ${refused.length} kind${refused.length === 1 ? '' : 's'} of iPhone ` +
410
+ `${refused.length === 1 ? 'was' : 'were'} tried on ${runtime.name}${typeId.candidates.length > MOST_TRIED ? ` (the newest ${MOST_TRIED} of the ${typeId.candidates.length} this Mac has)` : ''} and every one was refused. ` +
411
+ `The first was: ${refused[0] ?? 'nothing at all was tried'}. ` +
412
+ 'This usually means the iOS version installed here is newer than every phone this Mac knows about, or older than all of them — opening Xcode once and letting it finish installing its simulator components fixes it.',
413
+ };
414
+ }
379
415
 
380
416
  const booted = await bootDevice(udid, { signal: opts.signal });
381
417
  if (!booted.ok) return { ok: false, device: null, why: booted.why };
382
418
  return {
383
419
  ok: true,
384
420
  device: { udid, name: wanted, runtimeName: runtime.name, weMadeIt: true, weBootedIt: true, why: booted.why },
385
- why: `Made a new ${typeId.label} on ${runtime.name} called ${wanted}, and booted it.`,
421
+ why:
422
+ `Made a new ${label} on ${runtime.name} called ${wanted}, and booted it.` +
423
+ (refused.length > 0 ? ` ${refused.length} newer kind${refused.length === 1 ? '' : 's'} of iPhone would not run on ${runtime.name}, so ${label} was used instead.` : ''),
386
424
  };
387
425
  }
388
426
 
@@ -419,9 +457,25 @@ function compareVersions(a, b) {
419
457
  }
420
458
 
421
459
  /**
460
+ * Which kinds of iPhone this Mac could make, best first.
461
+ *
462
+ * A LIST rather than one answer, and the reason was measured on this Mac on 2026-08-31.
463
+ * `simctl list devicetypes` prints the newest iPhone first — iPhone 17 Pro at the top,
464
+ * iPhone 6s at the bottom — and this function used to take the LAST phone in that list.
465
+ * So on a Mac whose only iOS runtime was 27.0, it asked for an iPhone 6s on iOS 27, which
466
+ * Apple refuses outright: `simctl create` came back with SimError 403 and no explanation,
467
+ * `prepare` reported "a simulator called staysfixed-ios could not be made", and the whole
468
+ * iPhone surface was dark on a machine with Xcode, a runtime and a built app all sitting
469
+ * there ready. Nothing said the pairing was the problem, so the message read like the Mac
470
+ * was broken.
471
+ *
472
+ * Two things changed. The newest phone is picked first, because a runtime always supports
473
+ * the hardware of its own year. And every other phone is handed back behind it in order, so
474
+ * a caller that gets refused can try the next one instead of giving up on the platform.
475
+ *
422
476
  * @param {string|undefined} wanted
423
477
  * @param {{signal?: AbortSignal}} opts
424
- * @returns {Promise<{ok: boolean, id: string, label: string, why: string}>}
478
+ * @returns {Promise<{ok: boolean, id: string, label: string, why: string, candidates: {id: string, label: string}[]}>}
425
479
  */
426
480
  async function pickDeviceType(wanted, opts) {
427
481
  const listed = await simctl(['list', '-j', 'devicetypes'], { timeoutMs: 45_000, signal: opts.signal });
@@ -432,17 +486,46 @@ async function pickDeviceType(wanted, opts) {
432
486
  identifier: String(t.identifier), name: String(t.name),
433
487
  }));
434
488
  } catch {
435
- return { ok: false, id: '', label: '', why: 'The list of device kinds could not be read, so no device can be made.' };
489
+ return { ok: false, id: '', label: '', why: 'The list of device kinds could not be read, so no device can be made.', candidates: [] };
436
490
  }
437
491
  if (wanted) {
438
492
  const found = types.find((t) => t.identifier === wanted || t.name === wanted);
439
- if (found) return { ok: true, id: found.identifier, label: found.name, why: '' };
440
- return { ok: false, id: '', label: '', why: `This machine has no simulator called "${wanted}".` };
493
+ if (found) return { ok: true, id: found.identifier, label: found.name, why: '', candidates: [{ id: found.identifier, label: found.name }] };
494
+ return { ok: false, id: '', label: '', why: `This machine has no simulator called "${wanted}".`, candidates: [] };
495
+ }
496
+ const candidates = phoneKindsToTry(types);
497
+ const pick = candidates[0];
498
+ if (!pick) return { ok: false, id: '', label: '', why: 'This machine has no iPhone simulator kind at all.', candidates: [] };
499
+ return { ok: true, id: pick.id, label: pick.label, why: '', candidates };
500
+ }
501
+
502
+ /**
503
+ * Every kind of iPhone worth trying, best first.
504
+ *
505
+ * Pulled out of `pickDeviceType` so the ORDER can be tested on any machine, including one
506
+ * with no Xcode on it. The order is the whole bug: `simctl list devicetypes` prints the
507
+ * newest iPhone first and the oldest last, and taking the last one asked for an iPhone 6s on
508
+ * iOS 27.0, which Apple refuses with `SimError 403` and no words. Measured on this Mac on
509
+ * 2026-08-31, where it left the entire iPhone surface dark on a machine that had Xcode, a
510
+ * runtime and a built app all sitting there ready.
511
+ *
512
+ * A plain iPhone comes before a Plus, a Max, a mini or an `e`, because those are the same
513
+ * year's hardware in an awkward shape and a plain one is the least surprising thing to
514
+ * compare on; within each group the list's own order is kept, which is newest first.
515
+ *
516
+ * @param {{identifier: string, name: string}[]} types
517
+ * @returns {{id: string, label: string}[]}
518
+ */
519
+ export function phoneKindsToTry(types) {
520
+ const plain = types.filter((t) => /SimDeviceType\.iPhone-\d/.test(t.identifier) && !/Plus|Max|mini|e$/.test(t.name));
521
+ const anyPhone = types.filter((t) => t.identifier.includes('iPhone'));
522
+ /** @type {{id: string, label: string}[]} */
523
+ const candidates = [];
524
+ for (const t of [...plain, ...anyPhone]) {
525
+ if (candidates.some((c) => c.id === t.identifier)) continue;
526
+ candidates.push({ id: t.identifier, label: t.name });
441
527
  }
442
- const phones = types.filter((t) => /SimDeviceType\.iPhone-\d/.test(t.identifier) && !/Plus|Max|mini|e$/.test(t.name));
443
- const pick = phones[phones.length - 1] ?? types.find((t) => t.identifier.includes('iPhone'));
444
- if (!pick) return { ok: false, id: '', label: '', why: 'This machine has no iPhone simulator kind at all.' };
445
- return { ok: true, id: pick.identifier, label: pick.name, why: '' };
528
+ return candidates;
446
529
  }
447
530
 
448
531
  /**