staysfixed 0.7.1 → 0.8.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.
- package/CHANGELOG.md +364 -0
- package/README.md +193 -55
- package/docs/design-v2.md +24 -4
- package/docs/getting-started.md +18 -5
- package/docs/guards.md +2 -2
- package/docs/how-v2-works.md +12 -11
- package/docs/mcp.md +17 -8
- package/docs/settings.md +549 -0
- package/docs/watching.md +10 -4
- package/examples/staysfixed.config.electron.js +17 -6
- package/examples/staysfixed.config.web.js +22 -5
- package/package.json +2 -1
- package/src/cli/index.js +55 -46
- package/src/cli/watch-flags.js +54 -0
- package/src/core/config.js +23 -3
- package/src/guard/run.js +49 -1
- package/src/report/console.js +15 -2
- package/src/v2/adapters/android-driver.js +6 -1
- package/src/v2/adapters/android.js +97 -2
- package/src/v2/adapters/contract.js +42 -5
- package/src/v2/adapters/electron.js +72 -6
- package/src/v2/adapters/http.js +11 -2
- package/src/v2/adapters/ios-driver.js +64 -14
- package/src/v2/adapters/ios.js +247 -25
- package/src/v2/adapters/process.js +728 -66
- package/src/v2/adapters/python.js +495 -0
- package/src/v2/adapters/source.js +373 -18
- package/src/v2/adapters/web-driver.js +94 -24
- package/src/v2/adapters/web.js +142 -9
- package/src/v2/adapters/windows.js +18 -1
- package/src/v2/browsers.js +9 -1
- package/src/v2/cause.js +61 -17
- package/src/v2/check.js +530 -66
- package/src/v2/ci.js +130 -35
- package/src/v2/cli.js +42 -24
- package/src/v2/cluster.js +164 -13
- package/src/v2/coverage.js +43 -176
- package/src/v2/detect.js +308 -60
- package/src/v2/doctor.js +345 -47
- package/src/v2/init.js +162 -61
- package/src/v2/intent.js +9 -23
- package/src/v2/journeys/from-suite.js +336 -30
- package/src/v2/journeys/index.js +99 -6
- package/src/v2/mcp/tools.js +10 -11
- package/src/v2/normalise.js +169 -23
- package/src/v2/observation.js +19 -33
- package/src/v2/rank.js +216 -23
- package/src/v2/reference.js +40 -10
- package/src/v2/remote.js +113 -18
- package/src/v2/run.js +103 -14
- package/src/v2/sealed.js +0 -20
- package/src/v2/selfcheck.js +190 -13
- package/src/v2/ship.js +29 -5
- package/src/v2/store.js +67 -1
- package/src/v2/types.js +12 -2
- package/src/v2/waiver.js +64 -54
- package/src/v2/watch/events.js +60 -215
- package/src/v2/watch/focus.js +14 -4
- package/src/v2/watch/panel.js +167 -17
|
@@ -31,6 +31,7 @@ import fsp from 'node:fs/promises';
|
|
|
31
31
|
import path from 'node:path';
|
|
32
32
|
import nodeModule from 'node:module';
|
|
33
33
|
import { defineAdapter, joinPath, notCovered, observation } from './contract.js';
|
|
34
|
+
import { readPythonRoutes } from './python.js';
|
|
34
35
|
|
|
35
36
|
// ---------------------------------------------------------------------------
|
|
36
37
|
// The lexer
|
|
@@ -318,6 +319,19 @@ const HTTP_METHODS = new Set(['get', 'post', 'put', 'patch', 'delete', 'head', '
|
|
|
318
319
|
*/
|
|
319
320
|
const ROUTER_NAMES = new Set(['app', 'router', 'server', 'fastify', 'api', 'routes']);
|
|
320
321
|
|
|
322
|
+
/**
|
|
323
|
+
* The two names a hand-written request handler almost always gives its arguments. A server
|
|
324
|
+
* written straight on `node:http` registers nothing — there is no `app.get` to find — so the
|
|
325
|
+
* only thing that says "this function is where requests arrive" is the shape of its
|
|
326
|
+
* parameters. Both halves have to match before either name is believed, because a lone
|
|
327
|
+
* `req` could be anything and a function taking `(req, res)` could not.
|
|
328
|
+
*/
|
|
329
|
+
const REQUEST_PARAM_NAMES = new Set(['req', 'request', 'incoming']);
|
|
330
|
+
const RESPONSE_PARAM_NAMES = new Set(['res', 'response', 'reply']);
|
|
331
|
+
|
|
332
|
+
/** What a route reads as when the code never checks which verb it is. */
|
|
333
|
+
const METHOD_UNKNOWN = 'ANY';
|
|
334
|
+
|
|
321
335
|
/** @param {string} file */
|
|
322
336
|
export function looksLikeATest(file) {
|
|
323
337
|
const normalised = file.split(path.sep).join('/');
|
|
@@ -524,6 +538,13 @@ export function readFile(relFile, text) {
|
|
|
524
538
|
}
|
|
525
539
|
}
|
|
526
540
|
|
|
541
|
+
// A server written straight on node:http registers nothing. There is no `app.get` for the
|
|
542
|
+
// sweep above to find, so every route in it was invisible — and a repository whose entire
|
|
543
|
+
// HTTP surface is invisible was then reported as covered in full, which is the worst
|
|
544
|
+
// sentence this tool can say. In a hand-written server the routes live in the comparisons
|
|
545
|
+
// instead, and that is what this reads.
|
|
546
|
+
doors.push(...handWrittenRoutes(tokens, relFile, inTest, fromConstant));
|
|
547
|
+
|
|
527
548
|
return { doors, constants: exportedConstants, recoveries, typesStripped };
|
|
528
549
|
}
|
|
529
550
|
|
|
@@ -558,6 +579,342 @@ function isRouterFactory(tokens, at) {
|
|
|
558
579
|
return tokens[at + 1]?.v === '.' && /^(Router|createServer)$/.test(tokens[at + 2]?.v ?? '');
|
|
559
580
|
}
|
|
560
581
|
|
|
582
|
+
// ---------------------------------------------------------------------------
|
|
583
|
+
// Servers written by hand, with no framework under them
|
|
584
|
+
// ---------------------------------------------------------------------------
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* A path a hand-written server would really answer on.
|
|
588
|
+
*
|
|
589
|
+
* The comparison this came from is already strong evidence — the left-hand side had to be
|
|
590
|
+
* proven to hold the request's own address before we got here — so this only has to throw
|
|
591
|
+
* out the shapes no route ever has. It deliberately does NOT reject a path because it ends
|
|
592
|
+
* in something that looks like a file: `/favicon.ico` and `/api/data.json` are real routes,
|
|
593
|
+
* and a reader that quietly dropped them would be hiding doors to look tidy.
|
|
594
|
+
*
|
|
595
|
+
* @param {Token|undefined} token
|
|
596
|
+
* @returns {string|null}
|
|
597
|
+
*/
|
|
598
|
+
function routePathIn(token) {
|
|
599
|
+
if (!token) return null;
|
|
600
|
+
if (token.t !== 'string' && token.t !== 'template') return null;
|
|
601
|
+
if (token.built) return null;
|
|
602
|
+
const value = token.v;
|
|
603
|
+
if (!value.startsWith('/')) return null;
|
|
604
|
+
if (value.length > 120) return null;
|
|
605
|
+
if (/[\\<>{}()\s]/.test(value)) return null;
|
|
606
|
+
return value;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* The first parameter of the function written at `at`, when one is written there.
|
|
611
|
+
*
|
|
612
|
+
* Only a function literal counts. `createServer(handler)` names a function defined
|
|
613
|
+
* somewhere else, and guessing what that one calls its arguments would be inventing a fact —
|
|
614
|
+
* the `(req, res)` rule below is what covers that case, and it needs both names to agree
|
|
615
|
+
* before it believes either.
|
|
616
|
+
*
|
|
617
|
+
* @param {Token[]} tokens
|
|
618
|
+
* @param {number} at
|
|
619
|
+
* @returns {string|null}
|
|
620
|
+
*/
|
|
621
|
+
function firstParameterAt(tokens, at) {
|
|
622
|
+
let i = at;
|
|
623
|
+
while (tokens[i]?.t === 'name' && (tokens[i].v === 'async' || tokens[i].v === 'function')) i++;
|
|
624
|
+
if (tokens[i]?.v === '*') i++;
|
|
625
|
+
// A named function expression: skip its name, but only when a parameter list follows.
|
|
626
|
+
if (tokens[i]?.t === 'name' && tokens[i + 1]?.v === '(' && tokens[at]?.v !== tokens[i]?.v) i++;
|
|
627
|
+
const first = tokens[i];
|
|
628
|
+
if (!first) return null;
|
|
629
|
+
if (first.t === 'name' && tokens[i + 1]?.v === '=>') return first.v;
|
|
630
|
+
if (first.v !== '(') return null;
|
|
631
|
+
const name = tokens[i + 1];
|
|
632
|
+
if (name?.t !== 'name') return null;
|
|
633
|
+
const close = matchBracket(tokens, i);
|
|
634
|
+
const arrow = tokens[close + 1]?.v === '=>';
|
|
635
|
+
const wasFunction = tokens[at]?.v === 'function' || tokens[at]?.v === 'async';
|
|
636
|
+
if (!arrow && !wasFunction) return null;
|
|
637
|
+
return name.v;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Every name in this file that holds an arriving request.
|
|
642
|
+
*
|
|
643
|
+
* Two ways in, and both need proof. A function handed straight to `createServer` is a
|
|
644
|
+
* request handler by definition, whatever it calls its arguments. Everything else has to be
|
|
645
|
+
* a function taking `(req, res)` — BOTH halves conventional — because a lone `req` could be
|
|
646
|
+
* anything at all, and a pair could not be.
|
|
647
|
+
*
|
|
648
|
+
* @param {Token[]} tokens
|
|
649
|
+
* @returns {Set<string>}
|
|
650
|
+
*/
|
|
651
|
+
function requestNamesIn(tokens) {
|
|
652
|
+
/** @type {Set<string>} */
|
|
653
|
+
const names = new Set();
|
|
654
|
+
|
|
655
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
656
|
+
const t = tokens[i];
|
|
657
|
+
if (t.t !== 'name') continue;
|
|
658
|
+
let callbackAt = -1;
|
|
659
|
+
if (t.v === 'createServer' && tokens[i + 1]?.v === '(') callbackAt = i + 2;
|
|
660
|
+
else if (
|
|
661
|
+
t.v === 'on' && tokens[i - 1]?.v === '.' && tokens[i + 1]?.v === '('
|
|
662
|
+
&& tokens[i + 2]?.t === 'string' && tokens[i + 2].v === 'request' && tokens[i + 3]?.v === ','
|
|
663
|
+
) callbackAt = i + 4;
|
|
664
|
+
if (callbackAt === -1) continue;
|
|
665
|
+
const found = firstParameterAt(tokens, callbackAt);
|
|
666
|
+
if (found) names.add(found);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
for (let i = 0; i + 4 < tokens.length; i++) {
|
|
670
|
+
if (tokens[i].v !== '(') continue;
|
|
671
|
+
const [a, comma, b, close] = [tokens[i + 1], tokens[i + 2], tokens[i + 3], tokens[i + 4]];
|
|
672
|
+
if (a?.t !== 'name' || comma?.v !== ',' || b?.t !== 'name' || close?.v !== ')') continue;
|
|
673
|
+
if (REQUEST_PARAM_NAMES.has(a.v) && RESPONSE_PARAM_NAMES.has(b.v)) names.add(a.v);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
return names;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* The bracket closing `new URL(<the request address>, …)` when that is what sits at `at`.
|
|
681
|
+
* @param {Token[]} tokens
|
|
682
|
+
* @param {number} at
|
|
683
|
+
* @param {(i: number) => number} pathEnd
|
|
684
|
+
* @returns {number}
|
|
685
|
+
*/
|
|
686
|
+
function urlBuiltFromRequest(tokens, at, pathEnd) {
|
|
687
|
+
if (tokens[at]?.v !== 'new' || tokens[at + 1]?.v !== 'URL' || tokens[at + 2]?.v !== '(') return -1;
|
|
688
|
+
if (pathEnd(at + 3) < 0) return -1;
|
|
689
|
+
return matchBracket(tokens, at + 2);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* Every name in this file that holds the address being asked for, and every name that holds
|
|
694
|
+
* a parsed URL it was built from.
|
|
695
|
+
*
|
|
696
|
+
* Run to a standstill rather than once, because `const p = req.url` and
|
|
697
|
+
* `const q = p.split('?')[0]` is two hops and reading only the first hop loses the routes
|
|
698
|
+
* that hang off the second.
|
|
699
|
+
*
|
|
700
|
+
* @param {Token[]} tokens
|
|
701
|
+
* @param {Set<string>} requestNames
|
|
702
|
+
* @returns {{pathNames: Set<string>, urlNames: Set<string>}}
|
|
703
|
+
*/
|
|
704
|
+
function pathNamesIn(tokens, requestNames) {
|
|
705
|
+
/** @type {Set<string>} */
|
|
706
|
+
const pathNames = new Set();
|
|
707
|
+
/** @type {Set<string>} */
|
|
708
|
+
const urlNames = new Set();
|
|
709
|
+
/** @param {number} i */
|
|
710
|
+
const pathEnd = (i) => pathExpressionEnd(tokens, i, requestNames, pathNames, urlNames);
|
|
711
|
+
|
|
712
|
+
for (let round = 0; round < 4; round++) {
|
|
713
|
+
const before = pathNames.size + urlNames.size;
|
|
714
|
+
for (let i = 0; i + 3 < tokens.length; i++) {
|
|
715
|
+
const t = tokens[i];
|
|
716
|
+
if (t.t !== 'name' || (t.v !== 'const' && t.v !== 'let' && t.v !== 'var')) continue;
|
|
717
|
+
|
|
718
|
+
// const { pathname } = new URL(req.url, …)
|
|
719
|
+
if (tokens[i + 1]?.v === '{') {
|
|
720
|
+
const close = matchBracket(tokens, i + 1);
|
|
721
|
+
if (tokens[close + 1]?.v !== '=') continue;
|
|
722
|
+
if (urlBuiltFromRequest(tokens, close + 2, pathEnd) < 0) continue;
|
|
723
|
+
for (let j = i + 2; j < close; j++) {
|
|
724
|
+
if (tokens[j]?.t !== 'name' || tokens[j].v !== 'pathname') continue;
|
|
725
|
+
const renamed = tokens[j + 1]?.v === ':' && tokens[j + 2]?.t === 'name' ? tokens[j + 2].v : null;
|
|
726
|
+
pathNames.add(renamed ?? 'pathname');
|
|
727
|
+
}
|
|
728
|
+
continue;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
const target = tokens[i + 1];
|
|
732
|
+
if (target.t !== 'name' || tokens[i + 2]?.v !== '=') continue;
|
|
733
|
+
if (pathEnd(i + 3) > 0) { pathNames.add(target.v); continue; }
|
|
734
|
+
if (urlBuiltFromRequest(tokens, i + 3, pathEnd) > 0) urlNames.add(target.v);
|
|
735
|
+
}
|
|
736
|
+
if (pathNames.size + urlNames.size === before) break;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
return { pathNames, urlNames };
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/**
|
|
743
|
+
* Where the expression starting at `at` ends, when that expression is the address being
|
|
744
|
+
* asked for. Anything else answers -1.
|
|
745
|
+
*
|
|
746
|
+
* `.split('?')[0]` is allowed on the end because cutting the query string off is the one
|
|
747
|
+
* thing every hand-written server does to the address before comparing it. `.replace(…)` and
|
|
748
|
+
* friends are not, and that is deliberate: they change the value into something we can no
|
|
749
|
+
* longer claim is the route, and inventing a route is worse than missing one.
|
|
750
|
+
*
|
|
751
|
+
* @param {Token[]} tokens
|
|
752
|
+
* @param {number} at
|
|
753
|
+
* @param {Set<string>} requestNames
|
|
754
|
+
* @param {Set<string>} pathNames
|
|
755
|
+
* @param {Set<string>} urlNames
|
|
756
|
+
* @returns {number}
|
|
757
|
+
*/
|
|
758
|
+
function pathExpressionEnd(tokens, at, requestNames, pathNames, urlNames) {
|
|
759
|
+
const t = tokens[at];
|
|
760
|
+
if (!t || t.t !== 'name') return -1;
|
|
761
|
+
/** @param {number} i */
|
|
762
|
+
const again = (i) => pathExpressionEnd(tokens, i, requestNames, pathNames, urlNames);
|
|
763
|
+
|
|
764
|
+
let end = -1;
|
|
765
|
+
const dotted = tokens[at + 1]?.v === '.' ? tokens[at + 2]?.v : null;
|
|
766
|
+
if (pathNames.has(t.v) && dotted !== 'pathname') end = at + 1;
|
|
767
|
+
else if (requestNames.has(t.v) && dotted === 'url') end = at + 3;
|
|
768
|
+
else if (urlNames.has(t.v) && dotted === 'pathname') end = at + 3;
|
|
769
|
+
else if (t.v === 'new') {
|
|
770
|
+
const close = urlBuiltFromRequest(tokens, at, again);
|
|
771
|
+
if (close > 0 && tokens[close + 1]?.v === '.' && tokens[close + 2]?.v === 'pathname') end = close + 3;
|
|
772
|
+
} else if (dotted === 'parse' && tokens[at + 3]?.v === '(') {
|
|
773
|
+
// The shape node's own `url` module has had since forever: url.parse(req.url).pathname.
|
|
774
|
+
const close = matchBracket(tokens, at + 3);
|
|
775
|
+
if (again(at + 4) > 0 && tokens[close + 1]?.v === '.' && tokens[close + 2]?.v === 'pathname') end = close + 3;
|
|
776
|
+
}
|
|
777
|
+
if (end < 0) return -1;
|
|
778
|
+
|
|
779
|
+
for (;;) {
|
|
780
|
+
if (tokens[end]?.v !== '.' || tokens[end + 1]?.v !== 'split' || tokens[end + 2]?.v !== '(') break;
|
|
781
|
+
const close = matchBracket(tokens, end + 2);
|
|
782
|
+
const separator = tokens[end + 3];
|
|
783
|
+
const cutsTheQuery = separator?.t === 'string' && (separator.v === '?' || separator.v === '#');
|
|
784
|
+
const takesTheFirst = tokens[close + 1]?.v === '[' && tokens[close + 2]?.v === '0' && tokens[close + 3]?.v === ']';
|
|
785
|
+
if (!cutsTheQuery || !takesTheFirst) break;
|
|
786
|
+
end = close + 4;
|
|
787
|
+
}
|
|
788
|
+
return end;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/**
|
|
792
|
+
* The verb this route is guarded by, when the same condition says.
|
|
793
|
+
*
|
|
794
|
+
* A hand-written server that never looks at `req.method` answers a route on every verb, and
|
|
795
|
+
* writing GET on it would be putting a fact in the report that is not in the code. Only what
|
|
796
|
+
* the condition itself says is believed; anything further out reads as unknown, which the
|
|
797
|
+
* rest of the tool already has a word for.
|
|
798
|
+
*
|
|
799
|
+
* @param {Token[]} tokens
|
|
800
|
+
* @param {number} at
|
|
801
|
+
* @param {Set<string>} requestNames
|
|
802
|
+
* @returns {string}
|
|
803
|
+
*/
|
|
804
|
+
function methodGuarding(tokens, at, requestNames) {
|
|
805
|
+
let depth = 0;
|
|
806
|
+
let open = -1;
|
|
807
|
+
for (let j = at - 1, steps = 0; j >= 0 && steps < 400; j--, steps++) {
|
|
808
|
+
const t = tokens[j];
|
|
809
|
+
if (t.t !== 'punct') continue;
|
|
810
|
+
if (t.v === ')' || t.v === ']' || t.v === '}') { depth++; continue; }
|
|
811
|
+
if (t.v === '[' || t.v === '{') { if (depth === 0) return METHOD_UNKNOWN; depth--; continue; }
|
|
812
|
+
if (t.v === '(') { if (depth === 0) { open = j; break; } depth--; }
|
|
813
|
+
}
|
|
814
|
+
if (open < 0) return METHOD_UNKNOWN;
|
|
815
|
+
const keyword = tokens[open - 1];
|
|
816
|
+
if (keyword?.t !== 'name' || (keyword.v !== 'if' && keyword.v !== 'while')) return METHOD_UNKNOWN;
|
|
817
|
+
|
|
818
|
+
const close = matchBracket(tokens, open);
|
|
819
|
+
for (let j = open + 1; j + 3 < close; j++) {
|
|
820
|
+
if (tokens[j].t !== 'name' || !requestNames.has(tokens[j].v)) continue;
|
|
821
|
+
if (tokens[j + 1]?.v !== '.' || tokens[j + 2]?.v !== 'method') continue;
|
|
822
|
+
const operator = tokens[j + 3]?.v;
|
|
823
|
+
if (operator !== '===' && operator !== '==') continue;
|
|
824
|
+
const verb = tokens[j + 4];
|
|
825
|
+
if (verb?.t !== 'string' || !/^[a-z]+$/i.test(verb.v)) continue;
|
|
826
|
+
return verb.v.toUpperCase();
|
|
827
|
+
}
|
|
828
|
+
return METHOD_UNKNOWN;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/**
|
|
832
|
+
* Read the routes out of a server nobody used a framework to write.
|
|
833
|
+
*
|
|
834
|
+
* The whole thing is gated on there being a proven request handler in the file, so a project
|
|
835
|
+
* with no server in it walks straight back out. That gate is also what keeps the trap out:
|
|
836
|
+
* a string like '/etc/app/config.json' compared against an ordinary variable never reaches
|
|
837
|
+
* here, because the left-hand side was never shown to hold the request's own address.
|
|
838
|
+
*
|
|
839
|
+
* @param {Token[]} tokens
|
|
840
|
+
* @param {string} relFile
|
|
841
|
+
* @param {boolean} inTest
|
|
842
|
+
* @param {(raw: string) => {name: string|Pending, via: string}} fromConstant
|
|
843
|
+
* @returns {RawDoor[]}
|
|
844
|
+
*/
|
|
845
|
+
function handWrittenRoutes(tokens, relFile, inTest, fromConstant) {
|
|
846
|
+
const requestNames = requestNamesIn(tokens);
|
|
847
|
+
if (requestNames.size === 0) return [];
|
|
848
|
+
const { pathNames, urlNames } = pathNamesIn(tokens, requestNames);
|
|
849
|
+
|
|
850
|
+
/** @type {RawDoor[]} */
|
|
851
|
+
const doors = [];
|
|
852
|
+
const via = 'a hand-written request handler';
|
|
853
|
+
/** @param {number} i */
|
|
854
|
+
const pathEnd = (i) => pathExpressionEnd(tokens, i, requestNames, pathNames, urlNames);
|
|
855
|
+
|
|
856
|
+
/** @param {Token} token @param {string} method @param {boolean} prefix */
|
|
857
|
+
const take = (token, method, prefix) => {
|
|
858
|
+
const value = routePathIn(token);
|
|
859
|
+
if (value === null) return;
|
|
860
|
+
// A prefix match is not one route, it is a family of them. Written as a changing part it
|
|
861
|
+
// joins the flow that already exists for `/users/:id` — the tool asks for a real value
|
|
862
|
+
// and, until it gets one, says out loud that nothing under here was ever looked at.
|
|
863
|
+
const name = prefix ? `${value.replace(/\/+$/, '')}/:rest` : value;
|
|
864
|
+
doors.push(door('route', name, method, relFile, token.line, inTest, true, via));
|
|
865
|
+
};
|
|
866
|
+
|
|
867
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
868
|
+
// switch (pathname) { case '/a': … }
|
|
869
|
+
if (tokens[i].t === 'name' && tokens[i].v === 'switch' && tokens[i + 1]?.v === '(') {
|
|
870
|
+
const close = matchBracket(tokens, i + 1);
|
|
871
|
+
if (pathEnd(i + 2) !== close || tokens[close + 1]?.v !== '{') continue;
|
|
872
|
+
const endOfBody = matchBracket(tokens, close + 1);
|
|
873
|
+
for (let j = close + 2; j < endOfBody; j++) {
|
|
874
|
+
if (tokens[j].t !== 'name' || tokens[j].v !== 'case') continue;
|
|
875
|
+
const label = tokens[j + 1];
|
|
876
|
+
if (label?.t === 'name') {
|
|
877
|
+
const known = fromConstant(label.v);
|
|
878
|
+
if (typeof known.name === 'string' && known.name.startsWith('/')) {
|
|
879
|
+
doors.push(door('route', known.name, METHOD_UNKNOWN, relFile, label.line, inTest, true, known.via));
|
|
880
|
+
}
|
|
881
|
+
continue;
|
|
882
|
+
}
|
|
883
|
+
take(label, METHOD_UNKNOWN, false);
|
|
884
|
+
}
|
|
885
|
+
i = endOfBody;
|
|
886
|
+
continue;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
// '/a' === req.url, which reads the same and is written often enough to matter.
|
|
890
|
+
const mirrored = routePathIn(tokens[i]);
|
|
891
|
+
if (mirrored !== null && (tokens[i + 1]?.v === '===' || tokens[i + 1]?.v === '==') && pathEnd(i + 2) > 0) {
|
|
892
|
+
take(tokens[i], methodGuarding(tokens, i, requestNames), false);
|
|
893
|
+
continue;
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
const end = pathEnd(i);
|
|
897
|
+
if (end < 0) continue;
|
|
898
|
+
const operator = tokens[end]?.v;
|
|
899
|
+
if (operator === '===' || operator === '==') {
|
|
900
|
+
const label = tokens[end + 1];
|
|
901
|
+
if (label?.t === 'name') {
|
|
902
|
+
const known = fromConstant(label.v);
|
|
903
|
+
if (typeof known.name === 'string' && known.name.startsWith('/')) {
|
|
904
|
+
doors.push(door('route', known.name, methodGuarding(tokens, i, requestNames), relFile, label.line, inTest, true, known.via));
|
|
905
|
+
}
|
|
906
|
+
} else {
|
|
907
|
+
take(label, methodGuarding(tokens, i, requestNames), false);
|
|
908
|
+
}
|
|
909
|
+
} else if (operator === '.' && tokens[end + 1]?.v === 'startsWith' && tokens[end + 2]?.v === '(') {
|
|
910
|
+
take(tokens[end + 3], methodGuarding(tokens, i, requestNames), true);
|
|
911
|
+
}
|
|
912
|
+
i = end - 1;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
return doors;
|
|
916
|
+
}
|
|
917
|
+
|
|
561
918
|
/**
|
|
562
919
|
* Index of the bracket closing the one at `open`. Returns the end of the token list when
|
|
563
920
|
* the file is unbalanced, which happens in a file the lexer had to recover inside.
|
|
@@ -929,10 +1286,17 @@ async function collectFiles(root, folders, maxFileBytes) {
|
|
|
929
1286
|
// ---------------------------------------------------------------------------
|
|
930
1287
|
|
|
931
1288
|
/**
|
|
1289
|
+
* Every route that is not written as a call in JavaScript.
|
|
1290
|
+
*
|
|
932
1291
|
* Next.js puts its routes in folder names, so no amount of reading calls will find them.
|
|
933
1292
|
* Both layouts are handled: an app folder, where a `route` file's exported method names are
|
|
934
1293
|
* the verbs, and a pages/api folder, where the file itself is the route.
|
|
935
1294
|
*
|
|
1295
|
+
* Python's routes are read here too, and this is the reason they are read HERE rather than
|
|
1296
|
+
* somewhere of their own: five places in this tool ask for routes, and four of them would
|
|
1297
|
+
* have had to be found and changed. A Flask app that had routes in one of them and none in
|
|
1298
|
+
* the others is a worse bug than no Python support at all.
|
|
1299
|
+
*
|
|
936
1300
|
* A folder that cannot be opened takes every route behind it, so it is named rather than
|
|
937
1301
|
* skipped. This is the same bug as the one fixed in the file walk on 2026-08-30 — a hole that
|
|
938
1302
|
* looks exactly like a project with no routes in it — and it was still here in this function.
|
|
@@ -1014,6 +1378,10 @@ export async function readFileRoutes(root) {
|
|
|
1014
1378
|
});
|
|
1015
1379
|
}
|
|
1016
1380
|
|
|
1381
|
+
const python = await readPythonRoutes(root);
|
|
1382
|
+
doors.push(...python.doors);
|
|
1383
|
+
problems.push(...python.problems);
|
|
1384
|
+
|
|
1017
1385
|
return { doors, problems };
|
|
1018
1386
|
}
|
|
1019
1387
|
|
|
@@ -1183,16 +1551,15 @@ export function surfaceOf(project) {
|
|
|
1183
1551
|
return 'library';
|
|
1184
1552
|
}
|
|
1185
1553
|
|
|
1186
|
-
/** @type {ContractReading|null} */
|
|
1187
|
-
let lastReading = null;
|
|
1188
|
-
|
|
1189
1554
|
/**
|
|
1190
1555
|
* The static-contract adapter.
|
|
1191
1556
|
*
|
|
1192
1557
|
* It applies to every project, always, because every project has source. It is the one
|
|
1193
1558
|
* adapter that costs nothing to run and can never break anything, so the engine runs it
|
|
1194
|
-
* first
|
|
1195
|
-
*
|
|
1559
|
+
* first. It does NOT hand its reading to the other adapters: the HTTP adapter calls
|
|
1560
|
+
* readContract() itself, which costs a second read and buys not caring what order the
|
|
1561
|
+
* adapters ran in. A shared cache was tried and taken out again, because an adapter that
|
|
1562
|
+
* silently needs another one to have gone first is the bug this tool exists to catch.
|
|
1196
1563
|
*/
|
|
1197
1564
|
export const sourceAdapter = defineAdapter({
|
|
1198
1565
|
name: 'source',
|
|
@@ -1271,22 +1638,10 @@ export const sourceAdapter = defineAdapter({
|
|
|
1271
1638
|
for (const found of reading.doors) {
|
|
1272
1639
|
reading.report.counts[found.kind] = (reading.report.counts[found.kind] ?? 0) + 1;
|
|
1273
1640
|
}
|
|
1274
|
-
lastReading = reading;
|
|
1275
1641
|
return contractObservations(reading, journey.name);
|
|
1276
1642
|
},
|
|
1277
1643
|
|
|
1278
1644
|
async teardown() {
|
|
1279
|
-
|
|
1645
|
+
// Nothing to tear down. Nothing was started and nothing was written.
|
|
1280
1646
|
},
|
|
1281
1647
|
});
|
|
1282
|
-
|
|
1283
|
-
/**
|
|
1284
|
-
* The last thing the source adapter read.
|
|
1285
|
-
*
|
|
1286
|
-
* The HTTP adapter uses this to find its routes without crawling. Anything that cannot
|
|
1287
|
-
* guarantee it runs after the source adapter should call {@link readContract} itself rather
|
|
1288
|
-
* than depend on run order.
|
|
1289
|
-
*/
|
|
1290
|
-
export function lastContractReading() {
|
|
1291
|
-
return lastReading;
|
|
1292
|
-
}
|
|
@@ -67,43 +67,73 @@ import { globToRegExp } from '../../freeze/network.js';
|
|
|
67
67
|
*/
|
|
68
68
|
|
|
69
69
|
/**
|
|
70
|
-
* Find
|
|
70
|
+
* Find the browser library, in every place it could honestly be.
|
|
71
71
|
*
|
|
72
|
-
*
|
|
73
|
-
* different answers: is it beside us, and is it in the project we were pointed at. Both are
|
|
74
|
-
* tried, because a project that already drives its own tests with Playwright should not be
|
|
75
|
-
* asked to install a second copy.
|
|
72
|
+
* ## Two packages, not one
|
|
76
73
|
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
74
|
+
* `playwright` and `playwright-core` are the same driver. The difference is that the first
|
|
75
|
+
* downloads its own copy of Chromium when it installs — about 150MB — and the second
|
|
76
|
+
* downloads nothing and expects to be told where a browser already is. This tool depends on
|
|
77
|
+
* `playwright-core` and finds the browser itself, because it already knows how: `browsers.js`
|
|
78
|
+
* locates Chrome for Testing, Chrome, Edge or Chromium on the machine, and deliberately
|
|
79
|
+
* prefers one that is not the browser the person actually uses.
|
|
80
|
+
*
|
|
81
|
+
* Both names are tried, in both places, because a project that already drives its own tests
|
|
82
|
+
* with the full `playwright` should never be asked to install a second copy of the same thing.
|
|
83
|
+
*
|
|
84
|
+
* ## Why it is loaded like this
|
|
85
|
+
*
|
|
86
|
+
* With `import()` rather than named at the top of the file: a tool that cannot start at all
|
|
87
|
+
* because a browser library is missing is a tool that cannot tell you what is missing.
|
|
88
|
+
*
|
|
89
|
+
* ## What went wrong here before, so it does not happen twice
|
|
90
|
+
*
|
|
91
|
+
* `playwright` was removed from this package's dependencies on the grounds that nothing in
|
|
92
|
+
* `src/` imported it. Nothing does — this line does, and a search for a static import cannot
|
|
93
|
+
* see it. The result shipped: 0.7.2 told every agent that asked that web apps and sites could
|
|
94
|
+
* be checked "here and now", and then answered every website check with "Playwright is not
|
|
95
|
+
* installed, so no web page can be opened". A tool that is wrong about its own headline
|
|
96
|
+
* ability is worse than one that lacks it. `test/v2/web-driver.test.js` now holds the
|
|
97
|
+
* dependency in place by name.
|
|
80
98
|
*
|
|
81
99
|
* @param {object} [opts]
|
|
82
100
|
* @param {string} [opts.projectRoot] The project being checked. Looked in second.
|
|
83
101
|
* @returns {Promise<PlaywrightState>}
|
|
84
102
|
*/
|
|
85
103
|
export async function loadPlaywright(opts = {}) {
|
|
86
|
-
const install = 'npm install
|
|
104
|
+
const install = 'npm install playwright-core';
|
|
87
105
|
/** @type {any} */
|
|
88
106
|
let mod = null;
|
|
89
107
|
/** @type {string|undefined} */
|
|
90
108
|
let version;
|
|
109
|
+
/** @type {string|null} */
|
|
110
|
+
let loadedName = null;
|
|
91
111
|
|
|
92
112
|
/** @param {any} loaded */
|
|
93
113
|
const unwrap = (loaded) => (loaded && loaded.chromium ? loaded : (loaded?.default ?? null));
|
|
94
114
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
115
|
+
// `playwright` first, because a project that has the full package has a browser downloaded
|
|
116
|
+
// with it, and using that is one less thing to go looking for.
|
|
117
|
+
for (const name of ['playwright', 'playwright-core']) {
|
|
118
|
+
if (mod) break;
|
|
119
|
+
try {
|
|
120
|
+
mod = unwrap(await import(name));
|
|
121
|
+
if (mod) loadedName = name;
|
|
122
|
+
} catch {
|
|
123
|
+
// Not beside us under this name. Try the next, then the project we were pointed at.
|
|
124
|
+
}
|
|
99
125
|
}
|
|
100
126
|
|
|
101
127
|
if (!mod && opts.projectRoot) {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
128
|
+
for (const name of ['playwright', 'playwright-core']) {
|
|
129
|
+
if (mod) break;
|
|
130
|
+
try {
|
|
131
|
+
const require = createRequire(path.join(opts.projectRoot, 'package.json'));
|
|
132
|
+
mod = unwrap(await import(require.resolve(name)));
|
|
133
|
+
if (mod) loadedName = name;
|
|
134
|
+
} catch {
|
|
135
|
+
// Not there either. That is an answer, and it is reported as one.
|
|
136
|
+
}
|
|
107
137
|
}
|
|
108
138
|
}
|
|
109
139
|
|
|
@@ -111,14 +141,14 @@ export async function loadPlaywright(opts = {}) {
|
|
|
111
141
|
return {
|
|
112
142
|
ok: false,
|
|
113
143
|
state: 'no package',
|
|
114
|
-
why: '
|
|
144
|
+
why: 'The browser driver is not installed, so no web page can be opened. Everything read out of the source still works; nothing that needs a browser does.',
|
|
115
145
|
howToGet: install,
|
|
116
146
|
};
|
|
117
147
|
}
|
|
118
148
|
|
|
119
149
|
try {
|
|
120
150
|
const require = createRequire(import.meta.url);
|
|
121
|
-
version = String(require('playwright/package.json
|
|
151
|
+
version = String(require(`${loadedName ?? 'playwright-core'}/package.json`).version);
|
|
122
152
|
} catch {
|
|
123
153
|
// A version we cannot read is not a reason to refuse to run.
|
|
124
154
|
}
|
|
@@ -131,14 +161,40 @@ export async function loadPlaywright(opts = {}) {
|
|
|
131
161
|
executable = undefined;
|
|
132
162
|
}
|
|
133
163
|
|
|
134
|
-
|
|
164
|
+
let there = Boolean(executable) && (await exists(/** @type {string} */ (executable)));
|
|
165
|
+
|
|
166
|
+
// The driver's own browser is not the only browser.
|
|
167
|
+
//
|
|
168
|
+
// `playwright-core` downloads nothing, so it always names a Chromium that is not there.
|
|
169
|
+
// That is not a failure — this tool already knows how to find a browser, and has a
|
|
170
|
+
// considered opinion about which one: `browsers.js` prefers Chrome for Testing over the
|
|
171
|
+
// browser the person actually uses, precisely so a check can never take over their
|
|
172
|
+
// windows, their profile or their sign-ins.
|
|
173
|
+
//
|
|
174
|
+
// So: ask it. Only when there is no browser on the machine at all is this a real "no".
|
|
175
|
+
/** @type {string|undefined} */
|
|
176
|
+
let borrowedFrom;
|
|
177
|
+
if (!there) {
|
|
178
|
+
try {
|
|
179
|
+
const { surveyBrowsers } = await import('../browsers.js');
|
|
180
|
+
const survey = await surveyBrowsers({ headless: true });
|
|
181
|
+
if (survey.chosen?.binary && (await exists(survey.chosen.binary))) {
|
|
182
|
+
executable = survey.chosen.binary;
|
|
183
|
+
borrowedFrom = survey.chosen.name;
|
|
184
|
+
there = true;
|
|
185
|
+
}
|
|
186
|
+
} catch {
|
|
187
|
+
// Nothing found, or the survey itself would not run. Reported as "no browser" below.
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
135
191
|
if (!there) {
|
|
136
192
|
return {
|
|
137
193
|
ok: false,
|
|
138
194
|
state: 'no browser',
|
|
139
195
|
chromium: mod.chromium,
|
|
140
196
|
version,
|
|
141
|
-
why: `
|
|
197
|
+
why: `The browser driver ${version ?? ''} is installed, and there is no browser on this machine for it to open. This is one command and nobody has to be asked.`.trim(),
|
|
142
198
|
howToGet: 'npx playwright install chromium',
|
|
143
199
|
executable,
|
|
144
200
|
};
|
|
@@ -150,7 +206,9 @@ export async function loadPlaywright(opts = {}) {
|
|
|
150
206
|
chromium: mod.chromium,
|
|
151
207
|
version,
|
|
152
208
|
executable,
|
|
153
|
-
why:
|
|
209
|
+
why: borrowedFrom
|
|
210
|
+
? `The browser driver ${version ?? ''} is here and it will open ${borrowedFrom}, which is a separate application from the browser you use, so pages can be opened.`.trim()
|
|
211
|
+
: `The browser driver ${version ?? ''} is here and its Chromium is downloaded, so pages can be opened.`.trim(),
|
|
154
212
|
};
|
|
155
213
|
}
|
|
156
214
|
|
|
@@ -192,6 +250,7 @@ async function exists(file) {
|
|
|
192
250
|
*
|
|
193
251
|
* @param {object} opts
|
|
194
252
|
* @param {any} opts.chromium
|
|
253
|
+
* @param {string} [opts.executable] Which browser to open. From `loadPlaywright`.
|
|
195
254
|
* @param {string} opts.scratchDir
|
|
196
255
|
* @param {{width: number, height: number, deviceScaleFactor?: number}} [opts.viewport]
|
|
197
256
|
* @param {'light'|'dark'} [opts.colorScheme]
|
|
@@ -209,6 +268,15 @@ export async function openWindow(opts) {
|
|
|
209
268
|
await fsp.mkdir(profileDir, { recursive: true });
|
|
210
269
|
|
|
211
270
|
const context = await opts.chromium.launchPersistentContext(profileDir, {
|
|
271
|
+
// Which browser, said out loud rather than left to the driver's default.
|
|
272
|
+
//
|
|
273
|
+
// `playwright-core` has no browser of its own, so without this it looks for one that was
|
|
274
|
+
// never downloaded and the launch fails with a path nobody recognises. `loadPlaywright`
|
|
275
|
+
// has already decided which browser this machine should open — usually Chrome for
|
|
276
|
+
// Testing, deliberately not the browser the person uses — and this is where that decision
|
|
277
|
+
// is honoured. Left out when there is nothing to say, so the full `playwright` keeps
|
|
278
|
+
// using the Chromium it downloaded for itself.
|
|
279
|
+
...(opts.executable ? { executablePath: opts.executable } : {}),
|
|
212
280
|
headless: opts.headed !== true,
|
|
213
281
|
viewport,
|
|
214
282
|
deviceScaleFactor,
|
|
@@ -1243,7 +1311,9 @@ export function flattenAria(nodes) {
|
|
|
1243
1311
|
// Two things with the same name in the same place have to be told apart somehow, and
|
|
1244
1312
|
// counting is the only honest way left. The count is kept per place, so it cannot
|
|
1245
1313
|
// spread: adding a row to one list never renumbers another.
|
|
1246
|
-
|
|
1314
|
+
// The separator is written as the escape, never as the byte. A raw NUL makes grep and
|
|
1315
|
+
// file(1) treat this whole module as binary and skip it without saying so.
|
|
1316
|
+
const key = `${scope.join(' ')}\u0000${node.role}\u0000${node.name ?? ''}`;
|
|
1247
1317
|
const nth = (seen.get(key) ?? 0) + 1;
|
|
1248
1318
|
seen.set(key, nth);
|
|
1249
1319
|
|