From a21948daf6210f67955ae4f98a81e21b8cf9f1f2 Mon Sep 17 00:00:00 2001
From: toasted-nutbread <toasted-nutbread@users.noreply.github.com>
Date: Sat, 24 Feb 2024 23:32:39 -0500
Subject: Improve type safety for playwright stuff (#727)

---
 test/playwright/integration.spec.js |  34 ++++++----
 test/playwright/playwright-util.js  | 128 ++++++++++++++++++++----------------
 2 files changed, 91 insertions(+), 71 deletions(-)

diff --git a/test/playwright/integration.spec.js b/test/playwright/integration.spec.js
index 4957b676..8e641397 100644
--- a/test/playwright/integration.spec.js
+++ b/test/playwright/integration.spec.js
@@ -17,12 +17,12 @@
 
 import path from 'path';
 import {createDictionaryArchive} from '../../dev/util.js';
+import {deferPromise} from '../../ext/js/core/utilities.js';
 import {
     expect,
-    expectedAddNoteBody,
+    getExpectedAddNoteBody,
+    getMockModelFields,
     mockAnkiRouteHandler,
-    mockModelFieldNames,
-    mockModelFieldsToAnkiValues,
     root,
     test,
     writeToClipboardFromPage
@@ -45,16 +45,21 @@ test('search clipboard', async ({page, extensionId}) => {
 
 test('anki add', async ({context, page, extensionId}) => {
     // Mock anki routes
-    /** @type {?(value: unknown) => void} */
-    let resolve = null;
-    const addNotePromise = new Promise((res) => {
-        resolve = res;
-    });
+    /** @type {import('core').DeferredPromiseDetails<Record<string, unknown>>} */
+    const addNotePromiseDetails = deferPromise();
     await context.route(/127.0.0.1:8765\/*/, (route) => {
         void mockAnkiRouteHandler(route);
         const req = route.request();
-        if (req.url().includes('127.0.0.1:8765') && req.postDataJSON().action === 'addNote') {
-            /** @type {(value: unknown) => void} */ (resolve)(req.postDataJSON());
+        if (req.url().includes('127.0.0.1:8765')) {
+            /** @type {unknown} */
+            const requestJson = req.postDataJSON();
+            if (
+                typeof requestJson === 'object' &&
+                requestJson !== null &&
+                /** @type {Record<string, unknown>} */ (requestJson).action === 'addNote'
+            ) {
+                addNotePromiseDetails.resolve(/** @type {Record<string, unknown>} */ (requestJson));
+            }
         }
     });
 
@@ -79,8 +84,9 @@ test('anki add', async ({context, page, extensionId}) => {
     await page.locator('[data-modal-action="show,anki-cards"]').click();
     await page.locator('select.anki-card-deck').selectOption('Mock Deck');
     await page.locator('select.anki-card-model').selectOption('Mock Model');
-    for (const modelField of mockModelFieldNames) {
-        await page.locator(`[data-setting="anki.terms.fields.${modelField}"]`).fill(/** @type {string} */ (mockModelFieldsToAnkiValues[modelField]));
+    const mockFields = getMockModelFields();
+    for (const [modelField, value] of mockFields) {
+        await page.locator(`[data-setting="anki.terms.fields.${modelField}"]`).fill(value);
     }
     await page.locator('#anki-cards-modal > div > div.modal-footer > button:nth-child(2)').click();
     await writeToClipboardFromPage(page, '読むの例文');
@@ -94,6 +100,6 @@ test('anki add', async ({context, page, extensionId}) => {
     }).toPass({timeout: 5000});
     await page.locator('#search-textbox').press('Enter');
     await page.locator('[data-mode="term-kanji"]').click();
-    const addNoteReqBody = await addNotePromise;
-    expect(addNoteReqBody).toMatchObject(expectedAddNoteBody);
+    const addNoteReqBody = await addNotePromiseDetails.promise;
+    expect(addNoteReqBody).toMatchObject(getExpectedAddNoteBody());
 });
diff --git a/test/playwright/playwright-util.js b/test/playwright/playwright-util.js
index 653112c6..1ea2e604 100644
--- a/test/playwright/playwright-util.js
+++ b/test/playwright/playwright-util.js
@@ -51,32 +51,39 @@ export const test = base.extend({
 
 export const expect = test.expect;
 
-export const mockModelFieldNames = [
-    'Word',
-    'Reading',
-    'Audio',
-    'Sentence'
-];
-
-/** @type {{[key: string]: string|undefined}} */
-export const mockModelFieldsToAnkiValues = {
-    Word: '{expression}',
-    Reading: '{furigana-plain}',
-    Sentence: '{clipboard-text}',
-    Audio: '{audio}'
-};
+/**
+ * @returns {Map<string, string>}
+ */
+export function getMockModelFields() {
+    return new Map([
+        ['Word', '{expression}'],
+        ['Reading', '{furigana-plain}'],
+        ['Sentence', '{clipboard-text}'],
+        ['Audio', '{audio}']
+    ]);
+}
 
 /**
  * @param {import('playwright').Route} route
  * @returns {Promise<void>}
  */
 export async function mockAnkiRouteHandler(route) {
-    const reqBody = route.request().postDataJSON();
-    const respBody = ankiRouteResponses[reqBody.action];
-    if (!respBody) {
-        return route.abort();
+    try {
+        /** @type {unknown} */
+        const requestJson = route.request().postDataJSON();
+        if (typeof requestJson !== 'object' || requestJson === null) {
+            throw new Error(`Invalid request type: ${typeof requestJson}`);
+        }
+        const body = getResponseBody(/** @type {import('core').SerializableObject} */ (requestJson).action);
+        const responseJson = {
+            status: 200,
+            contentType: 'text/json',
+            body: JSON.stringify(body)
+        };
+        await route.fulfill(responseJson);
+    } catch {
+        return await route.abort();
     }
-    await route.fulfill(respBody);
 }
 
 /**
@@ -88,45 +95,52 @@ export const writeToClipboardFromPage = async (page, text) => {
     await page.evaluate(`navigator.clipboard.writeText('${text}')`);
 };
 
-export const expectedAddNoteBody = {
-    version: 2,
-    action: 'addNote',
-    params: {
-        note: {
-            fields: {
-                Word: '読む',
-                Reading: '読[よ]む',
-                Audio: '[sound:mock_audio.mp3]',
-                Sentence: '読むの例文'
-            },
-            tags: ['yomitan'],
-            deckName: 'Mock Deck',
-            modelName: 'Mock Model',
-            options: {
-                allowDuplicate: false,
-                duplicateScope: 'collection',
-                duplicateScopeOptions: {
-                    deckName: null,
-                    checkChildren: false,
-                    checkAllModels: false
+/**
+ * @returns {Record<string, unknown>}
+ */
+export function getExpectedAddNoteBody() {
+    return {
+        version: 2,
+        action: 'addNote',
+        params: {
+            note: {
+                fields: {
+                    Word: '読む',
+                    Reading: '読[よ]む',
+                    Audio: '[sound:mock_audio.mp3]',
+                    Sentence: '読むの例文'
+                },
+                tags: ['yomitan'],
+                deckName: 'Mock Deck',
+                modelName: 'Mock Model',
+                options: {
+                    allowDuplicate: false,
+                    duplicateScope: 'collection',
+                    duplicateScopeOptions: {
+                        deckName: null,
+                        checkChildren: false,
+                        checkAllModels: false
+                    }
                 }
             }
         }
-    }
-};
-
-const baseAnkiResp = {
-    status: 200,
-    contentType: 'text/json'
-};
+    };
+}
 
-/** @type {{[key: string]: import('core').SerializableObject}} */
-const ankiRouteResponses = {
-    version: Object.assign({body: JSON.stringify(6)}, baseAnkiResp),
-    deckNames: Object.assign({body: JSON.stringify(['Mock Deck'])}, baseAnkiResp),
-    modelNames: Object.assign({body: JSON.stringify(['Mock Model'])}, baseAnkiResp),
-    modelFieldNames: Object.assign({body: JSON.stringify(mockModelFieldNames)}, baseAnkiResp),
-    canAddNotes: Object.assign({body: JSON.stringify([true, true])}, baseAnkiResp),
-    storeMediaFile: Object.assign({body: JSON.stringify('mock_audio.mp3')}, baseAnkiResp),
-    addNote: Object.assign({body: JSON.stringify(102312488912)}, baseAnkiResp)
-};
+/**
+ * @param {unknown} action
+ * @returns {unknown}
+ * @throws {Error}
+ */
+function getResponseBody(action) {
+    switch (action) {
+        case 'version': return 6;
+        case 'deckNames': return ['Mock Deck'];
+        case 'modelNames': return ['Mock Model'];
+        case 'modelFieldNames': return [...getMockModelFields().keys()];
+        case 'canAddNotes': return [true, true];
+        case 'storeMediaFile': return 'mock_audio.mp3';
+        case 'addNote': return 102312488912;
+        default: throw new Error(`Unknown action: ${action}`);
+    }
+}
-- 
cgit v1.2.3