├── .babelrc ├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── release-please.yml └── workflows │ ├── build.yml │ ├── bump.yml │ └── publish.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.yml ├── .stylelintrc ├── .vscode └── settings.json ├── CHANGELOG.md ├── LICENSE.txt ├── README.md ├── cli ├── README.md ├── index.js ├── package-lock.json └── package.json ├── docs ├── code-of-conduct.md └── contributing.md ├── images ├── icon-small.svg ├── icon.svg ├── promo │ ├── github-social.png │ ├── large-promo.png │ ├── marquee-promo.png │ └── small-promo.png └── screenshots │ ├── screenshot-errors.png │ ├── screenshot-initial.png │ ├── screenshot-load-unpacked.png │ ├── screenshot-overview.png │ └── screenshot-pin-extension.png ├── package-lock.json ├── package.json ├── rollup.config.js ├── scripts ├── convert-to-pdf.sh ├── login.sh ├── publish.sh └── version.js ├── src ├── background.ts ├── components │ ├── app.css │ ├── app.tsx │ ├── code-wrap.css │ ├── code-wrap.tsx │ ├── header │ │ ├── index.tsx │ │ └── style.css │ ├── report │ │ ├── index.tsx │ │ └── style.css │ ├── summary │ │ ├── index.tsx │ │ ├── score.tsx │ │ └── style.css │ └── table │ │ └── index.tsx ├── css │ └── highlight.css ├── declaration.d.ts ├── images │ └── icons │ │ ├── icon128-monochrome.png │ │ ├── icon128.png │ │ ├── icon16.png │ │ ├── icon32.png │ │ └── icon48.png ├── index.ts ├── lib │ ├── array-util.spec.ts │ ├── array-util.ts │ ├── audits │ │ ├── attributes.spec.ts │ │ ├── attributes.ts │ │ ├── audit-util.spec.ts │ │ ├── audit-util.ts │ │ ├── audits.ts │ │ ├── autocomplete.spec.ts │ │ ├── autocomplete.ts │ │ ├── forms.spec.ts │ │ ├── forms.ts │ │ ├── inputs.spec.ts │ │ ├── inputs.ts │ │ ├── labels.spec.ts │ │ └── labels.ts │ ├── constants.ts │ ├── content-script.ts │ ├── dom-iterator.spec.ts │ ├── dom-iterator.ts │ ├── element-highlighter.ts │ ├── image-info-util.ts │ ├── messaging-util.ts │ ├── options.ts │ ├── overlay.ts │ ├── save-html.spec.ts │ ├── save-html.ts │ ├── string-util.spec.ts │ ├── string-util.ts │ ├── test-util.ts │ ├── tree-util.spec.ts │ ├── tree-util.ts │ ├── types.d.ts │ ├── wait-util.ts │ ├── webpage-icon-util.spec.ts │ └── webpage-icon-util.ts ├── manifest.json ├── module.ts ├── routes │ ├── details │ │ ├── index.tsx │ │ └── style.css │ ├── notfound │ │ ├── index.tsx │ │ └── style.css │ └── results │ │ ├── index.tsx │ │ ├── result-item.tsx │ │ └── style.css ├── style │ └── index.css ├── sw.js ├── template.html └── test-data │ ├── form-problems.json │ ├── score.json │ ├── shadow-dom.json │ └── shopify.json ├── tests ├── __mocks__ │ ├── browserMocks.ts │ ├── fileMocks.ts │ └── setupTests.ts ├── code-wrap.test.tsx ├── declarations.d.ts └── header.test.tsx └── tsconfig.json /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "preact-cli/babel", 5 | { 6 | "env": "development" 7 | } 8 | ], 9 | [ 10 | "@babel/preset-env", 11 | { 12 | "modules": false, 13 | "targets": { 14 | "esmodules": true 15 | } 16 | } 17 | ] 18 | ], 19 | "plugins": [ 20 | ["@babel/plugin-proposal-class-properties", { "loose": false }], 21 | ["@babel/plugin-proposal-private-methods", { "loose": false }], 22 | ["@babel/plugin-proposal-private-property-in-object", { "loose": false }] 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | indent_style = space 7 | indent_size = 2 8 | 9 | [*.{js,ts}] 10 | trim_trailing_whitespace = true 11 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | build/ 2 | dist/ 3 | scripts/version.js 4 | rollup.config.js 5 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "parserOptions": { 4 | "ecmaVersion": 2017, 5 | "sourceType": "module" 6 | }, 7 | "rules": { 8 | "@typescript-eslint/no-non-null-assertion": 0, 9 | "@typescript-eslint/no-unused-vars": [2, { "args": "none" }], 10 | "brace-style": [2, "1tbs", { "allowSingleLine": false }], 11 | "camelcase": [2, { "properties": "always" }], 12 | "curly": 2, 13 | "default-case": 2, 14 | "dot-notation": 2, 15 | "eqeqeq": ["error", "always", { "null": "ignore" }], 16 | "max-len": [0], 17 | "new-cap": 2, 18 | "no-console": 0, 19 | "no-else-return": 0, 20 | "no-eval": 2, 21 | "no-multi-spaces": 2, 22 | "no-multiple-empty-lines": [2, { "max": 2 }], 23 | "no-shadow": 2, 24 | "no-trailing-spaces": 2, 25 | "no-unused-expressions": 2, 26 | "padded-blocks": [2, "never"], 27 | "semi": [2, "always"], 28 | "spaced-comment": 2, 29 | "valid-typeof": 2 30 | }, 31 | "env": { 32 | "es6": true, 33 | "browser": true, 34 | "node": true 35 | }, 36 | "extends": ["preact", "plugin:@typescript-eslint/recommended", "plugin:prettier/recommended"], 37 | "globals": {}, 38 | "plugins": [], 39 | "overrides": [ 40 | { 41 | "files": ["*.spec.js"], 42 | "rules": { 43 | "no-unused-expressions": 0 44 | } 45 | } 46 | ], 47 | "ignorePatterns": ["build/"] 48 | } 49 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | --- 8 | 9 | **Describe the bug** A clear and concise description of what the bug is. 10 | 11 | **To Reproduce** Steps to reproduce the behavior: 12 | 13 | 1. Visit: [website url] 14 | 2. Click on '....' 15 | 3. Scroll down to '....' 16 | 4. See error 17 | 18 | **Expected behavior** A clear and concise description of what you expected to happen. 19 | 20 | **Screenshots** If applicable, add screenshots to help explain your problem. 21 | 22 | **Additional context** Add any other context about the problem here. 23 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** A clear and concise description of what the problem 10 | is. For example: I'm always frustrated when [...] 11 | 12 | **Describe the solution you'd like** A clear and concise description of what you want to happen. 13 | 14 | **Describe alternatives you've considered** A clear and concise description of any alternative solutions or features 15 | you've considered. 16 | 17 | **Additional context** Add any other context or screenshots about the feature request here. 18 | -------------------------------------------------------------------------------- /.github/release-please.yml: -------------------------------------------------------------------------------- 1 | # https://github.com/apps/release-please 2 | primaryBranch: main 3 | releaseType: node 4 | handleGHRelease: true 5 | bumpMinorPreMajor: false 6 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | branches: 6 | - '*' 7 | - '!release-*' 8 | pull_request: 9 | branches: 10 | - main 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | strategy: 17 | matrix: 18 | node-version: [16.x] 19 | 20 | steps: 21 | - uses: actions/checkout@v3 22 | - name: Use Node.js ${{ matrix.node-version }} 23 | uses: actions/setup-node@v3 24 | with: 25 | node-version: ${{ matrix.node-version }} 26 | - run: npm install 27 | - run: npm run lint 28 | - run: npm run build 29 | - run: npm test 30 | -------------------------------------------------------------------------------- /.github/workflows/bump.yml: -------------------------------------------------------------------------------- 1 | name: bump version 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | version: 7 | description: Version number 8 | required: true 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: bump version 17 | run: | 18 | git config --global user.name '${{ github.actor }}' 19 | git config --global user.email '${{ github.actor }}@users.noreply.github.com' 20 | git commit -m "chore: bump version to ${{ github.event.inputs.version }} 21 | 22 | release-as: ${{ github.event.inputs.version }}" --allow-empty 23 | git push 24 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: publish 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | 11 | strategy: 12 | matrix: 13 | node-version: [16.x] 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Use Node.js ${{ matrix.node-version }} 18 | uses: actions/setup-node@v1 19 | with: 20 | node-version: ${{ matrix.node-version }} 21 | - run: npm install 22 | - name: publish extension 23 | run: scripts/publish.sh 24 | env: 25 | EXTENSION_ID: ${{secrets.EXTENSION_ID}} 26 | CLIENT_ID: ${{secrets.GOOGLE_CLIENT_ID}} 27 | CLIENT_SECRET: ${{secrets.GOOGLE_CLIENT_SECRET}} 28 | REFRESH_TOKEN: ${{secrets.GOOGLE_REFRESH_TOKEN}} 29 | 30 | - name: upload release asset 31 | uses: actions/upload-release-asset@v1 32 | env: 33 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 34 | with: 35 | upload_url: ${{ github.event.release.upload_url }} 36 | asset_path: build/extension.zip 37 | asset_name: form-troubleshooter-extension.zip 38 | asset_content_type: application/zip 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build/ 2 | dist/ 3 | node_modules/ 4 | 5 | manifest.version.json 6 | manifest.dev.json 7 | manifest.prod.json 8 | *.pem 9 | *.crx 10 | *.zip 11 | 12 | size-plugin.json 13 | 14 | *.sublime-* 15 | .DS_Store 16 | .env 17 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | build/ 3 | dist/ 4 | src/test-data/ 5 | 6 | CHANGELOG.md 7 | 8 | manifest.version.json 9 | size-plugin.json 10 | -------------------------------------------------------------------------------- /.prettierrc.yml: -------------------------------------------------------------------------------- 1 | arrowParens: avoid 2 | printWidth: 120 3 | quoteProps: consistent 4 | semi: true 5 | singleQuote: true 6 | tabWidth: 2 7 | trailingComma: all 8 | overrides: 9 | - files: '*.md' 10 | options: 11 | parser: markdown 12 | proseWrap: always 13 | -------------------------------------------------------------------------------- /.stylelintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "stylelint-config-standard", 3 | "rules": { 4 | "no-descending-specificity": null 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.defaultFormatter": "esbenp.prettier-vscode", 3 | "[json]": { 4 | "editor.defaultFormatter": "esbenp.prettier-vscode" 5 | }, 6 | "[html]": { 7 | "editor.defaultFormatter": "esbenp.prettier-vscode" 8 | }, 9 | "editor.formatOnSave": true, 10 | "editor.codeActionsOnSave": { 11 | "source.fixAll.eslint": true 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Form troubleshooter 2 | 3 | **A Chrome extension to find and fix common form problems.** 4 | 5 | ![Screenshot of Form troubleshooter extension popup, running on form-problems.glitch.me page, Recommendations tab selected](https://user-images.githubusercontent.com/205226/128688470-2d7482fe-6251-46ff-af27-c4f601d431e0.png) 6 | 7 | ![Screenshot of Form troubleshooter extension popup, running on form-problems.glitch.me page, Common mistakes tab selected](https://user-images.githubusercontent.com/205226/128693291-9fcf8397-c1b7-4fb6-9731-9750d8598915.png) 8 | 9 | ![Screenshot of Form troubleshooter extension popup, running on form-problems.glitch.me page, Form details tab selected](https://user-images.githubusercontent.com/205226/128688891-20c02f65-f35f-48f5-af99-15b822ea2510.png) 10 | 11 | ## Installation 12 | 13 | This extension is be available from the 14 | [Chrome Web Store](https://chrome.google.com/webstore/detail/form-troubleshooter/lpjhcgjbicfdoijennopbjooigfipfjh). 15 | 16 | You can also download the source code for the extension and build and install it locally. 17 | 18 | ### Build from source 19 | 20 | You will need [Node.js 12 or later](https://nodejs.org/en/) to build the extension. 21 | 22 | 1. [Download the code](https://github.com/GoogleChromeLabs/form-troubleshooter/archive/refs/heads/main.zip) or clone the 23 | repo:

