@msout/microsoft-webauth 0.1.1 → 0.1.4

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/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 enoola/phptr/msout
3
+ Copyright (c) 2026 msout@tuta.io
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/NOTICE.md ADDED
@@ -0,0 +1,63 @@
1
+ # NOTICE
2
+
3
+ ## License
4
+
5
+ This project is released under the **MIT License**. See [LICENSE](LICENSE) for
6
+ the full text.
7
+
8
+ ## Commercial use
9
+
10
+ The MIT License grants anyone the right to use, copy, modify, merge, publish,
11
+ distribute, sublicense and sell copies of this software, including for
12
+ commercial purposes. **No permission is required, and none is withheld.**
13
+
14
+ This NOTICE cannot add conditions to the MIT License, and does not attempt to.
15
+ If you are reading this hoping it sets rules, it does not: the terms in
16
+ [LICENSE](LICENSE) are the terms.
17
+
18
+ **A courtesy request, not a restriction:** if you use this commercially, or build
19
+ on it in a way you make money from, please **let the author know** — an issue or
20
+ a note is welcome. This is a request out of interest in the project, not a
21
+ condition of use. Nobody can enforce it, and no licence condition depends on it.
22
+
23
+ ## Attribution
24
+
25
+ The copyright notice and the MIT permission notice must be retained in all
26
+ copies or substantial portions of the Software. Keeping the author's name in the
27
+ files is the one real obligation MIT does impose, and the reason the author
28
+ field is populated in `package.json` for every package in this organisation.
29
+
30
+ ## Paid features
31
+
32
+ If a paid or hosted version of this tool ever appears, it will be paid for as a
33
+ **service** — hosting, support, or convenience — never as a licence condition.
34
+ The code in this repository stays MIT for everyone, permanently. A licence
35
+ cannot be both permissively open and conditional, so the open-source grant is
36
+ not something that will ever be withdrawn or moved behind a paywall.
37
+
38
+ ## Origin
39
+
40
+ Extracted from [MSOneNote Exporter](https://github.com/enoola/Microsoft-OneNote-Exporter).
41
+ Sibling packages: [microsoft-onenote-list-notebooks](https://github.com/Ms-OneNote-Exporter/microsoft-onenote-list-notebooks),
42
+ [microsoft-onenote-export-notebook](https://github.com/Ms-OneNote-Exporter/microsoft-onenote-export-notebook),
43
+ [microsoft-outlook-list-emails](https://github.com/Ms-OneNote-Exporter/microsoft-outlook-list-emails).
44
+
45
+ ## Why this project exists
46
+
47
+ Microsoft does not provide a convenient way to export a whole OneNote notebook,
48
+ and the Graph API path is not a usable substitute: it caps page retrieval and
49
+ requires Entra admin rights. This tool produces the authenticated session state
50
+ those other tools consume, using Playwright instead of the Graph API.
51
+
52
+ It deliberately does **not** rely on Graph, and it deliberately does not ask you
53
+ to hand it your password in a hosted web form. Authentication state is written
54
+ to a local file (`auth-file.json`, mode `600`) that you own, can inspect, and can
55
+ delete at any time.
56
+
57
+ ## Handling of credentials
58
+
59
+ `auth-file.json` contains a Playwright `storageState` — live session cookies.
60
+ Anyone who reads that file can act as you until the session expires. Treat it
61
+ like a password file: do not commit it, do not upload it to a service you do not
62
+ control, and delete it when you are done. The default path is under
63
+ `~/.microsoft-webauth/`, outside any repository.
package/README.md CHANGED
@@ -166,5 +166,5 @@ microsoft-webauth-playwright/
166
166
 
167
167
  ## License
168
168
 
169
- MIT — see [LICENSE](LICENSE).
169
+ MIT — see [LICENSE](LICENSE), and read [NOTICE.md](NOTICE.md).
170
170
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@msout/microsoft-webauth",
3
- "version": "0.1.1",
3
+ "version": "0.1.4",
4
4
  "description": "Microsoft web authentication module, using playwright to automate the login process and retrieve cookies for authenticated sessions.",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/src/auth.js CHANGED
@@ -116,6 +116,52 @@ function promptUser(query) {
116
116
  * @param {import('playwright').Page} page - Playwright page object
117
117
  * @param {string} targetUrl - The target URL (ONENOTE_URL or OUTLOOK_URL)
118
118
  */
119
+ /**
120
+ * The authenticated OneNote web app's path. It is not stable: the Microsoft 365
121
+ * Copilot rebrand moved it from /notebooks to /copilotnotebooks, and
122
+ * "/copilotnotebooks".includes("/notebooks") is false — so a substring check on
123
+ * "/notebooks" alone timed out on a login that had in fact fully succeeded, with
124
+ * the notebooks UI rendered and the account name on screen, and never saved the
125
+ * auth state.
126
+ *
127
+ * Both spellings are listed, and the "notebooks" component is still required so
128
+ * the unauthenticated marketing page (onenote.cloud.microsoft/en-us) can never
129
+ * satisfy this. A bare hostname check would, and did: it caused premature
130
+ * auth saving.
131
+ */
132
+ const ONENOTE_APP_PATH = /\/copilotnotebooks\b|\/notebooks\b/;
133
+
134
+ /** UI markers that only render once the session is actually signed in. */
135
+ const ONENOTE_SIGNED_IN_MARKERS = [
136
+ 'text="My notebooks"',
137
+ 'text="Create new notebook"',
138
+ 'text="All Notebooks"',
139
+ 'text="Welcome, "'
140
+ ];
141
+
142
+ /**
143
+ * Waits for the authenticated app, bounded by `timeoutMs` instead of the full
144
+ * production timeout. Returns whether it arrived, so it can be asserted on.
145
+ * @returns {Promise<boolean>}
146
+ */
147
+ async function waitForAuthSuccessProbe(page, targetUrl, timeoutMs) {
148
+ const isOutlook = targetUrl && targetUrl.includes('outlook.cloud.microsoft');
149
+ const attempts = isOutlook
150
+ ? [
151
+ page.waitForSelector('[aria-label*="message list"], [role="grid"][aria-label*="mail"], .messageList', { state: 'visible', timeout: timeoutMs }),
152
+ page.waitForSelector('text=/Inbox|Sent Mail|Drafts/i', { state: 'visible', timeout: timeoutMs }),
153
+ page.waitForSelector('div[role="row"]', { state: 'visible', timeout: timeoutMs }),
154
+ ]
155
+ : [
156
+ page.waitForURL(url => ONENOTE_APP_PATH.test(url.toString()), { timeout: timeoutMs }),
157
+ ...ONENOTE_SIGNED_IN_MARKERS.map(marker =>
158
+ page.waitForSelector(marker, { state: 'visible', timeout: timeoutMs })),
159
+ ];
160
+
161
+ const won = await Promise.any(attempts.map(p => p.then(() => true))).catch(() => false);
162
+ return won;
163
+ }
164
+
119
165
  async function waitForAuthSuccess(page, targetUrl) {
120
166
  const isOutlook = targetUrl && targetUrl.includes('outlook.cloud.microsoft');
121
167
 
@@ -131,18 +177,13 @@ async function waitForAuthSuccess(page, targetUrl) {
131
177
  ]);
132
178
  logger.success('Outlook mail interface detected.');
133
179
  } else {
134
- // OneNote default behavior — wait for the authenticated notebooks interface.
135
- // IMPORTANT: We must require /notebooks in the URL to avoid matching the
136
- // unauthenticated marketing landing page (onenote.cloud.microsoft/en-us)
137
- // which also matches the old hostname-only check and caused premature auth saving.
138
180
  logger.info('Waiting for redirection to authenticated notebooks interface...');
139
181
  await Promise.any([
140
- // Primary: URL must contain /notebooks (authenticated app)
141
- page.waitForURL(url => url.toString().includes('/notebooks'), { timeout: 60000 }),
182
+ // Primary: the URL must be the authenticated app, not the marketing page
183
+ page.waitForURL(url => ONENOTE_APP_PATH.test(url.toString()), { timeout: 60000 }),
142
184
  // Fallback UI elements that only appear when actually signed in
143
- page.waitForSelector('text="My notebooks"', { state: 'visible', timeout: 60000 }),
144
- page.waitForSelector('text="Create new notebook"', { state: 'visible', timeout: 60000 }),
145
- page.waitForSelector('text="Welcome, "', { state: 'visible', timeout: 60000 }),
185
+ ...ONENOTE_SIGNED_IN_MARKERS.map(marker =>
186
+ page.waitForSelector(marker, { state: 'visible', timeout: 60000 })),
146
187
  ]);
147
188
  logger.success('Authenticated notebooks interface detected.');
148
189
  }
@@ -483,10 +524,7 @@ async function clearBlockingScreens(page, options = {}) {
483
524
  logger.info(`Blocking screen detected: ${screen.name} (${shortUrl(state.url)}). Accepting it...`);
484
525
 
485
526
  if (dodump) {
486
- const dumpDir = await logger.getDumpDir();
487
- const displayPath = logger.getDumpDisplayPath();
488
- const debugFile = path.join(dumpDir, `debug_blocking_screen_${i + 1}.html`);
489
- await fs.writeFile(debugFile, await page.content().catch(e => `<!-- Error: ${e.message} -->`));
527
+ const displayPath = await dumpPage(page, `debug_blocking_screen_${i + 1}.html`);
490
528
  logger.debug(`[dodump] Blocking screen state dumped to ${displayPath}/debug_blocking_screen_${i + 1}.html`);
491
529
  }
492
530
 
@@ -514,6 +552,435 @@ async function clearBlockingScreens(page, options = {}) {
514
552
  return { handled, reason: 'max_screens' };
515
553
  }
516
554
 
555
+ /* ------------------------------------------------------------------------- *
556
+ * "How do you want to sign in?" — the screens between email and password
557
+ *
558
+ * For a passwordless-enabled account, Microsoft serves this after the email
559
+ * step (captured in logs/dumps, PageID i5030):
560
+ *
561
+ * <h1 data-testid="title">Get a code to sign in</h1>
562
+ * <button type="submit" data-testid="primaryButton">Send code</button>
563
+ * <span role="button" class="fui-Link" tabindex="0">Use your password</span>
564
+ *
565
+ * Note what is *not* there: an "Other ways to sign in" link. The password is
566
+ * only reachable via the "Use your password" link in the footer of that very
567
+ * screen, and it is a <span role="button">, not an <a>, so it only responds to
568
+ * real pointer events. The previous implementation assumed "Other ways to sign
569
+ * in" always came first: it waited 15 s for a link that does not exist, threw
570
+ * STUCK, swallowed it, and then let the password selector time out 30 s later.
571
+ * A ~45 s stall ending in a misleading "incorrect credentials" message.
572
+ *
573
+ * So instead of racing text selectors and betting on which one wins, the state
574
+ * is read from the DOM — what is actually on offer — and "Use your password" is
575
+ * pressed wherever it appears.
576
+ * ------------------------------------------------------------------------- */
577
+
578
+ /**
579
+ * Label patterns for the sign-in-choice screens, as *source strings*: they are
580
+ * handed into page.evaluate(), where RegExp objects do not survive
581
+ * serialization and must be rebuilt on the far side.
582
+ */
583
+ const SIGN_IN_LABELS = {
584
+ usePassword: 'use your password|use a password instead|use password instead|sign in with a password',
585
+ otherWays: 'other ways to sign in|sign in another way|look for another way|try another way',
586
+ // The list of methods, where "Password" is one entry among several.
587
+ passwordEntry: '^password$|use your password|use a password',
588
+ methodList: 'select a (?:sign-in |verification )?method|choose (?:a |another )?way to sign in|how do you want to sign in',
589
+ sendCode: 'send code|get a code to sign in|text me a code|email me a code',
590
+ approveApp: 'approve a request on my microsoft authenticator app|approve sign in request',
591
+ otcPrompt: 'enter (?:the )?code|type (?:the )?code|verification code',
592
+ };
593
+
594
+ /** Compiles a SIGN_IN_LABELS entry into an anchored, case-insensitive RegExp. */
595
+ function signInLabel(key) {
596
+ return new RegExp(SIGN_IN_LABELS[key], 'i');
597
+ }
598
+
599
+ /** Selectors for anything on the page that can be clicked, on either UI generation. */
600
+ const CLICKABLE_SELECTOR = 'a[href], button, input[type="submit"], input[type="button"], [role="button"], [role="link"]';
601
+
602
+ /** The password box, on both the legacy and the Fluent sign-in pages. */
603
+ const PASSWORD_FIELD_SELECTOR = 'input[name="passwd"], input[type="password"]';
604
+
605
+ /**
606
+ * Reads what the current sign-in screen actually offers, in a single round trip.
607
+ *
608
+ * Everything is answered from one evaluate() so the reading is a consistent
609
+ * snapshot: seven separate locators would each sample the page at a slightly
610
+ * different moment, which is how a screen mid-navigation gets misclassified.
611
+ *
612
+ * @param {import('playwright').Page} page
613
+ * @returns {Promise<object|null>} null while the document is being swapped
614
+ */
615
+ async function readSignInState(page) {
616
+ try {
617
+ return await page.evaluate(labels => {
618
+ const visible = el => {
619
+ if (!el) return false;
620
+ // aria-hidden means the element is not exposed to the user, so it
621
+ // is not something the user can interact with. login.microsoftonline.com
622
+ // parks its leftover fields in the DOM exactly this way:
623
+ // <input name="loginfmt" class="moveOffScreen" aria-hidden="true">.
624
+ // Those are off-screen rather than display:none, so they still have
625
+ // a non-zero box and pass a pure geometry test — which is how a
626
+ // work account's password page was mistaken for the email step.
627
+ if (el.getAttribute('aria-hidden') === 'true') return false;
628
+ const rect = el.getBoundingClientRect();
629
+ if (rect.width <= 0 || rect.height <= 0) return false;
630
+ const style = window.getComputedStyle(el);
631
+ return style.visibility !== 'hidden' && style.display !== 'none';
632
+ };
633
+
634
+ const labelOf = el => `${el.value || ''} ${el.textContent || ''}`.replace(/\s+/g, ' ').trim();
635
+
636
+ // True when a *control* carrying this label is on screen. Deliberately
637
+ // not a body-text search: prose mentioning "another way" must never
638
+ // be mistaken for the button that acts on it.
639
+ const offersAction = key => {
640
+ const rx = new RegExp(labels[key], 'i');
641
+ return Array.from(document.querySelectorAll(
642
+ 'a[href], button, input[type="submit"], input[type="button"], [role="button"], [role="link"]'
643
+ )).some(el => visible(el) && rx.test(labelOf(el)));
644
+ };
645
+
646
+ const headingEl = document.querySelector('h1, [role="heading"], [data-testid="title"]');
647
+ const body = document.body ? (document.body.innerText || '').replace(/\s+/g, ' ') : '';
648
+
649
+ return {
650
+ url: location.href,
651
+ heading: (headingEl ? headingEl.textContent : '').replace(/\s+/g, ' ').trim(),
652
+ // Still on the email form: the step after it has not rendered yet.
653
+ emailField: visible(document.querySelector('input[name="loginfmt"]')),
654
+ passwordField: visible(document.querySelector('input[name="passwd"], input[type="password"]')),
655
+ otcField: visible(document.querySelector('input[name="otc"], input[type="tel"]')),
656
+ usePassword: offersAction('usePassword'),
657
+ otherWays: offersAction('otherWays'),
658
+ sendCode: offersAction('sendCode') || new RegExp(labels.sendCode, 'i').test(body),
659
+ // Prose-only screens: read from the page text, nothing to click.
660
+ methodList: new RegExp(labels.methodList, 'i').test(body),
661
+ approveApp: new RegExp(labels.approveApp, 'i').test(body),
662
+ otcPrompt: new RegExp(labels.otcPrompt, 'i').test(body)
663
+ };
664
+ }, SIGN_IN_LABELS);
665
+ } catch (_) {
666
+ // Execution context destroyed mid-navigation — caller should retry.
667
+ return null;
668
+ }
669
+ }
670
+
671
+ /** True when the screen is one this handler knows how to act on. */
672
+ function isActionableSignInState(state) {
673
+ if (!state || state.emailField) return false;
674
+ return !!(state.passwordField || state.usePassword || state.otherWays
675
+ || state.methodList || state.sendCode || state.approveApp || state.otcPrompt);
676
+ }
677
+
678
+ /** Identity of a sign-in screen, to tell a real step change from a re-render. */
679
+ function signInStateSignature(state) {
680
+ if (!state) return null;
681
+ return [state.url, state.heading, state.passwordField, state.usePassword,
682
+ state.otherWays, state.methodList, state.sendCode, state.approveApp].join('::');
683
+ }
684
+
685
+ /**
686
+ * Polls until `accept` is satisfied or the timeout runs out.
687
+ * @returns {Promise<object|null>} the accepted state, else the last readable one
688
+ */
689
+ async function waitForSignInState(page, timeout, accept = isActionableSignInState) {
690
+ const deadline = Date.now() + timeout;
691
+ let state = null;
692
+ do {
693
+ const read = await readSignInState(page);
694
+ if (read) {
695
+ state = read;
696
+ if (accept(read)) return read;
697
+ }
698
+ await page.waitForTimeout(400).catch(() => {});
699
+ } while (Date.now() < deadline);
700
+ // The last *readable* state, not whatever a mid-navigation read happened to
701
+ // return: null would discard the only evidence of why the step is stuck.
702
+ return state;
703
+ }
704
+
705
+ /**
706
+ * Clicks the control carrying a given label.
707
+ *
708
+ * The Fluent pages render links as <span role="button">, which only react to a
709
+ * real pointer event: a .click() on an ancestor wrapper fires nothing. So the
710
+ * lookup escalates from the most semantic to the most forceful, and the last
711
+ * step targets the *innermost* match rather than the first in document order.
712
+ *
713
+ * @param {import('playwright').Page} page
714
+ * @param {RegExp} rx
715
+ * @param {{ timeout?: number }} [options]
716
+ * @returns {Promise<string|null>} the label clicked, or null if nothing matched
717
+ */
718
+ async function clickByLabel(page, rx, { timeout = 8000 } = {}) {
719
+ // Precise strategies first: they match on the accessible name, so they hit
720
+ // the control the user sees rather than a wrapper.
721
+ const precise = [
722
+ page.getByRole('button', { name: rx }),
723
+ page.getByRole('link', { name: rx })
724
+ ];
725
+
726
+ // Raced, not sequential: a control is a button on one screen generation and a
727
+ // link on the other, so trying them in turn means paying the full timeout on
728
+ // whichever role does not apply.
729
+ const hit = await Promise.any(
730
+ precise.map((locator, i) => locator.first().waitFor({ state: 'visible', timeout }).then(() => i))
731
+ ).catch(() => -1);
732
+
733
+ // getByText is the fuzzier fallback, and the JS click the forceful one.
734
+ if (hit < 0) {
735
+ try {
736
+ await page.getByText(rx).first().waitFor({ state: 'visible', timeout: Math.min(timeout, 2000) });
737
+ precise.push(page.getByText(rx));
738
+ hit = precise.length - 1;
739
+ } catch (_) { /* fall through to the JS click */ }
740
+ }
741
+
742
+ if (hit >= 0) {
743
+ const first = precise[hit].first();
744
+ try {
745
+ // Read the label *before* clicking. These controls navigate on click,
746
+ // and textContent() against a locator whose element the navigation just
747
+ // removed blocks for the full 30 s default timeout before rejecting —
748
+ // which is exactly the stall this function is meant to avoid.
749
+ const label = ((await first.textContent({ timeout: 2000 }).catch(() => '')) || '')
750
+ .replace(/\s+/g, ' ').trim();
751
+ await first.click({ timeout: Math.min(timeout, 5000) });
752
+ return label || '(clicked)';
753
+ } catch (_) {
754
+ // Found but not clickable — let the JS click have a go.
755
+ }
756
+ }
757
+
758
+ return await page.evaluate(({ selector, source }) => {
759
+ const rx = new RegExp(source, 'i');
760
+ const labelOf = el => `${el.value || ''} ${el.textContent || ''}`.replace(/\s+/g, ' ').trim();
761
+ const hits = Array.from(document.querySelectorAll(selector)).filter(el => rx.test(labelOf(el)));
762
+ // Innermost hit: an ancestor's textContent includes the descendant's, so
763
+ // the first match in document order is usually a wrapper whose click
764
+ // never reaches the handler the user can see.
765
+ const target = hits.find(el => !hits.some(other => other !== el && el.contains(other)));
766
+ if (!target) return null;
767
+ target.click();
768
+ return labelOf(target);
769
+ }, { selector: CLICKABLE_SELECTOR, source: rx.source }).catch(() => null);
770
+ }
771
+
772
+ /**
773
+ * Presses the sign-in submit control.
774
+ *
775
+ * The legacy pages use <input type="submit" value="Sign in">, where the label
776
+ * lives in `value` and `filter({ hasText })` can never match it; the Fluent pages
777
+ * use <button type="submit" data-testid="primaryButton">Sign in</button>. Matching
778
+ * on the accessible role name covers both.
779
+ *
780
+ * @param {import('playwright').Page} page
781
+ * @returns {Promise<boolean>} true if a submit control was clicked
782
+ */
783
+ async function submitSignInForm(page) {
784
+ const strategies = [
785
+ page.getByRole('button', { name: /^(sign in|next|finish|continue)$/i }),
786
+ page.locator('input[type="submit"]'),
787
+ page.locator('button[type="submit"]')
788
+ ];
789
+
790
+ for (const locator of strategies) {
791
+ const button = locator.first();
792
+ if (!(await button.isVisible().catch(() => false))) continue;
793
+ // click() waits for the element to become enabled, which covers the
794
+ // short window after fill() while the page validates the password.
795
+ await button.click({ timeout: 10000 });
796
+ return true;
797
+ }
798
+ return false;
799
+ }
800
+
801
+ /**
802
+ * Walks the "how do you want to sign in?" screens and lands on the password box.
803
+ *
804
+ * Prefers "Use your password" wherever it is offered, because on the
805
+ * passwordless screen that link is the only route to the password — there is no
806
+ * "Other ways to sign in" step to go through first. Only falls back to that
807
+ * step when the screen really does present it, then picks "Password" out of the
808
+ * resulting method list.
809
+ *
810
+ * @param {import('playwright').Page} page
811
+ * @param {object} [options]
812
+ * @param {number} [options.maxSteps]
813
+ * @param {number} [options.stateTimeout] how long to wait for the first screen
814
+ * @param {number} [options.transitionTimeout] how long to wait after each click
815
+ * @param {boolean} [options.dodump]
816
+ * @param {string} [options.dumpFile] basename written under the dump dir
817
+ * @returns {Promise<{ reached: boolean, reason: string, steps: number, state: object|null }>}
818
+ */
819
+ async function reachPasswordScreen(page, options = {}) {
820
+ const {
821
+ maxSteps = 4,
822
+ stateTimeout = 15000,
823
+ transitionTimeout = 10000,
824
+ dodump = false,
825
+ dumpFile = 'debug_intermediate_screen'
826
+ } = options;
827
+
828
+ let state = await waitForSignInState(page, stateTimeout);
829
+ let dumped = false;
830
+
831
+ for (let steps = 0; steps < maxSteps; steps++) {
832
+ // The password box is checked FIRST, ahead of the "still on the email
833
+ // step" gate below. A work/school account signs in on
834
+ // login.microsoftonline.com, whose password page keeps the username
835
+ // field in the DOM as <input name="loginfmt" class="moveOffScreen"> —
836
+ // rendered off-screen, and therefore "visible" by any geometry test.
837
+ // Gating on emailField first made every such login report "unreadable"
838
+ // and burn the whole stateTimeout while the password box sat right
839
+ // there, ready to be filled.
840
+ if (state && state.passwordField) {
841
+ logger.debug(`Password field reached after ${steps} step(s).`);
842
+ return { reached: true, reason: 'password_field', steps, state };
843
+ }
844
+
845
+ if (!state || !isActionableSignInState(state)) {
846
+ return { reached: false, reason: 'unreadable', steps, state };
847
+ }
848
+
849
+ if (dodump && !dumped) {
850
+ dumped = true;
851
+ const displayPath = await dumpPage(page, `${dumpFile}.html`);
852
+ logger.debug(`[dodump] Intermediate screen state dumped to ${displayPath}/${dumpFile}.html`);
853
+ }
854
+
855
+ const before = signInStateSignature(state);
856
+ let clicked = null;
857
+
858
+ if (state.usePassword) {
859
+ logger.info('Password is offered on this screen — clicking "Use your password"...');
860
+ clicked = await clickByLabel(page, signInLabel('usePassword'));
861
+ } else if (state.otherWays) {
862
+ logger.info('Opening "Other ways to sign in"...');
863
+ clicked = await clickByLabel(page, signInLabel('otherWays'));
864
+ } else if (state.methodList) {
865
+ logger.info('Choosing "Password" from the sign-in method list...');
866
+ clicked = await clickByLabel(page, signInLabel('passwordEntry'));
867
+ }
868
+
869
+ if (!clicked) {
870
+ // No route to a password from here. Say which kind of screen it is,
871
+ // so a mandatory code/approval is not reported as a bad password.
872
+ const reason = state.approveApp ? 'approver_prompt'
873
+ : state.otcPrompt || state.sendCode || state.otcField ? 'code_prompt'
874
+ : 'no_password_route';
875
+ logger.warn(`No way to reach the password screen from "${state.heading || shortUrl(state.url)}" (${reason}).`);
876
+ return { reached: false, reason, steps, state };
877
+ }
878
+
879
+ logger.debug(`Clicked "${clicked}". Waiting for the next screen...`);
880
+
881
+ // Wait for a *different* screen, not just the password box: the method
882
+ // list is a legitimate hop in the middle of this walk, and waiting only
883
+ // for the password field would burn the whole timeout on it. The
884
+ // signature guard keeps the not-yet-navigated pre-click state from
885
+ // satisfying the wait immediately.
886
+ state = await waitForSignInState(page, transitionTimeout,
887
+ s => s.passwordField || (isActionableSignInState(s) && signInStateSignature(s) !== before));
888
+ if (state && signInStateSignature(state) === before) {
889
+ logger.warn(`Screen did not change after clicking "${clicked}".`);
890
+ return { reached: false, reason: 'unchanged', steps, state };
891
+ }
892
+ }
893
+
894
+ return { reached: false, reason: 'max_steps', steps: maxSteps, state };
895
+ }
896
+
897
+ /**
898
+ * Writes the current page to a debug dump, with credentials removed.
899
+ *
900
+ * `--dodump` calls page.content(), and that serialises the *live value* of every
901
+ * form control. On the page where the password is typed that means the user's
902
+ * actual Microsoft password lands on disk in cleartext:
903
+ *
904
+ * <input type="password" name="passwd" value="the-real-password">
905
+ *
906
+ * The dumps are gitignored and never packed, but they land in the working
907
+ * tree, where backups, sync clients and file-sharing will pick them up, and
908
+ * they get pasted into issues and chat. Every dump goes through here so the
909
+ * redaction cannot be forgotten at the next call site.
910
+ *
911
+ * The scrubbing happens on a *clone* of the document rather than by rewriting
912
+ * the HTML string: PPFT and the other flow tokens are the values the login
913
+ * form is about to POST, so blanking them in the live DOM would break the very
914
+ * login being debugged. A clone cannot affect the page.
915
+ *
916
+ * @param {import('playwright').Page} page
917
+ * @param {string} fileName basename, e.g. debug_after_password.html
918
+ */
919
+ async function dumpPage(page, fileName) {
920
+ const dumpDir = await logger.getDumpDir();
921
+ const displayPath = logger.getDumpDisplayPath();
922
+ await fs.writeFile(path.join(dumpDir, fileName), await redactedPageContent(page));
923
+ return displayPath;
924
+ }
925
+
926
+ /** Placeholder written over any redacted value, so a scrubbed dump is obvious. */
927
+ const REDACTED = '[redacted]';
928
+
929
+ /**
930
+ * Serialises the page with credential-bearing control values blanked.
931
+ *
932
+ * Redacted: every `input[type=password]` whatever it is named, plus any control
933
+ * whose name or id reads as a credential or a bearer token — PPFT and the other
934
+ * pre-auth flow tokens, id/access/refresh tokens, secrets, and OTP / one-time
935
+ * code fields.
936
+ *
937
+ * Deliberately *not* redacted: the account identifier (`loginfmt`, `login`).
938
+ * It is already written in cleartext by the "Attempting automated login for
939
+ * <email>" line and is on the command line, so hiding it in the HTML would
940
+ * protect nothing while removing the one field worth having when a login fails
941
+ * on the wrong account.
942
+ *
943
+ * @param {import('playwright').Page} page
944
+ * @returns {Promise<string>}
945
+ */
946
+ async function redactedPageContent(page) {
947
+ return await page.evaluate(REDACTED => {
948
+ // Any control whose name or id holds a credential or a bearer token
949
+ // rather than UI state. Substring matching on purpose: these names are
950
+ // compound in the wild (otc, otcFallback, verificationCode, srfSFT) and
951
+ // an anchored pattern misses all but the exact spelling. Over-redacting
952
+ // one extra field costs a debug dump nothing; under-redacting leaks a
953
+ // credential. PPFT is Microsoft's pre-auth flow token and matches no
954
+ // generic word, so it is named outright.
955
+ const SENSITIVE = /ppft|token|canary|secret|passw|credential|otp|otc|code$|pin$/i;
956
+
957
+ const isSensitive = el => {
958
+ if (String(el.type || '').toLowerCase() === 'password') return true;
959
+ return SENSITIVE.test(`${el.name || ''}`.trim()) || SENSITIVE.test(`${el.id || ''}`.trim());
960
+ };
961
+
962
+ const clone = document.documentElement.cloneNode(true);
963
+ for (const el of clone.querySelectorAll('input, textarea')) {
964
+ // Only a value that is actually there needs hiding.
965
+ if (!el.value || !isSensitive(el)) continue;
966
+
967
+ if (el.tagName === 'TEXTAREA') {
968
+ el.textContent = REDACTED;
969
+ } else {
970
+ // setAttribute, never `.value =`. Assigning the IDL property on a
971
+ // *visible* input puts the element into "dirty value mode": the IDL
972
+ // value changes but the content attribute is left alone, and
973
+ // outerHTML serialises the content attribute — so the secret comes
974
+ // out unchanged. Only type="hidden" inputs are saved by that
975
+ // accident, and a password field is a visible input, which is
976
+ // exactly the case that would have leaked.
977
+ el.setAttribute('value', REDACTED);
978
+ }
979
+ }
980
+ return `<!DOCTYPE html>\n${clone.outerHTML}`;
981
+ }, REDACTED).catch(e => `<!-- Error redacting or reading page: ${e.message} -->`);
982
+ }
983
+
517
984
  async function login(credentials = {}) {
518
985
  const { email, password, targetUrl, authFile } = credentials;
519
986
  const isAutomated = !!(email && password);
@@ -609,9 +1076,17 @@ async function login(credentials = {}) {
609
1076
  logger.info('Email entered. Clicking "Next"...');
610
1077
  await page.click('input[type="submit"]');
611
1078
 
612
- logger.debug('Waiting for email field to disappear...');
613
- await page.waitForSelector('input[name="loginfmt"]', { state: 'hidden', timeout: 15000 }).catch(() => {
614
- logger.debug('Email field still present, proceeding with caution.');
1079
+ // Wait for the email form to actually be replaced, but not for the
1080
+ // loginfmt input to vanish: on login.microsoftonline.com it is
1081
+ // parked as <input name="loginfmt" class="moveOffScreen"> and stays
1082
+ // in the DOM for the rest of the login, so "hidden" never happens
1083
+ // and this used to burn the full timeout on every work account.
1084
+ // The next step appearing is the real signal that we have moved on.
1085
+ await Promise.race([
1086
+ page.waitForSelector(PASSWORD_FIELD_SELECTOR, { state: 'visible', timeout: 15000 }).then(() => 'password'),
1087
+ page.waitForSelector('input[name="loginfmt"]', { state: 'hidden', timeout: 15000 }).then(() => 'advanced'),
1088
+ ]).catch(() => {
1089
+ logger.debug('Email form did not visibly change yet; the sign-in method step will wait for the next screen.');
615
1090
  });
616
1091
 
617
1092
  logger.info('Will wait 1 seconds to give the UI a moment to settle into the next screen (MFA/Password)');
@@ -625,10 +1100,7 @@ async function login(credentials = {}) {
625
1100
  } catch (e) {
626
1101
  logger.error(`Failed to enter email: ${e.message}`);
627
1102
  if (credentials.dodump) {
628
- const dumpDir = await logger.getDumpDir();
629
- const displayPath = logger.getDumpDisplayPath();
630
- const debugFile = path.join(dumpDir, 'debug_login_error_email.html');
631
- await fs.writeFile(debugFile, await page.content().catch(e => `<!-- Error: ${e.message} -->`));
1103
+ const displayPath = await dumpPage(page, 'debug_login_error_email.html');
632
1104
  logger.error(`Email submission failed. HTML dumped to ${displayPath}/debug_login_error_email.html`);
633
1105
  }
634
1106
  throw e;
@@ -636,145 +1108,61 @@ async function login(credentials = {}) {
636
1108
 
637
1109
  // Proactive dump after email step (before MFA detection)
638
1110
  if (credentials.dodump) {
639
- const dumpDir = await logger.getDumpDir();
640
- const displayPath = logger.getDumpDisplayPath();
641
- const debugFile = path.join(dumpDir, 'debug_after_email.html');
642
- await fs.writeFile(debugFile, await page.content().catch(e => `<!-- Error: ${e.message} -->`));
1111
+ const displayPath = await dumpPage(page, 'debug_after_email.html');
643
1112
  logger.debug(`[dodump] Post-email state dumped to ${displayPath}/debug_after_email.html`);
644
1113
  }
645
1114
 
646
- // 1.5. Handle intermediate screens (MFA selection, "Other ways to sign in")
1115
+ // 1.5. Get from the email step to the password box. This screen has
1116
+ // no "Other ways to sign in" step on it — the "Use your password" link
1117
+ // in its footer is the only route to the password — so the state is
1118
+ // read from the DOM rather than guessed from a race between text
1119
+ // selectors. See reachPasswordScreen() above.
647
1120
  try {
648
- const pageTitle = (await page.title()).trim();
649
- const pageHeading = (await page.locator('h1, [role="heading"]').first().textContent().catch(() => '')).trim();
650
-
651
- logger.debug(`Settled State: Title="${pageTitle}" | Heading="${pageHeading}"`);
652
- logger.debug('Checking for intermediate MFA/Sign-in option screens...');
653
-
654
- const result = await Promise.race([
655
- page.waitForSelector('text=/Other ways to sign in/i', { state: 'visible', timeout: 15000 }).then(() => 'other_ways'),
656
- page.waitForSelector('text=/Get a code to sign in/i', { state: 'visible', timeout: 15000 }).then(() => 'other_ways'),
657
- page.waitForSelector('text=/Verify your identity/i', { state: 'visible', timeout: 15000 }).then(() => 'other_ways'),
658
- page.waitForSelector('text=/Use your password/i', { state: 'visible', timeout: 15000 }).then(() => 'use_password'),
659
- page.waitForSelector('text=/Approve a request on my Microsoft Authenticator app/i', { state: 'visible', timeout: 5000 }).then(() => 'approve_app'),
660
- page.waitForSelector('input[name="passwd"]', { state: 'visible', timeout: 15000 }).then(() => 'password'),
661
- page.waitForFunction(() => {
662
- const h = document.querySelector('h1, [role="heading"]')?.textContent || '';
663
- return h.includes('Get a code') || h.includes('Verify your identity');
664
- }, { timeout: 15000 }).then(() => 'other_ways'),
665
- ]).catch((err) => {
666
- logger.debug(`Detection race timed out or failed: ${err.message}`);
667
- return 'timeout';
668
- });
669
-
670
- logger.debug(`Intermediate screen detection result: ${result}`);
1121
+ const nav = await reachPasswordScreen(page, { dodump: credentials.dodump });
671
1122
 
672
- if (credentials.dodump) {
673
- const dumpDir = await logger.getDumpDir();
674
- const displayPath = logger.getDumpDisplayPath();
675
- const debugFile = path.join(dumpDir, 'debug_intermediate_screen.html');
676
- await fs.writeFile(debugFile, await page.content().catch(e => `<!-- Error: ${e.message} -->`));
677
- logger.debug(`[dodump] Intermediate screen state dumped to ${displayPath}/debug_intermediate_screen.html`);
1123
+ logger.debug(`Sign-in method step: reached=${nav.reached} (${nav.reason}) after ${nav.steps} step(s)`);
1124
+ if (nav.state) {
1125
+ logger.debug(`Current screen: ${shortUrl(nav.state.url)} — heading: "${nav.state.heading || '(none)'}"`);
678
1126
  }
679
1127
 
680
- if (result === 'other_ways' || pageHeading.includes('Get a code') || pageHeading.includes('Verify your identity')) {
681
- logger.info('Detected MFA/Verification screen. Attempting to locate "Other ways to sign in"...');
682
-
683
- const otherWays = page.getByRole('button', { name: /Other ways to sign in|Sign in another way/i })
684
- .or(page.getByText(/Other ways to sign in|Sign in another way/i))
685
- .first();
686
-
687
- try {
688
- logger.debug('Waiting for "Other ways" link to appear in DOM...');
689
- await otherWays.waitFor({ state: 'attached', timeout: 15000 });
690
-
691
- const isVisible = await otherWays.isVisible();
692
- logger.debug(`"Other ways" link visibility: ${isVisible}`);
693
-
694
- logger.info('Clicking "Other ways to sign in"...');
695
- try {
696
- await otherWays.click({ timeout: 5000 });
697
- } catch (e) {
698
- logger.debug(`Standard click failed, trying forced: ${e.message}`);
699
- await otherWays.click({ force: true, timeout: 5000 });
700
- }
701
- } catch (e) {
702
- logger.warn(`MFA link interaction failed: ${e.message}`);
703
-
704
- logger.debug('Attempting final fallback: JavaScript-based click...');
705
- const clicked = await page.evaluate(() => {
706
- const elements = Array.from(document.querySelectorAll('span, a, button'));
707
- const target = elements.find(el =>
708
- el.textContent.toLowerCase().includes('other ways to sign in') ||
709
- el.textContent.toLowerCase().includes('sign in another way')
710
- );
711
- if (target) {
712
- target.click();
713
- return true;
714
- }
715
- return false;
716
- });
717
-
718
- if (clicked) {
719
- logger.info('Successfully triggered click via JavaScript fallback.');
720
- } else if (pageHeading.includes('Get a code')) {
721
- throw new Error('STUCK: "Other ways to sign in" link not found even via JS scan.');
722
- }
723
- }
724
-
725
- logger.debug('Waiting for method selection screen ("Use your password")...');
726
- const subResult = await Promise.race([
727
- page.waitForSelector('text=/Use your password/i', { state: 'visible', timeout: 15000 }).then(() => 'use_password'),
728
- page.waitForSelector('#idA_PWD_SwitchToPassword', { state: 'visible', timeout: 15000 }).then(() => 'use_password'),
729
- page.waitForSelector('text=/Select a verification method/i', { state: 'visible', timeout: 15000 }).then(() => 'other_ways_list'),
730
- ]).catch(() => 'timeout');
731
-
732
- logger.debug(`Sub-screen detection result: ${subResult}`);
733
-
734
- if (subResult === 'use_password') {
735
- logger.info('Selecting "Use your password" option...');
736
- await page.click('text=/Use your password/i');
737
- } else if (subResult === 'other_ways_list') {
738
- logger.info('Selection list detected. Looking for "Password"...');
739
- await page.click('text=/Password|Use your password/i');
740
- }
741
- } else if (result === 'use_password') {
742
- logger.info('Detected "Use your password" option. Clicking...');
743
- await page.click('text="Use your password"');
744
- } else if (result === 'approve_app') {
745
- logger.warn('MFA notification already sent. Attempting to switch to password...');
746
- const otherLink = page.locator('text="Other ways to sign in", #signInAnotherWay').first();
747
- if (await otherLink.isVisible()) {
748
- await otherLink.click();
749
- await page.waitForSelector('text="Use your password"', { state: 'visible', timeout: 10000 });
750
- await page.click('text="Use your password"');
751
- }
752
- } else if (result === 'password') {
753
- logger.debug('Direct password field detected.');
754
- } else if (result === 'timeout') {
755
- logger.debug('No intermediate screen detected within timeout. Proceeding to password entry.');
1128
+ if (!nav.reached && nav.reason === 'code_prompt') {
1129
+ logger.warn('Microsoft is asking for a verification code instead of a password. This account cannot finish a password-only login.');
756
1130
  }
757
1131
  } catch (e) {
758
- logger.debug(`Intermediate screen handler encountered a fatal issue: ${e.message}`);
1132
+ // Never fatal: step 2 still waits for the password box and reports
1133
+ // precisely which screen is in the way if it is not there.
1134
+ logger.debug(`Sign-in method step skipped: ${e.message}`);
759
1135
  }
760
1136
 
761
1137
  // 2. Enter Password
762
1138
  try {
763
- await page.waitForSelector('input[name="passwd"]', { state: 'visible', timeout: 30000 });
764
- await page.fill('input[name="passwd"]', password);
1139
+ const passwordField = page.locator(PASSWORD_FIELD_SELECTOR).first();
1140
+ try {
1141
+ await passwordField.waitFor({ state: 'visible', timeout: 30000 });
1142
+ } catch (e) {
1143
+ // "page.waitForSelector: Timeout 30000ms exceeded" is the least
1144
+ // actionable error this tool can emit, and it is what a
1145
+ // passwordless screen used to produce. Name the screen instead.
1146
+ const stuck = await readSignInState(page) || await readScreenState(page);
1147
+ if (stuck) {
1148
+ const needsCode = stuck.sendCode || stuck.approveApp || stuck.otcPrompt;
1149
+ throw new Error(
1150
+ `Password field never appeared. Still on ${shortUrl(stuck.url)} — heading: "${stuck.heading || '(none)'}".` +
1151
+ (needsCode
1152
+ ? ' Microsoft is offering a code/phone approval here, not a password.'
1153
+ : ' This screen does not offer a password sign-in.')
1154
+ );
1155
+ }
1156
+ throw e;
1157
+ }
765
1158
 
766
- const submitButton = page.locator('input[type="submit"], button[type="submit"]').filter({ hasText: /Sign in|Next|Finish/i }).first();
1159
+ await passwordField.fill(password);
767
1160
 
768
- logger.debug('Waiting for submit button to be enabled...');
769
- await submitButton.waitFor({ state: 'visible', timeout: 10000 });
770
- if (await submitButton.isDisabled()) {
771
- logger.debug('Submit button is disabled. It might be the wrong one or the password field is not considered filled.');
772
- logger.info('Will wait 1 seconds to let the submit button load properly');
773
- await page.waitForTimeout(1000);
1161
+ logger.debug('Submitting the sign-in form...');
1162
+ if (!(await submitSignInForm(page))) {
1163
+ throw new Error('Password filled but no sign-in submit control was found.');
774
1164
  }
775
1165
 
776
- await submitButton.click();
777
-
778
1166
  const passwordError = page.locator('#passwordError');
779
1167
  if (await passwordError.isVisible({ timeout: 2000 })) {
780
1168
  const errorMsg = await passwordError.textContent();
@@ -782,10 +1170,7 @@ async function login(credentials = {}) {
782
1170
  }
783
1171
  } catch (e) {
784
1172
  if (credentials.dodump) {
785
- const dumpDir = await logger.getDumpDir();
786
- const displayPath = logger.getDumpDisplayPath();
787
- const debugFile = path.join(dumpDir, 'debug_login_error_password.html');
788
- await fs.writeFile(debugFile, await page.content().catch(e => `<!-- Error: ${e.message} -->`));
1173
+ const displayPath = await dumpPage(page, 'debug_login_error_password.html');
789
1174
  logger.error(`Password entry failed. HTML dumped to ${displayPath}/debug_login_error_password.html`);
790
1175
  }
791
1176
  throw e;
@@ -793,10 +1178,7 @@ async function login(credentials = {}) {
793
1178
 
794
1179
  // Proactive dump after password submission (before post-password MFA check)
795
1180
  if (credentials.dodump) {
796
- const dumpDir = await logger.getDumpDir();
797
- const displayPath = logger.getDumpDisplayPath();
798
- const debugFile = path.join(dumpDir, 'debug_after_password.html');
799
- await fs.writeFile(debugFile, await page.content().catch(e => `<!-- Error: ${e.message} -->`));
1181
+ const displayPath = await dumpPage(page, 'debug_after_password.html');
800
1182
  logger.debug(`[dodump] Post-password state dumped to ${displayPath}/debug_after_password.html`);
801
1183
  }
802
1184
 
@@ -816,19 +1198,19 @@ async function login(credentials = {}) {
816
1198
 
817
1199
  // 2.5b. Handle post-password MFA/Verification if needed
818
1200
  try {
1201
+ // ".displaySign" is the legacy number-match element; the Fluent
1202
+ // pages put the same number under a data-testid instead.
1203
+ const NUMBER_MATCH = '.displaySign, [data-testid="displaySign"]';
819
1204
  const verificationScreen = await Promise.race([
820
1205
  page.waitForSelector('text="Verify your identity"', { timeout: 10000 }).then(() => 'verify'),
821
1206
  page.waitForSelector('text="Enter code"', { timeout: 10000 }).then(() => 'enter_code'),
822
1207
  page.waitForSelector('input[name="otc"]', { timeout: 10000 }).then(() => 'otc_input'),
823
1208
  page.waitForSelector('text=/Approve sign in request/i', { timeout: 10000 }).then(() => 'number_match'),
824
- page.waitForSelector('.displaySign', { timeout: 10000 }).then(() => 'number_match'),
1209
+ page.waitForSelector(NUMBER_MATCH, { timeout: 10000 }).then(() => 'number_match'),
825
1210
  ]).catch(() => null);
826
1211
 
827
1212
  if (credentials.dodump) {
828
- const dumpDir = await logger.getDumpDir();
829
- const displayPath = logger.getDumpDisplayPath();
830
- const debugFile = path.join(dumpDir, 'debug_post_password_mfa.html');
831
- await fs.writeFile(debugFile, await page.content().catch(e => `<!-- Error: ${e.message} -->`));
1213
+ const displayPath = await dumpPage(page, 'debug_post_password_mfa.html');
832
1214
  logger.debug(`[dodump] Post-password MFA screen state dumped to ${displayPath}/debug_post_password_mfa.html`);
833
1215
  }
834
1216
 
@@ -837,20 +1219,20 @@ async function login(credentials = {}) {
837
1219
 
838
1220
  let matchNumber = '??';
839
1221
  try {
840
- matchNumber = await page.$eval('.displaySign', el => el.textContent.trim());
1222
+ matchNumber = await page.locator(NUMBER_MATCH).first().textContent().catch(() => null) || '??';
841
1223
  } catch (_) {
842
- logger.debug('Could not extract number from .displaySign — user may still see it if --notheadless is used.');
1224
+ logger.debug('Could not extract the number-match code — user may still see it if --notheadless is used.');
843
1225
  }
844
1226
 
845
1227
  logger.step('══════════════════════════════════════════════════════');
846
1228
  logger.step(` ACTION REQUIRED: Open Microsoft Authenticator on your phone.`);
847
- logger.step(` Enter the number: ${matchNumber}`);
1229
+ logger.step(` Enter the number: ${matchNumber.trim()}`);
848
1230
  logger.step(` Then tap "Yes" / "Approve" in the app.`);
849
1231
  logger.step('══════════════════════════════════════════════════════');
850
1232
  logger.info('Waiting for phone approval (up to 120 seconds)...');
851
1233
 
852
1234
  await Promise.race([
853
- page.waitForSelector('.displaySign', { state: 'hidden', timeout: 120000 }),
1235
+ page.waitForSelector(NUMBER_MATCH, { state: 'hidden', timeout: 120000 }),
854
1236
  page.waitForURL(url => !url.toString().includes('login.microsoftonline.com'), { timeout: 120000 }),
855
1237
  page.waitForSelector('text=/Stay signed in/i', { timeout: 120000 }),
856
1238
  ]);
@@ -871,7 +1253,9 @@ async function login(credentials = {}) {
871
1253
  await page.locator('input[type="text"]:visible, input[type="tel"]:visible').first().fill(code);
872
1254
  }
873
1255
 
874
- await page.click('input[type="submit"]');
1256
+ if (!(await submitSignInForm(page))) {
1257
+ logger.debug('No submit control found on the verification screen.');
1258
+ }
875
1259
  }
876
1260
  } catch (e) {
877
1261
  logger.debug(`Post-password verification handling skipped or failed: ${e.message}`);
@@ -914,10 +1298,7 @@ async function login(credentials = {}) {
914
1298
  }
915
1299
  }
916
1300
  if (credentials.dodump) {
917
- const dumpDir = await logger.getDumpDir();
918
- const displayPath = logger.getDumpDisplayPath();
919
- const debugFile = path.join(dumpDir, 'debug_login_error_success.html');
920
- await fs.writeFile(debugFile, await page.content().catch(e => `<!-- Error: ${e.message} -->`));
1301
+ const displayPath = await dumpPage(page, 'debug_login_error_success.html');
921
1302
  logger.error(`Success detection failed. HTML dumped to ${displayPath}/debug_login_error_success.html`);
922
1303
  }
923
1304
  throw e;
@@ -1019,5 +1400,19 @@ module.exports = {
1019
1400
  logout,
1020
1401
  // Exported for tests: clears the consent/interrupt screens that Microsoft can
1021
1402
  // inject mid-login (e.g. the Terms of Use update at account.live.com/tou/accrue).
1022
- clearBlockingScreens
1403
+ clearBlockingScreens,
1404
+ // Exported for tests: walks the "how do you want to sign in?" screens
1405
+ // (passwordless "Get a code to sign in", "Other ways to sign in", method
1406
+ // list) and lands on the password box.
1407
+ reachPasswordScreen,
1408
+ // Exported for tests: presses the sign-in submit control on both the legacy
1409
+ // (<input type="submit" value="Sign in">) and Fluent (<button>) pages.
1410
+ submitSignInForm,
1411
+ // Exported for tests: the single path every --dodump write goes through, so
1412
+ // that credentials cannot reach a dump file.
1413
+ dumpPage,
1414
+ // Exported for tests: the same success detection with a caller-supplied
1415
+ // timeout, so "is this the authenticated app?" can be asserted on directly
1416
+ // rather than through a 60 s wait.
1417
+ waitForAuthSuccessProbe
1023
1418
  };