`git clone git@github.com:GoogleChromeLabs/form-troubleshooter.git`

24 | 2. Install dependencies: `npm install` 25 | 3. Build the extension: `npm run build` 26 | 27 | ### Install the extension locally 28 | 29 | 1. In Chrome, navigate to `chrome://extensions`

30 | Screenshot of the chrome://extensions page 31 | 2. Enable **Developer mode** 32 | 3. Click the **Load unpacked** button and select the extension's folder: 33 | 34 | - If you downloaded `form-troubleshooter-extension.zip`, the extension's folder will be the location of the extracted 35 | folder. 36 | - If you built the extension from source, the extension's folder will be the `build/` folder of the repository. 37 | 38 | 4. You can pin the extension so its icon is always visible: from the Chrome **Extensions** menu, select **Form 39 | Troubleshooter**

40 | Screenshot of the Chrome Extensions menu 41 | 42 | ## Usage 43 | 44 | Visit a page you want to check, then click the extension icon. The extension retrieves and audits form elements and 45 | attributes every time the icon is clicked. 46 | 47 | The extension popup has three sections: 48 | 49 | - **Recommendations** 50 | - **Common mistakes** 51 | - **Form details** 52 | 53 | **Save as HTML** saves the report as a local HTML file. 54 | 55 | You can try out the extension on the test page [form-problems.glitch.me](https://form-problems.glitch.me). 56 | 57 | ## Development 58 | 59 | To develop and test the extension locally, first follow the steps to [build from source](#building-from-source). 60 | 61 | ### Local web server 62 | 63 | Run the local web server with Hot Module Reloading (HMR): 64 | 65 | ```sh 66 | npm run dev 67 | ``` 68 | 69 | Open http://localhost:8080/. 70 | 71 | Note that developing in this mode requires you to bring your own form data. This can be achieved by referencing one of 72 | the existing data files in the `test-data` folder using the `data` query string parameter: 73 | 74 | - http://localhost:8080/?data=/test-data/form-problems.json 75 | - http://localhost:8080/?data=/test-data/score.json 76 | - http://localhost:8080/?data=/test-data/shadow-dom.json 77 | 78 | Or by loading a saved form file from the more menu in the top right of the popup. 79 | 80 | ### Running tests 81 | 82 | ```sh 83 | # one off test run 84 | npm run test 85 | 86 | # continuously watch for changes 87 | npm run test:watch 88 | ``` 89 | 90 | ### Linting 91 | 92 | Before contributing any code, make sure that code has been linted. 93 | 94 | ```sh 95 | npm run lint 96 | 97 | # reformat files automatically 98 | npm run pretty 99 | ``` 100 | 101 | ## Caveats 102 | 103 | - The extension is designed to be used as a tool, not to confirm whether code is 'right' or 'wrong'. Form usage is often 104 | complex (especially for high-traffic sites) so it's difficult to provide form code validation that is appropriate 105 | across a variety of sites. 106 | - Some errors found by the extension may represent known problems, or be triggered by 'incorrect' code that is justified 107 | for reasons outside the scope of the extension. In particular, many high-traffic sites use form code in a variety of 108 | ways to function at scale and integrate with legacy and third-party systems. The same code may not be appropriate for 109 | smaller-scale sites. 110 | 111 | However, please [provide feedback](https://forms.gle/Sm7DbKfLX3hHNcDp9) or 112 | [file a bug](https://github.com/GoogleChromeLabs/form-troubleshooter/issues/new) for audit results that appear to be 113 | incorrect. 114 | 115 | ## How it works 116 | 117 | The extension checks the current page for form and form field elements each time it's opened. 118 | 119 | 1. The extension icon is clicked to open [popup.html](src/popup.html). 120 | 1. popup.js [sends a message](src/js/popup.js#L36) to [content-script.js](src/js/content-script.js#L15) that the popup 121 | has opened (`popup opened`). 122 | 1. content-script.js [traverses the DOM](src/js/content-script.js#L54) including the shadow DOM and `iframe`s. 123 | 1. content-script.js [stores a DOM representation](src/js/content-script.js#L20) using `chrome.storage` which will be 124 | used by content-script.js. 125 | 1. content-script.js [sends a message](src/js/content-script.js#L22) via background.js that the DOM has been inspected 126 | (`dom inspected`). 127 | 1. content-script.js [stores form data](src/js/content-script.js#L20) using `chrome.storage`. 128 | 1. content-script.js [sends a message](src/js/content-script.js#L22) that element data has been stored 129 | (`stored element data`). 130 | 1. On [receiving the message](src/js/popup.js#L48), popup.js [gets the data from chrome.storage](src/js/popup.js#L51). 131 | 1. popup.js [runs the audits](src/js/popup.js#L111) defined in [audits.js](src/js/audits.js), to check the form elements 132 | and attributes in the page. 133 | 1. popup.js [displays an overview](src/js/popup.js#L114) of form and form field data in popup.html. 134 | 1. popup.js [displays results](src/js/popup.js#L113) of the audits in popup.html. 135 | 136 | ## Feedback and feature requests 137 | 138 | Feedback and audit suggestions welcome! 139 | 140 | - [Make a comment or request](https://forms.gle/Sm7DbKfLX3hHNcDp9) 141 | - [File a bug](https://github.com/GoogleChromeLabs/form-troubleshooter/issues/new) 142 | 143 | ## TODO 144 | 145 | - [x] Link to and/or highlight problematic elements. 146 | - [x] Link to items in the regular DOM. 147 | - [x] Link to items in shadow DOM. 148 | - [x] Link to items in iframes. 149 | - [x] Move code for displaying audit results [out of audits.js](js/audits.js#L59). 150 | - [x] Move constants to external file. 151 | - [ ] Check for forms (or other elements) that don't have a closing tag. 152 | - [x] Check for invalid `type` attribute values, for example ``. 153 | - [x] Suggest alternatives to invalid attribute names, e.g. for `autcomplete`. 154 | 155 | --- 156 | 157 | This is not a Google product. 158 | -------------------------------------------------------------------------------- /cli/README.md: -------------------------------------------------------------------------------- 1 | # Form troubleshooter command line interface 2 | 3 | This tool is a command line interface for running miscellaneous **Form troubleshooter** tasks. 4 | 5 | ## Usage 6 | 7 | ```sh 8 | form-audit rerun [...saved-html-files] 9 | form-audit extract [...saved-html-files] 10 | ``` 11 | 12 | ## Installation 13 | 14 | ```sh 15 | # Make sure that form-troubleshooter has been built, run the following command if required (from the `cli` directory): 16 | # (cd .. && npm install && npm run build) 17 | 18 | # From the `cli` directory, install dependencies: 19 | npm install 20 | 21 | # From the `cli` directory, install the command line alias: 22 | npm install --global . 23 | ``` 24 | -------------------------------------------------------------------------------- /cli/index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /* Copyright 2021 Google LLC. 3 | SPDX-License-Identifier: Apache-2.0 */ 4 | 5 | /* eslint-disable camelcase */ 6 | /* eslint-disable @typescript-eslint/no-var-requires */ 7 | const yargs = require('yargs/yargs'); 8 | const fs = require('fs'); 9 | const { hideBin } = require('yargs/helpers'); 10 | 11 | const { audit } = require('form-troubleshooter'); 12 | const { version } = require('./package.json'); 13 | 14 | const TREE_REGEXP = /\n\n`, 26 | ); 27 | }); 28 | 29 | it('should return basic HTML string', async function () { 30 | const result = await generateHtmlString( 31 | [createNode({ name: 'p', children: [{ text: 'hello world' }] })] as Element[], 32 | { json: { hello: 'world' } }, 33 | [ 34 | createNode({ name: 'title', children: [{ text: 'My title' }] }), 35 | createNode({ name: 'style', children: [{ text: 'html { width: 100% }' }] }), 36 | ] as Element[], 37 | ); 38 | expect(result).toEqual( 39 | `\n\nMy title\n

hello world

\n`, 40 | ); 41 | }); 42 | 43 | it('should return HTML embedding external stylesheet', async function () { 44 | const result = await generateHtmlString( 45 | [createNode({ name: 'p', children: [{ text: 'hello world' }] })] as Element[], 46 | { version: '1', json: { hello: 'world' } }, 47 | [ 48 | createNode({ name: 'style', children: [{ text: 'html { width: 100% }' }] }), 49 | createNode({ name: 'link', attributes: { rel: 'stylesheet', href: 'style.css' } }), 50 | ] as Element[], 51 | ); 52 | expect(result).toEqual( 53 | `\n\n\n

hello world

\n`, 54 | ); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /src/lib/save-html.ts: -------------------------------------------------------------------------------- 1 | /* Copyright 2021 Google LLC. 2 | SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | async function expandStyleSheet(element: Element) { 5 | if ( 6 | element.nodeName.toLowerCase() === 'link' && 7 | element.getAttribute('rel') === 'stylesheet' && 8 | element.getAttribute('href') 9 | ) { 10 | const href = element.getAttribute('href'); 11 | const response = await window.fetch(href as RequestInfo); 12 | const text = await response.text(); 13 | 14 | return ``; 15 | } 16 | return element.outerHTML; 17 | } 18 | 19 | function createElement(elementType: string, attributes: { [key: string]: string }, text?: string) { 20 | const element = document.createElement(elementType); 21 | 22 | Object.entries(attributes).forEach(([name, value]) => { 23 | element.setAttribute(name, value); 24 | }); 25 | 26 | if (text) { 27 | element.textContent = text; 28 | } 29 | 30 | return element; 31 | } 32 | 33 | export async function generateHtmlString( 34 | bodyElements: Element[], 35 | metadata: { [key: string]: unknown }, 36 | optionalHeadElements?: Element[], 37 | ): Promise { 38 | const headElements = [ 39 | ...Object.entries(metadata).map(([key, value]) => 40 | createElement('script', { type: 'text/json', name: key }, JSON.stringify(value)), 41 | ), 42 | ...(optionalHeadElements ?? Array.from(document.head.querySelectorAll('title, style, link[rel="stylesheet"]'))), 43 | ]; 44 | 45 | return [ 46 | ``, 47 | ``, 48 | `${(await Promise.all(headElements.map(expandStyleSheet))).join('')}`, 49 | `${bodyElements.map(element => element.outerHTML).join('')}`, 50 | ``, 51 | ].join('\n'); 52 | } 53 | -------------------------------------------------------------------------------- /src/lib/string-util.spec.ts: -------------------------------------------------------------------------------- 1 | /* Copyright 2021 Google LLC. 2 | SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | import { condenseWhitespace, pluralize, truncate } from './string-util'; 5 | 6 | describe('pluralize', function () { 7 | describe('single word', function () { 8 | it('should pluralize 0 count', function () { 9 | const result = pluralize(0, 'form'); 10 | expect(result).toEqual('forms'); 11 | }); 12 | 13 | it('should not pluralize 1 count', function () { 14 | const result = pluralize(1, 'form'); 15 | expect(result).toEqual('form'); 16 | }); 17 | 18 | it('should pluralize 2 count', function () { 19 | const result = pluralize(2, 'form'); 20 | expect(result).toEqual('forms'); 21 | }); 22 | }); 23 | 24 | describe('multiple words', function () { 25 | it('should pluralize 0 count', function () { 26 | const result = pluralize(0, 'octopus', 'octopuses'); 27 | expect(result).toEqual('octopuses'); 28 | }); 29 | 30 | it('should not pluralize 1 count', function () { 31 | const result = pluralize(1, 'octopus', 'octopuses'); 32 | expect(result).toEqual('octopus'); 33 | }); 34 | 35 | it('should pluralize 2 count', function () { 36 | const result = pluralize(2, 'octopus', 'octopuses'); 37 | expect(result).toEqual('octopuses'); 38 | }); 39 | }); 40 | 41 | describe('multiple words (same word)', function () { 42 | it('should pluralize 0 count', function () { 43 | const result = pluralize(0, 'sheep', 'sheep'); 44 | expect(result).toEqual('sheep'); 45 | }); 46 | 47 | it('should not pluralize 1 count', function () { 48 | const result = pluralize(1, 'sheep', 'sheep'); 49 | expect(result).toEqual('sheep'); 50 | }); 51 | 52 | it('should pluralize 2 count', function () { 53 | const result = pluralize(2, 'sheep', 'sheep'); 54 | expect(result).toEqual('sheep'); 55 | }); 56 | }); 57 | 58 | describe('multiple words with zero', function () { 59 | it('should pluralize 0 count', function () { 60 | const result = pluralize(0, 'octopus', 'octopuses', 'noctopi'); 61 | expect(result).toEqual('noctopi'); 62 | }); 63 | 64 | it('should not pluralize 1 count', function () { 65 | const result = pluralize(1, 'octopus', 'octopuses', 'noctopi'); 66 | expect(result).toEqual('octopus'); 67 | }); 68 | 69 | it('should pluralize 2 count', function () { 70 | const result = pluralize(2, 'octopus', 'octopuses', 'noctopi'); 71 | expect(result).toEqual('octopuses'); 72 | }); 73 | }); 74 | }); 75 | 76 | describe('truncate', function () { 77 | it('should not truncate a string that is shorter than the target length', function () { 78 | const result = truncate('short', 20); 79 | expect(result).toEqual('short'); 80 | }); 81 | 82 | it('should truncate a string that is longer than the target length, inserting default indicator', function () { 83 | const result = truncate('short sentence', 10); 84 | expect(result).toEqual('short s...'); 85 | }); 86 | 87 | it('should truncate a string that is longer than the target length, trimming out whitespace and inserting default indicator', function () { 88 | const result = truncate('short sentence', 9); 89 | expect(result).toEqual('short...'); 90 | }); 91 | 92 | it('should truncate a string that is longer than the target length, inserting custom indicator', function () { 93 | const result = truncate('short sentence', 10, '_'); 94 | expect(result).toEqual('short sen_'); 95 | }); 96 | 97 | it('should truncate a string that is longer than the target length, inserting empty indicator', function () { 98 | const result = truncate('short sentence', 10, ''); 99 | expect(result).toEqual('short sent'); 100 | }); 101 | 102 | it('should return the indicator where the indicator is longer than the target length', function () { 103 | const result = truncate('short sentence', 5, '...more...'); 104 | expect(result).toEqual('...more...'); 105 | }); 106 | 107 | it('should truncate a string leaving leading whitespace as is', function () { 108 | const result = truncate(' short sentence', 9); 109 | expect(result).toEqual(' shor...'); 110 | }); 111 | 112 | it('should return null when input is null', function () { 113 | const result = truncate(null, 10); 114 | expect(result).toEqual(null); 115 | }); 116 | }); 117 | 118 | describe('condenseWhitespace', function () { 119 | it('should condense leading and trailing whitespace (default)', function () { 120 | const result = condenseWhitespace('\n\nhello world\n\n'); 121 | expect(result).toEqual(' hello world '); 122 | }); 123 | 124 | it('should condense leading and trailing whitespace', function () { 125 | const result = condenseWhitespace('\n\nhello world\n\n', 'leading-trailing'); 126 | expect(result).toEqual(' hello world '); 127 | }); 128 | 129 | it('should condense all whitespace', function () { 130 | const result = condenseWhitespace('\n\nhello\n \nworld\n\n', 'all'); 131 | expect(result).toEqual(' hello world '); 132 | }); 133 | }); 134 | -------------------------------------------------------------------------------- /src/lib/string-util.ts: -------------------------------------------------------------------------------- 1 | /* Copyright 2021 Google LLC. 2 | SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | export function pluralize(count: number, single: string, multiple?: string, zero?: string): string { 5 | if (count === 1) { 6 | return single; 7 | } 8 | 9 | const plural = multiple ?? `${single}s`; 10 | if (count === 0) { 11 | return zero ?? plural; 12 | } 13 | return plural; 14 | } 15 | 16 | export function truncate( 17 | input: string | null | undefined, 18 | length: number, 19 | indicator = '...', 20 | ): string | null | undefined { 21 | if (!input) { 22 | return input; 23 | } 24 | 25 | const truncateLength = length - indicator.length; 26 | 27 | if (indicator.length >= length) { 28 | return indicator; 29 | } 30 | 31 | if (input.length > length) { 32 | return input.substring(0, truncateLength).trimEnd() + indicator; 33 | } 34 | 35 | return input.substring(0, length); 36 | } 37 | 38 | export function condenseWhitespace( 39 | input: string | null | undefined, 40 | mode: 'leading-trailing' | 'all' = 'leading-trailing', 41 | ): string | null | undefined { 42 | if (!input) { 43 | return input; 44 | } 45 | 46 | if (mode === 'leading-trailing') { 47 | return input.replace(/^\s+/, ' ').replace(/\s+$/, ' '); 48 | } 49 | return input.replace(/\s+/g, ' '); 50 | } 51 | 52 | export function escapeRegExp(str: string | null | undefined): string | null | undefined { 53 | return str ? str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') : str; 54 | } 55 | -------------------------------------------------------------------------------- /src/lib/test-util.ts: -------------------------------------------------------------------------------- 1 | /* Copyright 2021 Google LLC. 2 | SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | export function createNode(tree: TreeNode, document?: Document, parent?: Node): Node { 5 | const doc = document ?? new Document(); 6 | let node: Node | undefined; 7 | const treeNode = tree; 8 | 9 | if (treeNode.name) { 10 | node = doc.createElement(treeNode.name); 11 | 12 | if (treeNode.attributes) { 13 | Object.entries(treeNode.attributes).forEach(([name, value]) => { 14 | const attribute = doc.createAttribute(name)!; 15 | attribute.value = value; 16 | (node as Element).setAttributeNode(attribute); 17 | }); 18 | } 19 | 20 | if (treeNode.children) { 21 | treeNode.children.forEach(child => { 22 | const childNode = createNode(child, doc, node); 23 | if (childNode) { 24 | node?.appendChild(childNode); 25 | } 26 | }); 27 | } 28 | } else if (treeNode.text != null) { 29 | node = doc.createTextNode(treeNode.text); 30 | } else if (treeNode.type === '#shadow-root') { 31 | addShadowRoot(parent as Element); 32 | node = (parent as Element).shadowRoot!; 33 | 34 | if (treeNode.children) { 35 | treeNode.children.forEach(child => { 36 | const childNode = createNode(child, doc, node); 37 | if (childNode) { 38 | node!.appendChild(childNode); 39 | } 40 | }); 41 | } 42 | } else { 43 | throw new Error(`Unsupported node ${JSON.stringify(tree)}`); 44 | } 45 | 46 | return node; 47 | } 48 | 49 | function addShadowRoot(element: Element) { 50 | const fakeRoot = element.ownerDocument.createElement('fake-node'); 51 | Object.defineProperty(fakeRoot, 'parentNode', { 52 | get() { 53 | return null; 54 | }, 55 | }); 56 | 57 | Object.defineProperty(element, 'shadowRoot', { 58 | get() { 59 | return fakeRoot; 60 | }, 61 | }); 62 | } 63 | -------------------------------------------------------------------------------- /src/lib/tree-util.ts: -------------------------------------------------------------------------------- 1 | /* Copyright 2021 Google LLC. 2 | SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | import { groupBy } from './array-util'; 5 | 6 | /** 7 | * Copies a tree, adding parent relationships 8 | */ 9 | export function getTreeNodeWithParents(parent?: TreeNode): TreeNodeWithParent { 10 | const root = Object.assign({ attributes: {} }, parent) as TreeNodeWithParent; 11 | const queue: TreeNodeWithParent[] = [root]; 12 | let item: TreeNodeWithParent | undefined; 13 | 14 | while ((item = queue.shift())) { 15 | if (item.children) { 16 | item.children = item.children.map(c => Object.assign({ attributes: {} }, c, { parent: item })); 17 | queue.push(...item.children); 18 | } else { 19 | item.children = []; 20 | } 21 | } 22 | 23 | return root; 24 | } 25 | 26 | type TreeNodeConverter = ( 27 | node: TreeNode | TreeNodeWithParent, 28 | childNodeConverter: TreeNodeConverter | null, 29 | ) => TreeNode; 30 | function convertToBareTreeNode( 31 | node: TreeNode | TreeNodeWithParent, 32 | childNodeConverter: TreeNodeConverter | null, 33 | ): TreeNode { 34 | const newNode = {} as TreeNode; 35 | 36 | if (node.name !== undefined) { 37 | newNode.name = node.name; 38 | } 39 | if (node.text !== undefined) { 40 | newNode.text = node.text; 41 | } 42 | if (node.type !== undefined) { 43 | newNode.type = node.type; 44 | } 45 | if (node.attributes && Object.keys(node.attributes).length) { 46 | newNode.attributes = { 47 | ...node.attributes, 48 | }; 49 | } 50 | if (node.children?.length && childNodeConverter) { 51 | newNode.children = node.children.map(child => childNodeConverter(child, childNodeConverter)); 52 | } 53 | 54 | return newNode; 55 | } 56 | 57 | /** 58 | * Copies a tree, removing non essential properties which is JSON serializable 59 | * 60 | * This is the opposite of `getTreeNodeWithParents` 61 | */ 62 | export function getBareTreeNode(node: TreeNodeWithParent, includeChildren = true): TreeNode { 63 | return convertToBareTreeNode(node, includeChildren ? convertToBareTreeNode : null); 64 | } 65 | 66 | /** 67 | * Finds descendants of a given node by tagName 68 | */ 69 | export function findDescendants(parent: TreeNodeWithParent, tagNames: string[]): TreeNodeWithParent[] { 70 | const queue = [...(parent.children || [])]; 71 | const results = []; 72 | let item: TreeNodeWithParent | undefined; 73 | 74 | while ((item = queue.shift())) { 75 | if (tagNames.some(t => t === item!.name)) { 76 | results.push(item); 77 | } 78 | if (item.children) { 79 | queue.unshift(...item.children); 80 | } 81 | } 82 | 83 | return results; 84 | } 85 | 86 | /** 87 | * Gets text content recursively for a given node 88 | */ 89 | export function getTextContent(parent: TreeNodeWithParent): string { 90 | const queue = [parent]; 91 | const results = []; 92 | let item; 93 | 94 | while ((item = queue.shift())) { 95 | if (item.type === '#document') { 96 | continue; 97 | } 98 | if (item.text) { 99 | results.push(item.text); 100 | } 101 | if (item.children) { 102 | queue.unshift(...item.children); 103 | } 104 | } 105 | 106 | return results.join(' '); 107 | } 108 | 109 | /** 110 | * Searches for the closest parent node with the matching tagName 111 | */ 112 | export function closestParent(node: TreeNodeWithParent, tagName: string): TreeNodeWithParent | null { 113 | let currentNode: TreeNodeWithParent | undefined = node; 114 | while ((currentNode = currentNode.parent)) { 115 | if (tagName === currentNode.name) { 116 | return currentNode; 117 | } 118 | } 119 | return null; 120 | } 121 | 122 | /** 123 | * Searches for the closest document or shadow root 124 | */ 125 | export function closestRoot(node: TreeNodeWithParent): TreeNodeWithParent { 126 | let currentNode: TreeNodeWithParent = node; 127 | while (currentNode.parent) { 128 | if (currentNode.type === '#document' || currentNode.type === '#shadow-root') { 129 | return currentNode; 130 | } 131 | 132 | currentNode = currentNode.parent; 133 | } 134 | return currentNode; 135 | } 136 | 137 | /** 138 | * Gets a path to the node separated by `/` 139 | * 140 | * @param {TreeNodeWithParent} node 141 | * @returns {string} 142 | */ 143 | export function getPath(node: TreeNodeWithParent): string { 144 | let currentNode: TreeNodeWithParent | undefined = node; 145 | const pathSegments = []; 146 | 147 | while (currentNode) { 148 | pathSegments.unshift(getPathSegment(currentNode)); 149 | currentNode = currentNode.parent; 150 | } 151 | return `/${pathSegments.join('/')}`; 152 | } 153 | 154 | const validCssFragmentExpression = /^[-_a-z]+[-_a-z0-9]*$/i; 155 | const validCssFragmentInitialCharactersExpression = /^[-_a-z]/i; 156 | const invalidCssFragmentCharactersExpression = /[^-_a-z0-9]/gi; 157 | export function escapeCssSelectorFragment(fragment: string): string | null { 158 | if (!validCssFragmentInitialCharactersExpression.test(fragment)) { 159 | return null; 160 | } 161 | if (validCssFragmentExpression.test(fragment)) { 162 | return fragment; 163 | } 164 | return fragment.replace(invalidCssFragmentCharactersExpression, match => `\\${match.charCodeAt(0).toString(16)} `); 165 | } 166 | 167 | /** 168 | * Gets a parent unique path segment for the node 169 | */ 170 | function getPathSegment(node: TreeNodeWithParent | TreeNodeWithContext): string { 171 | if (node.type) { 172 | return node.type; 173 | } 174 | 175 | if (!node.name) { 176 | return ''; 177 | } 178 | 179 | if (node.attributes.id) { 180 | const idFragment = escapeCssSelectorFragment(node.attributes.id); 181 | if (idFragment) { 182 | return `${node.name}#${idFragment}`; 183 | } 184 | } 185 | 186 | if (!node.parent) { 187 | return node.name || ''; 188 | } 189 | 190 | const siblingsByType = groupBy( 191 | node.parent.children.filter(child => child.name), 192 | child => child.name, 193 | ); 194 | const siblingsOfType = siblingsByType.get(node.name); 195 | if (siblingsOfType?.length === 1) { 196 | return node.name; 197 | } else { 198 | const lookupNode = (node as TreeNodeWithContext).original ?? node; 199 | const index = siblingsOfType!.indexOf(lookupNode); 200 | if (index === -1) { 201 | console.log('node', lookupNode); 202 | throw new Error('Node not found among siblings'); 203 | } 204 | return `${node.name}[${index}]`; 205 | } 206 | } 207 | 208 | /** 209 | * Converts a path to a css query selector 210 | */ 211 | export function pathToQuerySelector(path: string): string { 212 | return path 213 | .split('/') 214 | .filter(segment => segment) 215 | .map(segment => 216 | segment.replace(/\[(\d+)\]/, (match, index) => { 217 | return `:nth-of-type(${Number(index) + 1})`; 218 | }), 219 | ) 220 | .join(' > '); 221 | } 222 | -------------------------------------------------------------------------------- /src/lib/types.d.ts: -------------------------------------------------------------------------------- 1 | /* Copyright 2021 Google LLC. 2 | SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | interface AuditDetails { 5 | score: number; 6 | errors: AuditResult[]; 7 | warnings: AuditResult[]; 8 | } 9 | 10 | interface AuditResult { 11 | auditType: string; 12 | items: TreeNodeWithContext[]; 13 | score: number; 14 | } 15 | 16 | interface SerializableAuditDetails { 17 | score: number; 18 | errors: SerializableAuditResult[]; 19 | warnings: SerializableAuditResult[]; 20 | } 21 | 22 | interface SerializableAuditResult { 23 | auditType: string; 24 | items: Array; 25 | score: number; 26 | } 27 | 28 | interface TreeNode { 29 | name?: string | null; 30 | text?: string | null; 31 | type?: string | null; 32 | children?: TreeNode[]; 33 | attributes?: { [key: string]: string }; 34 | } 35 | 36 | interface TreeNodeWithParent extends TreeNode { 37 | children: TreeNodeWithParent[]; 38 | attributes: { [key: string]: string }; 39 | parent?: TreeNodeWithParent; 40 | } 41 | 42 | interface TreeNodeWithContext extends TreeNodeWithParent { 43 | original?: TreeNodeWithParent; 44 | context?: T; 45 | } 46 | 47 | interface LearnMoreReference { 48 | title: string; 49 | url: string; 50 | } 51 | 52 | interface ContextSuggestion { 53 | token?: string | null; 54 | suggestion?: string | null; 55 | } 56 | 57 | interface ContextReasons { 58 | reasons: Array<{ type: string; reference: string; suggestion?: string | null }>; 59 | } 60 | 61 | interface ContextText { 62 | text: string; 63 | } 64 | 65 | interface ContextAutocompleteValue { 66 | id?: string; 67 | name?: string; 68 | } 69 | 70 | interface ContextFields { 71 | fields: TreeNodeWithParent[]; 72 | } 73 | 74 | interface ContextInvalidAttributes { 75 | invalidAttributes: Array<{ attribute: string; suggestion: string | null }>; 76 | } 77 | 78 | interface ContextDuplicates { 79 | duplicates?: TreeNodeWithParent[]; 80 | } 81 | 82 | interface AuditMetadata { 83 | type: 'error' | 'warning'; 84 | weight: number; 85 | audit: (tree: TreeNodeWithParent) => AuditResult | undefined; 86 | } 87 | -------------------------------------------------------------------------------- /src/lib/wait-util.ts: -------------------------------------------------------------------------------- 1 | /* Copyright 2021 Google LLC. 2 | SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | export function waitFor(predicate: () => T, timeoutMilliseconds = 500, pollMilliseconds = 10): Promise { 5 | return new Promise((resolve, reject) => { 6 | let interval: NodeJS.Timer | undefined; 7 | 8 | // get ready to reject if predicate doesn't resolve in time 9 | const timeout = setTimeout(() => { 10 | if (interval) { 11 | clearInterval(interval); 12 | } 13 | reject(new Error('Timeout duration exceeded')); 14 | }, timeoutMilliseconds); 15 | 16 | // check predicate as soon as possible (but not synchronously) 17 | setTimeout(() => { 18 | let ready = predicate(); 19 | if (ready) { 20 | clearTimeout(timeout); 21 | resolve(ready); 22 | } else { 23 | // poll for changes 24 | interval = setInterval(() => { 25 | ready = predicate(); 26 | if (ready) { 27 | clearTimeout(timeout); 28 | if (interval) { 29 | clearInterval(interval); 30 | } 31 | resolve(ready); 32 | } 33 | }, pollMilliseconds); 34 | } 35 | }, 0); 36 | }); 37 | } 38 | -------------------------------------------------------------------------------- /src/lib/webpage-icon-util.spec.ts: -------------------------------------------------------------------------------- 1 | /* Copyright 2021 Google LLC. 2 | SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | jest.mock('./image-info-util'); 5 | 6 | import { getImageInfo } from './image-info-util'; 7 | import { createNode } from './test-util'; 8 | import { getWebsiteIcon } from './webpage-icon-util'; 9 | 10 | function createDocumentWithHead(headElements: TreeNode[]) { 11 | const document = new Document(); 12 | 13 | document.appendChild( 14 | createNode({ 15 | name: 'html', 16 | children: [ 17 | { 18 | name: 'head', 19 | children: headElements, 20 | }, 21 | ], 22 | }), 23 | ); 24 | 25 | return document; 26 | } 27 | 28 | describe('getWebsiteIcon', function () { 29 | beforeEach(() => { 30 | (getImageInfo as jest.Mock).mockImplementation(src => { 31 | return Promise.resolve({ 32 | src: `http://localhost/${src}`, 33 | width: 100, 34 | height: 100, 35 | }); 36 | }); 37 | }); 38 | 39 | afterEach(() => { 40 | jest.clearAllMocks(); 41 | }); 42 | 43 | it('should return empty string when no icon is found', async function () { 44 | const document = createDocumentWithHead([]); 45 | await getWebsiteIcon(document); 46 | expect(getImageInfo).not.toBeCalled(); 47 | }); 48 | 49 | it('should return data uri when icon is found', async function () { 50 | const document = createDocumentWithHead([ 51 | { 52 | name: 'link', 53 | attributes: { rel: 'icon', href: 'icon.png' }, 54 | }, 55 | ]); 56 | const result = await getWebsiteIcon(document); 57 | expect(getImageInfo).toBeCalledWith('icon.png'); 58 | expect(result).toEqual({ src: 'http://localhost/icon.png', width: 100, height: 100 }); 59 | }); 60 | 61 | it('should return data apple-touch-icon when multiple icons are present', async function () { 62 | const document = createDocumentWithHead([ 63 | { 64 | name: 'link', 65 | attributes: { rel: 'icon', href: 'icon.png' }, 66 | }, 67 | { 68 | name: 'link', 69 | attributes: { rel: 'apple-touch-icon', href: 'apple-touch-icon.png' }, 70 | }, 71 | { 72 | name: 'link', 73 | attributes: { rel: 'shortcut icon', href: 'shortcut icon.png' }, 74 | }, 75 | ]); 76 | const result = await getWebsiteIcon(document); 77 | expect(getImageInfo).toBeCalledWith('apple-touch-icon.png'); 78 | expect(result).toEqual({ src: 'http://localhost/apple-touch-icon.png', width: 100, height: 100 }); 79 | }); 80 | 81 | it('should return data shortcut icon when multiple icons are present (excluding apple-touch-icon)', async function () { 82 | const document = createDocumentWithHead([ 83 | { 84 | name: 'link', 85 | attributes: { rel: 'icon', href: 'icon.png' }, 86 | }, 87 | { 88 | name: 'link', 89 | attributes: { rel: 'shortcut icon', href: 'shortcut icon.png' }, 90 | }, 91 | ]); 92 | const result = await getWebsiteIcon(document); 93 | expect(getImageInfo).toBeCalledWith('shortcut icon.png'); 94 | expect(result).toEqual({ src: 'http://localhost/shortcut icon.png', width: 100, height: 100 }); 95 | }); 96 | }); 97 | -------------------------------------------------------------------------------- /src/lib/webpage-icon-util.ts: -------------------------------------------------------------------------------- 1 | /* Copyright 2021 Google LLC. 2 | SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | import { getImageInfo, ImageInfo } from './image-info-util'; 5 | 6 | export async function getWebsiteIcon(document: Document): Promise { 7 | const linkElement = 8 | document.querySelector('link[rel="apple-touch-icon"]') ?? 9 | document.querySelector('link[rel="shortcut icon"]') ?? 10 | document.querySelector('link[rel="icon"]'); 11 | 12 | if (linkElement) { 13 | const href = linkElement.getAttribute('href'); 14 | 15 | if (href) { 16 | return await getImageInfo(href); 17 | } 18 | } 19 | 20 | return null; 21 | } 22 | -------------------------------------------------------------------------------- /src/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Form Troubleshooter", 3 | "description": "Find and fix common form problems.", 4 | "version": "0.0.0", 5 | "manifest_version": 3, 6 | "background": { 7 | "service_worker": "background.js" 8 | }, 9 | "content_scripts": [ 10 | { 11 | "matches": ["*://*/*"], 12 | "js": ["lib/content-script.js"], 13 | "css": ["css/highlight.css"], 14 | "all_frames": true 15 | } 16 | ], 17 | "web_accessible_resources": [ 18 | { 19 | "resources": ["css/highlight.css"], 20 | "matches": ["*://*/*"] 21 | } 22 | ], 23 | "permissions": ["storage"], 24 | "action": { 25 | "default_popup": "index.html", 26 | "default_icon": { 27 | "32": "/images/icons/icon32.png", 28 | "48": "/images/icons/icon48.png", 29 | "128": "/images/icons/icon128.png" 30 | } 31 | }, 32 | "icons": { 33 | "32": "/images/icons/icon32.png", 34 | "48": "/images/icons/icon48.png", 35 | "128": "/images/icons/icon128.png" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/module.ts: -------------------------------------------------------------------------------- 1 | /* Copyright 2021 Google LLC. 2 | SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | import { getTreeNodeWithParents } from './lib/tree-util'; 5 | import { runAudits } from './lib/audits/audits'; 6 | import { makeAuditDetailsSerializable } from './lib/audits/audit-util'; 7 | 8 | export function audit(tree: TreeNode): SerializableAuditDetails { 9 | return makeAuditDetailsSerializable(runAudits(getTreeNodeWithParents(tree))); 10 | } 11 | -------------------------------------------------------------------------------- /src/routes/details/index.tsx: -------------------------------------------------------------------------------- 1 | /* Copyright 2021 Google LLC. 2 | SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | import { Fragment, FunctionalComponent, h } from 'preact'; 5 | import Table from '../../components/table'; 6 | import { stringifyFormElement } from '../../lib/audits/audit-util'; 7 | import { closestParent, findDescendants, getTextContent } from '../../lib/tree-util'; 8 | import { 9 | handleHighlightClick, 10 | handleHighlightMouseEnter, 11 | handleHighlightMouseLeave, 12 | } from '../../lib/element-highlighter'; 13 | import style from './style.css'; 14 | import { groupBy } from '../../lib/array-util'; 15 | import Score from '../../components/summary/score'; 16 | import { runAudits } from '../../lib/audits/audits'; 17 | 18 | interface Props { 19 | documentTree: TreeNodeWithParent | undefined; 20 | } 21 | 22 | const Details: FunctionalComponent = props => { 23 | const { documentTree: tree } = props; 24 | let forms: Map> = new Map(); 25 | const formSections = [ 26 | { 27 | name: 'Inputs', 28 | type: 'input', 29 | toItem(item: TreeNodeWithParent) { 30 | return { 31 | ...item.attributes, 32 | required: item.attributes.required !== undefined ? 'true' : '', 33 | _field: item, 34 | }; 35 | }, 36 | columns: ['id', 'name', 'class', 'type', 'autocomplete', 'placeholder', 'required'], 37 | }, 38 | { 39 | name: 'Selects', 40 | type: 'select', 41 | toItem(item: TreeNodeWithParent) { 42 | return { 43 | ...item.attributes, 44 | required: item.attributes.required !== undefined ? 'true' : '', 45 | _field: item, 46 | }; 47 | }, 48 | columns: ['id', 'name', 'class', 'placeholder', 'required'], 49 | }, 50 | { 51 | name: 'Text areas', 52 | type: 'textarea', 53 | toItem(item: TreeNodeWithParent) { 54 | return { 55 | ...item.attributes, 56 | required: item.attributes.required !== undefined ? 'true' : '', 57 | _field: item, 58 | }; 59 | }, 60 | columns: ['id', 'name', 'class', 'autocomplete', 'placeholder', 'required'], 61 | }, 62 | { 63 | name: 'Buttons', 64 | type: 'button', 65 | toItem(item: TreeNodeWithParent) { 66 | return { 67 | ...item.attributes, 68 | text: getTextContent(item), 69 | _field: item, 70 | }; 71 | }, 72 | columns: ['id', 'name', 'class', 'text', 'type'], 73 | }, 74 | { 75 | name: 'Labels', 76 | type: 'label', 77 | toItem(item: TreeNodeWithParent) { 78 | return { 79 | ...item.attributes, 80 | text: getTextContent(item), 81 | _field: item, 82 | }; 83 | }, 84 | columns: ['id', 'name', 'class', 'for', 'text'], 85 | }, 86 | ]; 87 | 88 | if (tree) { 89 | // find forms by section types 90 | forms = new Map( 91 | Array.from( 92 | groupBy( 93 | findDescendants( 94 | tree, 95 | formSections.map(section => section.type), 96 | ), 97 | item => closestParent(item, 'form'), 98 | ).entries(), 99 | ).map(([form, fields]) => [form, groupBy(fields, field => field.name!)]), 100 | ); 101 | 102 | // find forms that don't have elements 103 | const allForms = findDescendants(tree, ['form']); 104 | allForms.forEach(form => { 105 | if (!forms.has(form)) { 106 | forms.set(form, new Map()); 107 | } 108 | }); 109 | } 110 | 111 | return ( 112 |
113 | {Array.from(forms.entries()) 114 | .sort(([key1], [key2]) => (`${key1}` < `${key2}` ? -1 : 0)) 115 | .map(([form, fieldsMap], formIndex) => ( 116 | 117 | {form ? ( 118 |

119 | {form.parent ? ( 120 | 121 | ) : null} 122 | Form:{' '} 123 | { 125 | handleHighlightClick(form); 126 | }} 127 | onMouseEnter={() => { 128 | handleHighlightMouseEnter(form); 129 | }} 130 | onMouseLeave={() => { 131 | handleHighlightMouseLeave(form); 132 | }} 133 | > 134 | {stringifyFormElement(form)} 135 | 136 |

137 | ) : ( 138 |

139 | Elements not in a <form> 140 |

141 | )} 142 | {formSections.map((section, sectionIndex) => { 143 | const items = fieldsMap.get(section.type)?.map(field => section.toItem(field)); 144 | if (!items?.length) { 145 | return null; 146 | } 147 | 148 | return ( 149 | 150 |

151 | {section.name} ({items.length}) 152 |

153 | { 157 | handleHighlightClick(item._field); 158 | }} 159 | onRowEnter={(row, item) => { 160 | handleHighlightMouseEnter(item._field); 161 | }} 162 | onRowLeave={(row, item) => { 163 | handleHighlightMouseLeave(item._field); 164 | }} 165 | /> 166 | 167 | ); 168 | })} 169 | 170 | ))} 171 | 172 | ); 173 | }; 174 | 175 | export default Details; 176 | -------------------------------------------------------------------------------- /src/routes/details/style.css: -------------------------------------------------------------------------------- 1 | /* Copyright 2021 Google LLC. 2 | SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | .details { 5 | margin-top: -15px; 6 | min-height: 100%; 7 | width: 100%; 8 | } 9 | 10 | .details table { 11 | border-spacing: 0; 12 | border-collapse: collapse; 13 | } 14 | 15 | .details table > tbody > tr { 16 | cursor: pointer; 17 | } 18 | 19 | .details table > tbody > tr:hover { 20 | background-color: #fafafa; 21 | } 22 | 23 | .details th, 24 | .details td { 25 | border: 1px solid #eee; 26 | padding: 5px 10px; 27 | } 28 | 29 | .details th { 30 | background-color: #eee; 31 | text-align: left; 32 | padding: 5px; 33 | } 34 | 35 | .details td:empty:after { 36 | color: #999; 37 | } 38 | 39 | .details td:empty:after { 40 | content: '-'; 41 | } 42 | 43 | .details h3 { 44 | margin-block-end: 0.5em; 45 | } 46 | 47 | .details h4 { 48 | margin-block-end: 0.5em; 49 | } 50 | 51 | .details h3 + h4 { 52 | margin-block-start: 0.5em; 53 | } 54 | 55 | .details table + h3 { 56 | margin-block-start: 1.5em; 57 | } 58 | 59 | .miniScore { 60 | margin-right: 0.3rem; 61 | } 62 | -------------------------------------------------------------------------------- /src/routes/notfound/index.tsx: -------------------------------------------------------------------------------- 1 | /* Copyright 2021 Google LLC. 2 | SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | import { FunctionalComponent, h } from 'preact'; 5 | import { Link } from 'preact-router/match'; 6 | import style from './style.css'; 7 | 8 | const Notfound: FunctionalComponent = () => { 9 | return ( 10 |
11 |

Error 404

12 |

That page doesn't exist.

13 | 14 |

Back to Home

15 | 16 |
17 | ); 18 | }; 19 | 20 | export default Notfound; 21 | -------------------------------------------------------------------------------- /src/routes/notfound/style.css: -------------------------------------------------------------------------------- 1 | /* Copyright 2021 Google LLC. 2 | SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | .notfound { 5 | padding: 0 5%; 6 | margin: 100px 0; 7 | } 8 | -------------------------------------------------------------------------------- /src/routes/results/index.tsx: -------------------------------------------------------------------------------- 1 | /* Copyright 2021 Google LLC. 2 | SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | import { Fragment, FunctionalComponent, h } from 'preact'; 5 | import ResultItem from './result-item'; 6 | import style from './style.css'; 7 | 8 | interface Props { 9 | results: AuditResult[]; 10 | onRender?: () => void; 11 | } 12 | 13 | const Results: FunctionalComponent = props => { 14 | const { results } = props; 15 | const element = ( 16 |
17 | {results.length ? ( 18 |
    19 | {results.map((result, index) => ( 20 |
  • 21 | 22 |
  • 23 | ))} 24 |
25 | ) : ( 26 | 27 |

Looking good

28 |

There are no issues with this page.

29 |
30 | )} 31 |
32 | ); 33 | 34 | props.onRender?.(); 35 | 36 | return element; 37 | }; 38 | 39 | export default Results; 40 | -------------------------------------------------------------------------------- /src/routes/results/style.css: -------------------------------------------------------------------------------- 1 | /* Copyright 2021 Google LLC. 2 | SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | .results { 5 | margin-top: -15px; 6 | min-height: 100%; 7 | width: 100%; 8 | } 9 | 10 | .audit { 11 | padding: 0; 12 | margin: 0; 13 | } 14 | 15 | .audit > li { 16 | list-style: none; 17 | } 18 | 19 | .details ul > li { 20 | line-height: 1.3rem; 21 | } 22 | 23 | .details > ul > li { 24 | margin-block-end: 0.5rem; 25 | } 26 | 27 | .learnMore::before { 28 | content: '⇒'; 29 | padding: 0 5px 0 0; 30 | position: relative; 31 | top: -1px; 32 | } 33 | -------------------------------------------------------------------------------- /src/style/index.css: -------------------------------------------------------------------------------- 1 | /* Copyright 2021 Google LLC. 2 | SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | html, 5 | body { 6 | width: 100%; 7 | padding: 0; 8 | margin: 0; 9 | font-family: 'Helvetica Neue', arial, sans-serif; 10 | font-weight: 400; 11 | color: #444; 12 | -webkit-font-smoothing: antialiased; 13 | -moz-osx-font-smoothing: grayscale; 14 | font-size: 100%; 15 | } 16 | 17 | * { 18 | box-sizing: border-box; 19 | } 20 | 21 | a { 22 | color: #3740ff; 23 | cursor: pointer; 24 | text-decoration: none; 25 | } 26 | 27 | a:hover { 28 | text-decoration: underline; 29 | } 30 | 31 | code { 32 | font-family: 'Consolas', 'Roboto Mono', monospace; 33 | background-color: #eee; 34 | border-radius: 0.2rem; 35 | padding: 0.05rem 0.2rem; 36 | } 37 | 38 | #preact_root { 39 | min-width: 770px; 40 | min-height: 400px; 41 | } 42 | 43 | .deemphasise { 44 | font-weight: normal; 45 | font-size: 90%; 46 | opacity: 0.7; 47 | } 48 | -------------------------------------------------------------------------------- /src/sw.js: -------------------------------------------------------------------------------- 1 | /* Copyright 2021 Google LLC. 2 | SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | import { getFiles, setupPrecaching, setupRouting } from 'preact-cli/sw/'; 5 | 6 | setupRouting(); 7 | setupPrecaching(getFiles()); 8 | -------------------------------------------------------------------------------- /src/template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | <% preact.title %> 6 | 7 | <% preact.headEnd %> 8 | 9 | 10 | <% preact.bodyEnd %> 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/test-data/score.json: -------------------------------------------------------------------------------- 1 | { 2 | "children": [ 3 | { 4 | "children": [ 5 | { "name": "head" }, 6 | { 7 | "children": [ 8 | { "children": [{ "text": "Login (no matching for)" }], "name": "h1" }, 9 | { 10 | "attributes": { "method": "POST" }, 11 | "children": [ 12 | { 13 | "children": [ 14 | { 15 | "attributes": { "for": "username_missing" }, 16 | "children": [{ "text": "Username/email" }], 17 | "name": "label" 18 | }, 19 | { 20 | "attributes": { 21 | "autocomplete": "username", 22 | "id": "username", 23 | "name": "username", 24 | "required": "", 25 | "title": "overall type: HTML_TYPE_EMAIL\nserver type: NO_SERVER_DATA\nheuristic type: UNKNOWN_TYPE\nlabel: Username/email\nparseable name: username\nsection: username_1-default\nfield signature: 239111655\nform signature: 3283463994967116017\nform frame token: 0799792EC9F0A647E27F2D4F291D71DB\nfield frame token: 0799792EC9F0A647E27F2D4F291D71DB\nform renderer id: 30\nfield renderer id: 81", 26 | "type": "text" 27 | }, 28 | "name": "input" 29 | } 30 | ], 31 | "name": "div" 32 | }, 33 | { 34 | "children": [ 35 | { 36 | "attributes": { "for": "password_missing" }, 37 | "children": [{ "text": "Password" }], 38 | "name": "label" 39 | }, 40 | { 41 | "attributes": { 42 | "autocomplete": "current-password", 43 | "id": "password", 44 | "name": "password", 45 | "required": "", 46 | "title": "overall type: UNKNOWN_TYPE\nserver type: NO_SERVER_DATA\nheuristic type: UNKNOWN_TYPE\nlabel: Password\nparseable name: password\nsection: username_1-default\nfield signature: 2051817934\nform signature: 3283463994967116017\nform frame token: 0799792EC9F0A647E27F2D4F291D71DB\nfield frame token: 0799792EC9F0A647E27F2D4F291D71DB\nform renderer id: 30\nfield renderer id: 82", 47 | "type": "password" 48 | }, 49 | "name": "input" 50 | } 51 | ], 52 | "name": "div" 53 | }, 54 | { "children": [{ "text": "Login" }], "name": "button" } 55 | ], 56 | "name": "form" 57 | } 58 | ], 59 | "name": "body" 60 | } 61 | ], 62 | "name": "html" 63 | } 64 | ] 65 | } 66 | -------------------------------------------------------------------------------- /tests/__mocks__/browserMocks.ts: -------------------------------------------------------------------------------- 1 | /* Copyright 2021 Google LLC. 2 | SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | // Mock Browser API's which are not supported by JSDOM, e.g. ServiceWorker, LocalStorage 5 | /** 6 | * An example how to mock localStorage is given below 👇 7 | */ 8 | 9 | /* 10 | // Mocks localStorage 11 | const localStorageMock = (function() { 12 | let store = {}; 13 | 14 | return { 15 | getItem: (key) => store[key] || null, 16 | setItem: (key, value) => store[key] = value.toString(), 17 | clear: () => store = {} 18 | }; 19 | 20 | })(); 21 | 22 | Object.defineProperty(window, 'localStorage', { 23 | value: localStorageMock 24 | }); */ 25 | -------------------------------------------------------------------------------- /tests/__mocks__/fileMocks.ts: -------------------------------------------------------------------------------- 1 | /* Copyright 2021 Google LLC. 2 | SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | // This fixed an error related to the CSS and loading gif breaking my Jest test 5 | // See https://facebook.github.io/jest/docs/en/webpack.html#handling-static-assets 6 | export default 'test-file-stub'; 7 | -------------------------------------------------------------------------------- /tests/__mocks__/setupTests.ts: -------------------------------------------------------------------------------- 1 | /* Copyright 2021 Google LLC. 2 | SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | import 'regenerator-runtime/runtime'; 5 | import { configure } from 'enzyme'; 6 | import Adapter from 'enzyme-adapter-preact-pure'; 7 | 8 | configure({ 9 | adapter: new Adapter(), 10 | }); 11 | -------------------------------------------------------------------------------- /tests/code-wrap.test.tsx: -------------------------------------------------------------------------------- 1 | /* Copyright 2021 Google LLC. 2 | SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | import { h } from 'preact'; 5 | // See: https://github.com/preactjs/enzyme-adapter-preact-pure 6 | import { configure, shallow, ShallowWrapper } from 'enzyme'; 7 | import CodeWrap from '../src/components/code-wrap'; 8 | import Adapter from 'enzyme-adapter-preact-pure'; 9 | import { ReactElement } from 'react'; 10 | 11 | configure({ adapter: new Adapter() }); 12 | 13 | function getCodeInfo(context: ShallowWrapper) { 14 | return context 15 | .find('code') 16 | .children() 17 | .map(elem => ({ text: elem.text(), type: elem.type() })); 18 | } 19 | 20 | describe('CodeWrap', function () { 21 | test('Wraps a string in code', function () { 22 | const context = shallow(() as unknown as ReactElement); 23 | expect(getCodeInfo(context)).toEqual([{ text: 'hello' }]); 24 | }); 25 | 26 | test('Wraps a string in code when text not found', function () { 27 | const context = shallow(() as unknown as ReactElement); 28 | expect(getCodeInfo(context)).toEqual([{ text: 'hello' }]); 29 | }); 30 | 31 | test('Wraps a string in code with emphasis (beginning of text)', function () { 32 | const context = shallow(() as unknown as ReactElement); 33 | expect(getCodeInfo(context)).toEqual([{ text: 'hello', type: 'strong' }, { text: ' world' }]); 34 | }); 35 | 36 | test('Wraps a string in code with emphasis (middle of text)', function () { 37 | const context = shallow(() as unknown as ReactElement); 38 | expect(getCodeInfo(context)).toEqual([{ text: 'hel' }, { text: 'lo wo', type: 'strong' }, { text: 'rld' }]); 39 | }); 40 | 41 | test('Wraps a string in code with emphasis (end of text)', function () { 42 | const context = shallow(() as unknown as ReactElement); 43 | expect(getCodeInfo(context)).toEqual([{ text: 'hello ' }, { text: 'world', type: 'strong' }]); 44 | }); 45 | 46 | test('Wraps a string in code with emphasis on first instances', function () { 47 | const context = shallow(() as unknown as ReactElement); 48 | expect(getCodeInfo(context)).toEqual([{ text: 'hello', type: 'strong' }, { text: ' world hello hello' }]); 49 | }); 50 | 51 | test('Wraps a string in code with emphasis on all instances', function () { 52 | const context = shallow( 53 | () as unknown as ReactElement, 54 | ); 55 | expect(getCodeInfo(context)).toEqual([ 56 | { text: 'hello', type: 'strong' }, 57 | { text: ' world ' }, 58 | { text: 'hello', type: 'strong' }, 59 | { text: ' ' }, 60 | { text: 'hello', type: 'strong' }, 61 | ]); 62 | }); 63 | 64 | test('Wraps a string in code with emphasis on a full regular expression', function () { 65 | const context = shallow( 66 | ( 67 | 68 | ) as unknown as ReactElement, 69 | ); 70 | expect(getCodeInfo(context)).toEqual([ 71 | { text: '' }, 74 | ]); 75 | }); 76 | 77 | test('Wraps a string in code with emphasis on the first capture group of a regular expression', function () { 78 | const context = shallow( 79 | ( 80 | 84 | ) as unknown as ReactElement, 85 | ); 86 | expect(getCodeInfo(context)).toEqual([ 87 | { text: '' }, 90 | ]); 91 | }); 92 | 93 | test('Wraps a string in code with emphasis on the first capture group of a global regular expression', function () { 94 | const context = shallow( 95 | ( 96 | 97 | ) as unknown as ReactElement, 98 | ); 99 | expect(getCodeInfo(context)).toEqual([ 100 | { text: '' }, 105 | ]); 106 | }); 107 | 108 | test('Wraps a string in code with multiple words to emphasize', function () { 109 | const context = shallow( 110 | () as unknown as ReactElement, 111 | ); 112 | expect(getCodeInfo(context)).toEqual([ 113 | { text: 'hello', type: 'strong' }, 114 | { text: ' ' }, 115 | { text: 'world', type: 'strong' }, 116 | ]); 117 | }); 118 | 119 | test('Wraps a string in code with multiple adjacent words to emphasize', function () { 120 | const context = shallow(() as unknown as ReactElement); 121 | expect(getCodeInfo(context)).toEqual([{ text: 'helloworld', type: 'strong' }]); 122 | }); 123 | 124 | test('Wraps a string in code with multiple words to emphasize with one word sharing a prefix with another', function () { 125 | const context = shallow(() as unknown as ReactElement); 126 | expect(getCodeInfo(context)).toEqual([{ text: 'hello', type: 'strong' }, { text: ' world' }]); 127 | }); 128 | 129 | test('Wraps a string in code with multiple words to emphasize with one word sharing a suffix with another', function () { 130 | const context = shallow(() as unknown as ReactElement); 131 | expect(getCodeInfo(context)).toEqual([{ text: 'hello', type: 'strong' }, { text: ' world' }]); 132 | }); 133 | 134 | test('Wraps a string in code with multiple words to emphasize with one word sharing a prefix with another, and one being global', function () { 135 | const context = shallow( 136 | () as unknown as ReactElement, 137 | ); 138 | expect(getCodeInfo(context)).toEqual([ 139 | { text: 'hello', type: 'strong' }, 140 | { text: ' world, ' }, 141 | { text: 'hell', type: 'strong' }, 142 | { text: 'o, ' }, 143 | { text: 'hell', type: 'strong' }, 144 | { text: 'o, ' }, 145 | { text: 'hell', type: 'strong' }, 146 | { text: 'o' }, 147 | ]); 148 | }); 149 | 150 | describe('Attribute highlighting', function () { 151 | let expression: RegExp; 152 | 153 | beforeEach(() => { 154 | expression = / (link)((?==)|(?=[^"]*$)|(?![^"]+(?'} emphasize={expression} />) as unknown as ReactElement, 160 | ); 161 | expect(getCodeInfo(context)).toEqual([ 162 | { text: '' }, 165 | ]); 166 | }); 167 | 168 | test('Wraps an attribute without a value at the end of a tag', function () { 169 | const context = shallow( 170 | ('} emphasize={expression} />) as unknown as ReactElement, 171 | ); 172 | expect(getCodeInfo(context)).toEqual([ 173 | { text: '' }, 176 | ]); 177 | }); 178 | 179 | test('Wraps an attribute without a value with ...', function () { 180 | const context = shallow( 181 | ('} emphasize={expression} />) as unknown as ReactElement, 182 | ); 183 | expect(getCodeInfo(context)).toEqual([ 184 | { text: '' }, 187 | ]); 188 | }); 189 | 190 | test('Wraps an attribute without a value with other attributes trailing', function () { 191 | const context = shallow( 192 | ( 193 | '} emphasize={expression} /> 194 | ) as unknown as ReactElement, 195 | ); 196 | expect(getCodeInfo(context)).toEqual([ 197 | { text: '' }, 200 | ]); 201 | }); 202 | }); 203 | }); 204 | -------------------------------------------------------------------------------- /tests/declarations.d.ts: -------------------------------------------------------------------------------- 1 | /* Copyright 2021 Google LLC. 2 | SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | /* eslint-disable spaced-comment */ 5 | // Enable enzyme adapter's integration with TypeScript 6 | // See: https://github.com/preactjs/enzyme-adapter-preact-pure#usage-with-typescript 7 | /// 8 | -------------------------------------------------------------------------------- /tests/header.test.tsx: -------------------------------------------------------------------------------- 1 | /* Copyright 2021 Google LLC. 2 | SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | import { h } from 'preact'; 5 | import Header from '../src/components/header'; 6 | // See: https://github.com/preactjs/enzyme-adapter-preact-pure 7 | import { render } from '@testing-library/preact'; 8 | 9 | describe('Initial Test of the Header', function () { 10 | test('Header renders', function () { 11 | const dom = render(
); 12 | expect(dom.container.getElementsByTagName('header')[0].textContent).toBe('Form troubleshooter'); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | "target": "ESNEXT" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', or 'ESNEXT'. */, 5 | "module": "ESNext" /* Specify module code generation: 'none', commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */, 6 | // "lib": [], /* Specify library files to be included in the compilation: */ 7 | "allowJs": true /* Allow javascript files to be compiled. */, 8 | // "checkJs": true, /* Report errors in .js files. */ 9 | "jsx": "react-jsx", 10 | "jsxImportSource": "preact", 11 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 12 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 13 | // "outFile": "./", /* Concatenate and emit output to single file. */ 14 | // "outDir": "./", /* Redirect output structure to the directory. */ 15 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 16 | // "removeComments": true, /* Do not emit comments to output. */ 17 | "noEmit": true /* Do not emit outputs. */, 18 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 19 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 20 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 21 | 22 | /* Strict Type-Checking Options */ 23 | "strict": true /* Enable all strict type-checking options. */, 24 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 25 | // "strictNullChecks": true, /* Enable strict null checks. */ 26 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 27 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 28 | 29 | /* Additional Checks */ 30 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 31 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 32 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 33 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 34 | 35 | /* Module Resolution Options */ 36 | "moduleResolution": "node" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */, 37 | "esModuleInterop": true /* */, 38 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 39 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 40 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 41 | // "typeRoots": [], /* List of folders to include type definitions from. */ 42 | // "types": [], /* Type declaration files to be included in compilation. */ 43 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 44 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 45 | 46 | /* Source Map Options */ 47 | // "sourceRoot": "./", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 48 | // "mapRoot": "./", /* Specify the location where debugger should locate map files instead of generated locations. */ 49 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 50 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 51 | 52 | /* Experimental Options */ 53 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 54 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 55 | 56 | "resolveJsonModule": true, 57 | 58 | /* Advanced Options */ 59 | "skipLibCheck": true /* Skip type checking of declaration files. */ 60 | }, 61 | "include": ["src/**/*", "tests/**/*"], 62 | "exclude": ["cli"] 63 | } 64 | --------------------------------------------------------------------------------