├── .eslintignore ├── src ├── lib │ ├── options.ts │ ├── webpage-icon-util.ts │ ├── image-info-util.ts │ ├── array-util.ts │ ├── audits │ │ ├── forms.ts │ │ ├── audits.ts │ │ ├── audit-util.ts │ │ ├── forms.spec.ts │ │ ├── inputs.ts │ │ ├── audit-util.spec.ts │ │ ├── attributes.ts │ │ ├── autocomplete.ts │ │ ├── inputs.spec.ts │ │ ├── labels.ts │ │ └── autocomplete.spec.ts │ ├── array-util.spec.ts │ ├── wait-util.ts │ ├── messaging-util.ts │ ├── string-util.ts │ ├── element-highlighter.ts │ ├── save-html.ts │ ├── test-util.ts │ ├── types.d.ts │ ├── save-html.spec.ts │ ├── webpage-icon-util.spec.ts │ ├── overlay.ts │ ├── content-script.ts │ ├── string-util.spec.ts │ ├── dom-iterator.ts │ ├── constants.ts │ ├── dom-iterator.spec.ts │ └── tree-util.ts ├── images │ └── icons │ │ ├── icon128.png │ │ ├── icon16.png │ │ ├── icon32.png │ │ ├── icon48.png │ │ └── icon128-monochrome.png ├── routes │ ├── notfound │ │ ├── style.css │ │ └── index.tsx │ ├── results │ │ ├── style.css │ │ └── index.tsx │ └── details │ │ ├── style.css │ │ └── index.tsx ├── index.ts ├── sw.js ├── declaration.d.ts ├── template.html ├── module.ts ├── components │ ├── app.css │ ├── code-wrap.css │ ├── report │ │ ├── style.css │ │ └── index.tsx │ ├── header │ │ ├── style.css │ │ └── index.tsx │ ├── summary │ │ ├── style.css │ │ ├── index.tsx │ │ └── score.tsx │ ├── table │ │ └── index.tsx │ ├── code-wrap.tsx │ └── app.tsx ├── background.ts ├── style │ └── index.css ├── manifest.json ├── css │ └── highlight.css └── test-data │ └── score.json ├── images ├── promo │ ├── large-promo.png │ ├── small-promo.png │ ├── github-social.png │ └── marquee-promo.png ├── screenshots │ ├── screenshot-errors.png │ ├── screenshot-initial.png │ ├── screenshot-overview.png │ ├── screenshot-load-unpacked.png │ └── screenshot-pin-extension.png ├── icon-small.svg └── icon.svg ├── .stylelintrc ├── .prettierignore ├── .github ├── release-please.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── build.yml │ ├── bump.yml │ └── publish.yml ├── .editorconfig ├── .gitignore ├── .prettierrc.yml ├── tests ├── __mocks__ │ ├── fileMocks.ts │ ├── setupTests.ts │ └── browserMocks.ts ├── declarations.d.ts ├── header.test.tsx └── code-wrap.test.tsx ├── scripts ├── version.js ├── convert-to-pdf.sh ├── login.sh └── publish.sh ├── .vscode └── settings.json ├── cli ├── package.json ├── README.md └── index.js ├── .babelrc ├── docs ├── contributing.md └── code-of-conduct.md ├── .eslintrc ├── rollup.config.js ├── package.json ├── tsconfig.json └── README.md /.eslintignore: -------------------------------------------------------------------------------- 1 | build/ 2 | dist/ 3 | scripts/version.js 4 | rollup.config.js 5 | -------------------------------------------------------------------------------- /src/lib/options.ts: -------------------------------------------------------------------------------- 1 | /* Copyright 2021 Google LLC. 2 | SPDX-License-Identifier: Apache-2.0 */ 3 | -------------------------------------------------------------------------------- /images/promo/large-promo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleChromeLabs/form-troubleshooter/main/images/promo/large-promo.png -------------------------------------------------------------------------------- /images/promo/small-promo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleChromeLabs/form-troubleshooter/main/images/promo/small-promo.png -------------------------------------------------------------------------------- /src/images/icons/icon128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleChromeLabs/form-troubleshooter/main/src/images/icons/icon128.png -------------------------------------------------------------------------------- /src/images/icons/icon16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleChromeLabs/form-troubleshooter/main/src/images/icons/icon16.png -------------------------------------------------------------------------------- /src/images/icons/icon32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleChromeLabs/form-troubleshooter/main/src/images/icons/icon32.png -------------------------------------------------------------------------------- /src/images/icons/icon48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleChromeLabs/form-troubleshooter/main/src/images/icons/icon48.png -------------------------------------------------------------------------------- /images/promo/github-social.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleChromeLabs/form-troubleshooter/main/images/promo/github-social.png -------------------------------------------------------------------------------- /images/promo/marquee-promo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleChromeLabs/form-troubleshooter/main/images/promo/marquee-promo.png -------------------------------------------------------------------------------- /.stylelintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "stylelint-config-standard", 3 | "rules": { 4 | "no-descending-specificity": null 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /images/screenshots/screenshot-errors.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleChromeLabs/form-troubleshooter/main/images/screenshots/screenshot-errors.png -------------------------------------------------------------------------------- /src/images/icons/icon128-monochrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleChromeLabs/form-troubleshooter/main/src/images/icons/icon128-monochrome.png -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /images/screenshots/screenshot-initial.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleChromeLabs/form-troubleshooter/main/images/screenshots/screenshot-initial.png -------------------------------------------------------------------------------- /images/screenshots/screenshot-overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleChromeLabs/form-troubleshooter/main/images/screenshots/screenshot-overview.png -------------------------------------------------------------------------------- /images/screenshots/screenshot-load-unpacked.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleChromeLabs/form-troubleshooter/main/images/screenshots/screenshot-load-unpacked.png -------------------------------------------------------------------------------- /images/screenshots/screenshot-pin-extension.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleChromeLabs/form-troubleshooter/main/images/screenshots/screenshot-pin-extension.png -------------------------------------------------------------------------------- /.github/release-please.yml: -------------------------------------------------------------------------------- 1 | # https://github.com/apps/release-please 2 | primaryBranch: main 3 | releaseType: node 4 | handleGHRelease: true 5 | bumpMinorPreMajor: false 6 | -------------------------------------------------------------------------------- /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/index.ts: -------------------------------------------------------------------------------- 1 | /* Copyright 2021 Google LLC. 2 | SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | import './style/index.css'; 5 | import App from './components/app'; 6 | 7 | export default App; 8 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /src/declaration.d.ts: -------------------------------------------------------------------------------- 1 | /* Copyright 2021 Google LLC. 2 | SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | declare module '*.css' { 5 | const mapping: Record; 6 | export default mapping; 7 | } 8 | 9 | declare module 'preact-cli/sw/' { 10 | export function setupRouting(); 11 | } 12 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | <% preact.title %> 6 | 7 | <% preact.headEnd %> 8 | 9 | 10 | <% preact.bodyEnd %> 11 | 12 | 13 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /scripts/version.js: -------------------------------------------------------------------------------- 1 | /* Copyright 2021 Google LLC. 2 | SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | const path = require('path'); 5 | let packageFile = '../package.json'; 6 | 7 | if (process.argv[2]) { 8 | packageFile = path.join(process.cwd(), process.argv[2]); 9 | } 10 | 11 | const packageInfo = require(packageFile); 12 | 13 | console.log(packageInfo.version); 14 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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/components/app.css: -------------------------------------------------------------------------------- 1 | /* Copyright 2021 Google LLC. 2 | SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | .tabWrapper { 5 | border-bottom: 1px solid #ccc; 6 | } 7 | 8 | .tabs { 9 | margin: 10px 10px -2px; 10 | } 11 | 12 | .tabs button { 13 | text-transform: none; 14 | font-weight: normal; 15 | } 16 | 17 | .content { 18 | padding: 20px; 19 | } 20 | 21 | .reportContainer { 22 | position: absolute; 23 | clip: rect(0, 0, 0, 0); 24 | } 25 | -------------------------------------------------------------------------------- /cli/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "form-troubleshooter-cli", 3 | "version": "0.0.1", 4 | "description": "Command line tool to find and fix common form problems", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "bin": { 10 | "form-audit": "./index.js" 11 | }, 12 | "author": "socsieng", 13 | "license": "Apache-2.0", 14 | "dependencies": { 15 | "form-troubleshooter": "file:..", 16 | "yargs": "^17.1.0" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/components/code-wrap.css: -------------------------------------------------------------------------------- 1 | /* Copyright 2021 Google LLC. 2 | SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | .code .emphasize { 5 | font-weight: bold; 6 | } 7 | 8 | .code code:not(:first-child) { 9 | padding-left: 0; 10 | border-top-left-radius: 0; 11 | border-bottom-left-radius: 0; 12 | } 13 | 14 | .code code:not(:last-child) { 15 | padding-right: 0; 16 | border-top-right-radius: 0; 17 | border-bottom-right-radius: 0; 18 | } 19 | /* 20 | .code code + code { 21 | padding: 0.05rem 0; 22 | border-radius: 0; 23 | } */ 24 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /images/icon-small.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/lib/image-info-util.ts: -------------------------------------------------------------------------------- 1 | /* Copyright 2021 Google LLC. 2 | SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | export interface ImageInfo { 5 | src: string; 6 | width: number; 7 | height: number; 8 | } 9 | 10 | export function getImageInfo(src: string): Promise { 11 | const image = document.createElement('img'); 12 | image.setAttribute('src', src); 13 | 14 | return new Promise((resolve, reject) => { 15 | image.onload = () => { 16 | resolve({ 17 | src: image.src, 18 | width: image.width, 19 | height: image.height, 20 | }); 21 | }; 22 | 23 | image.onerror = e => { 24 | reject(e); 25 | }; 26 | }); 27 | } 28 | -------------------------------------------------------------------------------- /src/background.ts: -------------------------------------------------------------------------------- 1 | /* Copyright 2021 Google LLC. 2 | SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | import { setupRouting } from 'preact-cli/sw/'; 5 | 6 | setupRouting(); 7 | 8 | chrome.runtime.onInstalled.addListener(() => { 9 | // console.log('Hi from `installed` event listener in background.js!'); 10 | }); 11 | 12 | chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { 13 | if (message.broadcast) { 14 | if (sender.tab?.id) { 15 | chrome.tabs.sendMessage(sender.tab.id, message, response => { 16 | if (message.wait) { 17 | sendResponse(response); 18 | } 19 | }); 20 | } 21 | } 22 | 23 | return message.wait; 24 | }); 25 | -------------------------------------------------------------------------------- /.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/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 | -------------------------------------------------------------------------------- /scripts/convert-to-pdf.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Copyright 2021 Google LLC. 3 | # SPDX-License-Identifier: Apache-2.0 4 | 5 | set -e 6 | 7 | cwd=`pwd` 8 | script_folder=`cd $(dirname $0) && pwd` 9 | 10 | html_files=("$@") 11 | 12 | for file in "${html_files[@]}" 13 | do 14 | pdf_file="${file%.*}.pdf" 15 | 16 | if [ -f "$file" ] 17 | then 18 | if [ -f "$pdf_file" ] 19 | then 20 | echo -e "\033[1;30m$pdf_file already exists, skipping\033[0m" 21 | else 22 | command="npx electron-pdf \"$file\" \"$pdf_file\" -w 100" 23 | 24 | echo "$command" 25 | eval "$command" 26 | echo "$pdf_file" 27 | fi 28 | else 29 | echo "$file not found" 30 | exit 1 31 | fi 32 | 33 | done 34 | -------------------------------------------------------------------------------- /src/components/report/style.css: -------------------------------------------------------------------------------- 1 | /* Copyright 2021 Google LLC. 2 | SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | .report { 5 | padding: 1rem 2rem; 6 | min-width: 1024px; 7 | } 8 | 9 | .report h1 { 10 | margin-block-end: 0; 11 | } 12 | 13 | .report h1 + p { 14 | margin-block-start: 0; 15 | } 16 | 17 | .report h2 { 18 | margin-block-start: 3rem; 19 | } 20 | 21 | .url { 22 | opacity: 0.8; 23 | font-size: 0.8rem; 24 | } 25 | 26 | .report .reportScore { 27 | margin: 0; 28 | margin-top: 3rem; 29 | } 30 | 31 | .results { 32 | margin: 2rem 0 2rem 2rem; 33 | } 34 | 35 | .title { 36 | display: flex; 37 | } 38 | 39 | .icon { 40 | width: 48px; 41 | height: 48px; 42 | display: block; 43 | margin-top: 1rem; 44 | margin-right: 0.5rem; 45 | } 46 | -------------------------------------------------------------------------------- /src/lib/array-util.ts: -------------------------------------------------------------------------------- 1 | /* Copyright 2021 Google LLC. 2 | SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | /** 5 | * Groups items in an array by the specified group by function. 6 | */ 7 | export function groupBy( 8 | array: T[], 9 | groupByFn: (item: T) => S = (item: T) => item as unknown as S, 10 | valueFn: (item: T) => U = (item: T) => item as unknown as U, 11 | ): Map { 12 | const map = new Map(); 13 | 14 | array.forEach(item => { 15 | const key = groupByFn(item); 16 | const value = valueFn(item); 17 | let items = map.get(key); 18 | if (items) { 19 | items.push(value); 20 | } else { 21 | items = [value]; 22 | map.set(key, items); 23 | } 24 | }); 25 | 26 | return map; 27 | } 28 | -------------------------------------------------------------------------------- /src/lib/audits/forms.ts: -------------------------------------------------------------------------------- 1 | /* Copyright 2021 Google LLC. 2 | SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | import { findDescendants } from '../tree-util'; 5 | 6 | const FORM_FIELDS = ['button', 'input', 'select', 'textarea']; 7 | 8 | /** 9 | * All form elements should contain at least one form field element. 10 | */ 11 | export function hasEmptyForms(tree: TreeNodeWithParent): AuditResult | undefined { 12 | const eligibleFields = findDescendants(tree, ['form']); 13 | const emptyForms = eligibleFields.filter(form => findDescendants(form, FORM_FIELDS).length === 0); 14 | 15 | if (emptyForms.length) { 16 | return { 17 | auditType: 'form-empty', 18 | items: emptyForms, 19 | score: 1 - emptyForms.length / eligibleFields.length, 20 | }; 21 | } 22 | } 23 | 24 | /** 25 | * Run all form audits. 26 | */ 27 | export const formAudits: AuditMetadata[] = [{ type: 'warning', weight: 1, audit: hasEmptyForms }]; 28 | -------------------------------------------------------------------------------- /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/components/header/style.css: -------------------------------------------------------------------------------- 1 | /* Copyright 2021 Google LLC. 2 | SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | .header { 5 | left: 0; 6 | top: 0; 7 | width: 100%; 8 | height: 56px; 9 | padding: 0; 10 | background: #fff; 11 | z-index: 50; 12 | display: flex; 13 | } 14 | 15 | .header h1 { 16 | float: left; 17 | flex-grow: 1; 18 | margin: 0; 19 | padding: 0 15px; 20 | font-size: 24px; 21 | line-height: 56px; 22 | font-weight: bold; 23 | color: #444; 24 | } 25 | 26 | .header nav { 27 | position: fixed; 28 | font-size: 100%; 29 | top: 5px; 30 | right: 5px; 31 | } 32 | 33 | .more { 34 | background-color: #fff; 35 | border-radius: 100px; 36 | } 37 | 38 | .menu .menuDivider { 39 | margin-top: 6px; 40 | border-top: 1px solid #00000044; 41 | padding: 12px 16px 3px; 42 | font-size: 90%; 43 | opacity: 0.5; 44 | cursor: default; 45 | } 46 | 47 | .menu .menuDivider:first-child { 48 | margin-top: 0; 49 | border-top: 0; 50 | } 51 | -------------------------------------------------------------------------------- /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/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/lib/array-util.spec.ts: -------------------------------------------------------------------------------- 1 | /* Copyright 2021 Google LLC. 2 | SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | import { groupBy } from './array-util'; 5 | 6 | describe('array-util', function () { 7 | describe('groupBy', function () { 8 | it('should group elements using grouping function', function () { 9 | const result = groupBy( 10 | [ 11 | { id: '1', value: 'a' }, 12 | { id: '1', value: 'b' }, 13 | { id: '2', value: 'c' }, 14 | ], 15 | item => item.id, 16 | ); 17 | expect(result.get('1')).toEqual([ 18 | { id: '1', value: 'a' }, 19 | { id: '1', value: 'b' }, 20 | ]); 21 | expect(result.get('2')).toEqual([{ id: '2', value: 'c' }]); 22 | }); 23 | 24 | it('should group primitives without grouping function', function () { 25 | const result = groupBy([1, 1, 1, 2, 3, 3]); 26 | expect(result.get(1)).toEqual([1, 1, 1]); 27 | expect(result.get(2)).toEqual([2]); 28 | expect(result.get(3)).toEqual([3, 3]); 29 | }); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /docs/contributing.md: -------------------------------------------------------------------------------- 1 | # How to Contribute 2 | 3 | We'd love to accept your patches and contributions to this project. There are just a few small guidelines you need to 4 | follow. 5 | 6 | ## Contributor License Agreement 7 | 8 | Contributions to this project must be accompanied by a Contributor License Agreement. You (or your employer) retain the 9 | copyright to your contribution; this simply gives us permission to use and redistribute your contributions as part of 10 | the project. Head over to to see your current agreements on file or to sign a new 11 | one. 12 | 13 | You generally only need to submit a CLA once, so if you've already submitted one (even if it was for a different 14 | project), you probably don't need to do it again. 15 | 16 | ## Code Reviews 17 | 18 | All submissions, including submissions by project members, require review. We use GitHub pull requests for this purpose. 19 | Consult [GitHub Help](https://help.github.com/articles/about-pull-requests/) for more information on using pull 20 | requests. 21 | 22 | ## Community Guidelines 23 | 24 | This project follows [Google's Open Source Community Guidelines](https://opensource.google/conduct/). 25 | -------------------------------------------------------------------------------- /images/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 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 | -------------------------------------------------------------------------------- /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/components/summary/style.css: -------------------------------------------------------------------------------- 1 | /* Copyright 2021 Google LLC. 2 | SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | .summary { 5 | display: flex; 6 | gap: 20px; 7 | align-items: center; 8 | margin: 10px 10px 10px 36px; 9 | } 10 | 11 | .score { 12 | font-size: 2rem; 13 | stroke: #ccc; 14 | stroke-linecap: round; 15 | } 16 | 17 | .score circle { 18 | transition: stroke 0.5s, stroke-dashoffset 0.5s; 19 | } 20 | 21 | .score text { 22 | font-size: 2rem; 23 | } 24 | 25 | .good circle { 26 | stroke: #137333; 27 | } 28 | 29 | .fair circle { 30 | stroke: #ea8600; 31 | } 32 | 33 | .bad circle { 34 | stroke: #cf221f; 35 | } 36 | 37 | .result dl { 38 | display: grid; 39 | width: auto; 40 | margin: 0 0 8px; 41 | grid-auto-flow: dense; 42 | } 43 | 44 | .result dl > dt, 45 | .result dl > dd { 46 | padding: 3px 5px; 47 | cursor: pointer; 48 | } 49 | 50 | .result dl > dt:hover, 51 | .result dl > dd:hover { 52 | text-decoration: underline; 53 | } 54 | 55 | .result dl > dt { 56 | grid-column: 2; 57 | justify-self: start; 58 | } 59 | 60 | .result dl > dd { 61 | grid-column: 1; 62 | justify-self: end; 63 | margin: 0; 64 | } 65 | 66 | .recommended { 67 | font-weight: bold; 68 | font-size: 1.3rem; 69 | } 70 | -------------------------------------------------------------------------------- /src/lib/messaging-util.ts: -------------------------------------------------------------------------------- 1 | /* Copyright 2021 Google LLC. 2 | SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | /* eslint-disable @typescript-eslint/no-explicit-any */ 5 | /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ 6 | 7 | let messageCounter = 0; 8 | const messageHandlers = new Map void>(); 9 | window.addEventListener('message', event => { 10 | if (event.data?.message === 'iframe message response') { 11 | const callback = messageHandlers.get(event.data?.messageId); 12 | if (callback) { 13 | messageHandlers.delete(event.data?.messageId); 14 | callback(event.data.data); 15 | } 16 | } 17 | }); 18 | export function sendMessageToIframe(frame: HTMLIFrameElement, message: any, timeoutDuration = 500): Promise { 19 | const messageId = ++messageCounter; 20 | frame.contentWindow?.postMessage( 21 | { 22 | message: 'iframe message', 23 | messageId, 24 | data: message, 25 | }, 26 | '*', 27 | ); 28 | return new Promise((resolve, reject) => { 29 | const timeout = setTimeout(() => { 30 | reject(new Error('Timeout duration exceeded')); 31 | }, timeoutDuration); 32 | messageHandlers.set(messageId, response => { 33 | clearTimeout(timeout); 34 | resolve(response); 35 | }); 36 | }); 37 | } 38 | -------------------------------------------------------------------------------- /scripts/login.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Copyright 2021 Google LLC. 3 | # SPDX-License-Identifier: Apache-2.0 4 | 5 | set -e 6 | 7 | # load .env variables if they exist 8 | if [ -f .env ] 9 | then 10 | set -o allexport 11 | source .env 12 | set +o allexport 13 | fi 14 | 15 | cwd=`pwd` 16 | script_folder=`cd $(dirname $0) && pwd` 17 | version=`node $script_folder/version.js` 18 | 19 | echo "{\"version\": \"$version\"}" > $script_folder/../manifest.version.json 20 | 21 | required_environment_variables=("CLIENT_ID" "CLIENT_SECRET") 22 | 23 | any_missing=0 24 | for variable in "${required_environment_variables[@]}" 25 | do 26 | val=`echo "${!variable}"` 27 | if [ -z "$val" ] 28 | then 29 | echo "$variable environment variable not present." 30 | any_missing=1 31 | fi 32 | done 33 | 34 | if [[ $any_missing -ne 0 ]] 35 | then 36 | exit 1 37 | fi 38 | 39 | login_url="https://accounts.google.com/o/oauth2/auth?response_type=code&scope=https://www.googleapis.com/auth/chromewebstore&client_id=$CLIENT_ID&redirect_uri=urn:ietf:wg:oauth:2.0:oob" 40 | 41 | echo "Login with $login_url" 42 | echo "After logging in, copy/paste the code below." 43 | read -p "Code: " code 44 | 45 | token=`curl "https://accounts.google.com/o/oauth2/token" -d "client_id=$CLIENT_ID&client_secret=$CLIENT_SECRET&code=$code&grant_type=authorization_code&redirect_uri=urn:ietf:wg:oauth:2.0:oob" 2>/dev/null` 46 | echo $token 47 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /src/css/highlight.css: -------------------------------------------------------------------------------- 1 | /* Copyright 2021 Google LLC. 2 | SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | #form-troubleshooter-highlight-click-overlay, 5 | #form-troubleshooter-highlight-hover-overlay { 6 | position: absolute; 7 | box-sizing: border-box; 8 | transition: top 0.2s, left 0.2s, height 0.2s, width 0.2s, opacity 0.2s; 9 | } 10 | 11 | #form-troubleshooter-highlight-click-overlay.in, 12 | #form-troubleshooter-highlight-hover-overlay.in { 13 | opacity: 1; 14 | clip: auto; 15 | } 16 | 17 | #form-troubleshooter-highlight-click-overlay.out, 18 | #form-troubleshooter-highlight-hover-overlay.out { 19 | opacity: 0; 20 | animation-duration: 0.2s; 21 | animation-name: overlay-disappear; 22 | clip: rect(0, 0, 0, 0); 23 | } 24 | 25 | @keyframes overlay-disappear { 26 | from { 27 | clip: auto; 28 | } 29 | 30 | to { 31 | clip: auto; 32 | } 33 | } 34 | 35 | #form-troubleshooter-highlight-click-overlay { 36 | outline: 2px solid #ff0000; 37 | outline-offset: 2px; 38 | z-index: 999999; 39 | } 40 | 41 | #form-troubleshooter-highlight-hover-overlay { 42 | background-color: #ff000011; 43 | z-index: 999998; 44 | transition: top 0.2s, left 0.2s, height 0.2s, width 0.2s, opacity 0.2s 0.2s; 45 | } 46 | 47 | #form-troubleshooter-highlight-click-overlay, 48 | #form-troubleshooter-highlight-hover-overlay { 49 | position: absolute; 50 | box-sizing: border-box; 51 | transition: top 0.2s, left 0.2s, height 0.2s, width 0.2s, opacity 0.2s; 52 | } 53 | -------------------------------------------------------------------------------- /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/components/summary/index.tsx: -------------------------------------------------------------------------------- 1 | /* Copyright 2021 Google LLC. 2 | SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | import { FunctionalComponent, h } from 'preact'; 5 | import style from './style.css'; 6 | import Score from './score'; 7 | import { pluralize } from '../../lib/string-util'; 8 | import { route } from 'preact-router'; 9 | 10 | interface Props { 11 | className?: string; 12 | score: number; 13 | recommendations: AuditResult[]; 14 | commonMistakes: AuditResult[]; 15 | } 16 | 17 | const AuditSummary: FunctionalComponent = (props: Props) => { 18 | const { className, score, recommendations, commonMistakes } = props; 19 | 20 | return ( 21 |
22 | 23 |
24 |
25 |
route('/recommendations')}> 26 | {pluralize(recommendations.length, 'recommendation')} 27 |
28 |
route('/recommendations')}> 29 | {recommendations.length} 30 |
31 |
route('/mistakes')}> 32 | common {pluralize(commonMistakes.length, 'mistake')} 33 |
34 |
route('/mistakes')}> 35 | {commonMistakes.length} 36 |
37 |
38 |
39 |
40 | ); 41 | }; 42 | 43 | export default AuditSummary; 44 | -------------------------------------------------------------------------------- /src/lib/element-highlighter.ts: -------------------------------------------------------------------------------- 1 | /* Copyright 2021 Google LLC. 2 | SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | import { getPath, pathToQuerySelector } from './tree-util'; 5 | 6 | let tabId: number; 7 | 8 | // Send a message to the content script to audit the current page. 9 | // Need to do this every time the popup is opened. 10 | chrome?.tabs?.query({ active: true, currentWindow: true }, function (tabs) { 11 | tabId = tabs[0].id!; 12 | 13 | window.addEventListener('blur', () => { 14 | clearHighlight('click'); 15 | clearHighlight('hover'); 16 | }); 17 | }); 18 | 19 | function showHighlight(selector: string, type: 'click' | 'hover', scrollIntoView: boolean): void { 20 | if (chrome?.tabs) { 21 | chrome.tabs.sendMessage(tabId, { message: 'highlight', selector, type, scroll: scrollIntoView }); 22 | } else { 23 | console.log('simulating highlight', { selector, type, scrollIntoView }); 24 | } 25 | } 26 | 27 | function clearHighlight(type: 'click' | 'hover'): void { 28 | if (chrome?.tabs) { 29 | chrome.tabs.sendMessage(tabId, { message: 'clear highlight', type }); 30 | } else { 31 | console.log('simulating clear highlight', { type }); 32 | } 33 | } 34 | 35 | export function handleHighlightClick(item: TreeNodeWithParent): void { 36 | const path = getPath(item); 37 | const selector = pathToQuerySelector(path); 38 | showHighlight(selector, 'click', true); 39 | } 40 | 41 | export function handleHighlightMouseEnter(item: TreeNodeWithParent): void { 42 | const path = getPath(item); 43 | const selector = pathToQuerySelector(path); 44 | showHighlight(selector, 'hover', false); 45 | } 46 | 47 | export function handleHighlightMouseLeave(item: TreeNodeWithParent): void { 48 | clearHighlight('hover'); 49 | } 50 | -------------------------------------------------------------------------------- /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/audits/audits.ts: -------------------------------------------------------------------------------- 1 | /* Copyright 2021 Google LLC. 2 | SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | import { attributeAudits } from './attributes'; 5 | import { autocompleteAudits } from './autocomplete'; 6 | import { formAudits } from './forms'; 7 | import { inputAudits } from './inputs'; 8 | import { labelAudits } from './labels'; 9 | 10 | interface AuditRun { 11 | audit: AuditMetadata; 12 | result?: AuditResult; 13 | } 14 | 15 | const scoreReducer = ({ score, max }: { score: number; max: number }, result: AuditRun) => ({ 16 | score: score + (result.result !== undefined ? result.result.score * result.audit.weight : result.audit.weight), 17 | max: max + result.audit.weight, 18 | }); 19 | 20 | export function runAudits(tree: TreeNodeWithParent): AuditDetails { 21 | const audits = [...attributeAudits, ...formAudits, ...autocompleteAudits, ...labelAudits, ...inputAudits]; 22 | const errorResults: AuditRun[] = []; 23 | const warningResults: AuditRun[] = []; 24 | 25 | audits 26 | .sort((a, b) => b.weight - a.weight) 27 | .forEach(audit => { 28 | const result = audit.audit(tree); 29 | 30 | if (audit.type === 'error') { 31 | errorResults.push({ audit, result }); 32 | } else { 33 | warningResults.push({ audit, result }); 34 | } 35 | }); 36 | 37 | const errorsScore = errorResults.reduce(scoreReducer, { score: 0, max: 0 }); 38 | const warningsScore = warningResults.reduce(scoreReducer, { score: 0, max: 0 }); 39 | const totalScore = (errorsScore.score / errorsScore.max) * 0.9 + (warningsScore.score / warningsScore.max) * 0.1; 40 | 41 | return { 42 | score: totalScore, 43 | errors: errorResults.map(result => result.result).filter(Boolean) as AuditResult[], 44 | warnings: warningResults.map(result => result.result).filter(Boolean) as AuditResult[], 45 | }; 46 | } 47 | -------------------------------------------------------------------------------- /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/components/summary/score.tsx: -------------------------------------------------------------------------------- 1 | /* Copyright 2021 Google LLC. 2 | SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | import { FunctionalComponent, h } from 'preact'; 5 | import style from './style.css'; 6 | 7 | interface Props { 8 | class?: string; 9 | radius: number; 10 | stroke: number; 11 | value: number; 12 | text?: string; 13 | } 14 | 15 | const ColorGrading = new Map([ 16 | [0.8, style.good], 17 | [0.6, style.fair], 18 | [0, style.bad], 19 | ]); 20 | 21 | const Score: FunctionalComponent = (props: Props) => { 22 | const { radius, stroke, value, text } = props; 23 | const width = radius * 2; 24 | const height = radius * 2; 25 | const normalizedRadius = radius - stroke * 0.5; 26 | const circumference = normalizedRadius * 2 * Math.PI; 27 | const offset = circumference - value * circumference; 28 | const colorCss = Array.from(ColorGrading.entries()).find(([minScore, css]) => value >= minScore)?.[1] ?? style.bad; 29 | 30 | return ( 31 | 32 | 41 | 50 | 51 | {text} 52 | 53 | 54 | ); 55 | }; 56 | 57 | export default Score; 58 | -------------------------------------------------------------------------------- /src/lib/audits/audit-util.ts: -------------------------------------------------------------------------------- 1 | /* Copyright 2021 Google LLC. 2 | SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | import { getBareTreeNode, getPath, getTextContent } from '../tree-util'; 5 | 6 | const FORM_ATTRIBUTES_TO_INCLUDE = ['action', 'autocomplete', 'class', 'for', 'id', 'name', 'placeholder', 'type']; 7 | const END_TAGS_TO_INCLUDE = new Set(['label', 'button']); 8 | 9 | /** 10 | * Create a representation of a form element. 11 | */ 12 | export function stringifyFormElement(node: TreeNodeWithParent, additionalAttributes: string[] = []): string { 13 | const attributes = Object.entries(node.attributes) 14 | .filter(entry => [...FORM_ATTRIBUTES_TO_INCLUDE, ...additionalAttributes].includes(entry[0])) 15 | // Include empty attributes, e.g. for="", but not missing attributes. 16 | .filter(entry => node.attributes[entry[0]] !== null); 17 | const attributesString = attributes 18 | .map(([name, value]) => { 19 | if (!value) { 20 | return name; 21 | } 22 | return `${name}="${value}"`; 23 | }) 24 | .join(' '); 25 | 26 | const hasHiddenAttributes = Object.entries(node.attributes).length > attributes.length; 27 | let str = `<${node.name}${attributesString ? ` ${attributesString}` : ''}${hasHiddenAttributes ? ' ...' : ''}>`; 28 | if (END_TAGS_TO_INCLUDE.has(node.name!)) { 29 | const textContent = getTextContent(node); 30 | str += `${textContent}`; 31 | } 32 | 33 | return str; 34 | } 35 | 36 | export function makeAuditDetailsSerializable(auditDetails: AuditDetails): SerializableAuditDetails { 37 | return { 38 | score: auditDetails.score, 39 | errors: auditDetails.errors.map(result => ({ 40 | ...result, 41 | items: result.items.map(item => ({ ...getBareTreeNode(item, false), path: getPath(item) })), 42 | })), 43 | warnings: auditDetails.warnings.map(result => ({ 44 | ...result, 45 | items: result.items.map(item => ({ ...getBareTreeNode(item, false), path: getPath(item) })), 46 | })), 47 | }; 48 | } 49 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /scripts/publish.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Copyright 2021 Google LLC. 3 | # SPDX-License-Identifier: Apache-2.0 4 | 5 | set -e 6 | 7 | # load .env variables if they exist 8 | if [ -f .env ] 9 | then 10 | set -o allexport 11 | source .env 12 | set +o allexport 13 | fi 14 | 15 | cwd=`pwd` 16 | script_folder=`cd $(dirname $0) && pwd` 17 | version=`node $script_folder/version.js` 18 | 19 | echo "{\"version\": \"$version\"}" > $script_folder/../manifest.version.json 20 | 21 | required_environment_variables=("EXTENSION_ID" "CLIENT_ID" "CLIENT_SECRET" "REFRESH_TOKEN") 22 | 23 | any_missing=0 24 | for variable in "${required_environment_variables[@]}" 25 | do 26 | val=`echo "${!variable}"` 27 | if [ -z "$val" ] 28 | then 29 | echo "$variable environment variable not present." 30 | any_missing=1 31 | fi 32 | done 33 | 34 | if [[ $any_missing -ne 0 ]] 35 | then 36 | exit 1 37 | fi 38 | 39 | # get access token 40 | token=`curl "https://accounts.google.com/o/oauth2/token" -d "client_id=${CLIENT_ID}&client_secret=${CLIENT_SECRET}&refresh_token=${REFRESH_TOKEN}&grant_type=refresh_token&redirect_uri=urn:ietf:wg:oauth:2.0:oob" 2>/dev/null` 41 | access_token=`echo "console.log($token.access_token)" | node` 42 | 43 | # build extension 44 | npm run build 45 | (cd build && zip -r extension.zip ./) 46 | 47 | # upload and publish extension 48 | response=`curl -H "Authorization: Bearer ${access_token}" -H "x-goog-api-version: 2" -X PUT -T build/extension.zip "https://www.googleapis.com/upload/chromewebstore/v1.1/items/${EXTENSION_ID}" 2>/dev/null` 49 | echo "$response" 50 | status=`echo "console.log($response.uploadState)" | node` 51 | 52 | if [[ $status != "SUCCESS" ]] 53 | then 54 | echo "Failed to upload extension. Response:" 55 | echo "$response" 56 | exit 1 57 | fi 58 | 59 | response=`curl -H "Authorization: Bearer ${access_token}" -H "x-goog-api-version: 2" -H "Content-Length: 0" -X POST "https://www.googleapis.com/chromewebstore/v1.1/items/${EXTENSION_ID}/publish" 2>/dev/null` 60 | echo "$response" 61 | status=`echo "console.log($response.status[0])" | node` 62 | 63 | if [[ $status != "OK" ]] 64 | then 65 | echo "Falied to publish extension. Response:" 66 | echo "$response" 67 | exit 1 68 | fi 69 | -------------------------------------------------------------------------------- /src/lib/audits/forms.spec.ts: -------------------------------------------------------------------------------- 1 | /* Copyright 2021 Google LLC. 2 | SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | import { getTreeNodeWithParents } from '../tree-util'; 5 | import { hasEmptyForms } from './forms'; 6 | 7 | describe('forms', function () { 8 | it('should return audit error when form is empty', function () { 9 | const tree = getTreeNodeWithParents({ children: [{ name: 'form' }] }); 10 | const result = hasEmptyForms(tree); 11 | 12 | expect(result!.items[0].name).toEqual('form'); 13 | expect(result!.score).toBe(0); 14 | }); 15 | 16 | it('should return audit error when one or more forms is empty', function () { 17 | const tree = getTreeNodeWithParents({ 18 | children: [ 19 | { name: 'form', attributes: { id: 'form1' }, children: [{ name: 'input' }] }, 20 | { name: 'form', attributes: { id: 'form2' } }, 21 | ], 22 | }); 23 | const result = hasEmptyForms(tree); 24 | 25 | expect(result!.items[0].name).toEqual('form'); 26 | expect(result!.items[0].attributes.id).toEqual('form2'); 27 | expect(result!.score).toBe(0.5); 28 | }); 29 | 30 | it('should not return audit error when form is contains button', function () { 31 | const tree = getTreeNodeWithParents({ children: [{ name: 'form', children: [{ name: 'button' }] }] }); 32 | const result = hasEmptyForms(tree); 33 | expect(result).toBeUndefined(); 34 | }); 35 | 36 | it('should not return audit error when form is contains input', function () { 37 | const tree = getTreeNodeWithParents({ children: [{ name: 'form', children: [{ name: 'input' }] }] }); 38 | const result = hasEmptyForms(tree); 39 | expect(result).toBeUndefined(); 40 | }); 41 | 42 | it('should not return audit error when form is contains select', function () { 43 | const tree = getTreeNodeWithParents({ children: [{ name: 'form', children: [{ name: 'select' }] }] }); 44 | const result = hasEmptyForms(tree); 45 | expect(result).toBeUndefined(); 46 | }); 47 | 48 | it('should not return audit error when form is contains textarea', function () { 49 | const tree = getTreeNodeWithParents({ children: [{ name: 'form', children: [{ name: 'textarea' }] }] }); 50 | const result = hasEmptyForms(tree); 51 | expect(result).toBeUndefined(); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | /* Copyright 2021 Google LLC. 2 | SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | import fs from 'fs-extra'; 5 | import * as path from 'path'; 6 | import typescript from '@rollup/plugin-typescript'; 7 | import { nodeResolve } from '@rollup/plugin-node-resolve'; 8 | import replace from '@rollup/plugin-replace'; 9 | 10 | const buildFolder = 'build/'; 11 | const distFolder = 'dist/'; 12 | const __dirname = process.cwd(); 13 | 14 | const envManifest = process.argv.includes('--configDev') ? 'manifest.dev.json' : 'manifest.prod.json'; 15 | 16 | export default [ 17 | { 18 | input: 'src/background.ts', 19 | output: { 20 | file: `${buildFolder}background.js`, 21 | }, 22 | plugins: [ 23 | typescript(), 24 | replace({ 25 | 'preventAssignment': true, 26 | 'process.env.NODE_ENV': JSON.stringify('production'), 27 | }), 28 | nodeResolve(), 29 | ], 30 | }, 31 | { 32 | input: 'src/lib/content-script.ts', 33 | output: { 34 | file: `${buildFolder}lib/content-script.js`, 35 | }, 36 | plugins: [ 37 | typescript(), 38 | copy('src/css', `${buildFolder}css`), 39 | copy('src/images', `${buildFolder}images`), 40 | mergeJson(['src/manifest.json', 'manifest.version.json', envManifest], `${buildFolder}manifest.json`), 41 | ], 42 | }, 43 | { 44 | input: 'src/module.ts', 45 | output: { 46 | file: `${distFolder}index.js`, 47 | format: 'cjs', 48 | }, 49 | plugins: [typescript({ target: 'es6', lib: ['es5', 'es6', 'ESNext', 'dom'] }), nodeResolve()], 50 | }, 51 | ]; 52 | 53 | function copy(source, destination) { 54 | return { 55 | name: 'copy', 56 | writeBundle(output) { 57 | fs.copySync(path.resolve(__dirname, source), path.resolve(__dirname, destination)); 58 | }, 59 | }; 60 | } 61 | 62 | function mergeJson(jsonFiles, target) { 63 | return { 64 | name: 'mergeJson', 65 | writeBundle(output) { 66 | const [baseFile, ...otherJsonFiles] = jsonFiles 67 | .map(file => path.resolve(__dirname, file)) 68 | .filter(file => fs.existsSync(file)) 69 | .map(file => JSON.parse(fs.readFileSync(file, 'utf-8'))); 70 | 71 | fs.writeFileSync(target, JSON.stringify(Object.assign({}, baseFile, ...otherJsonFiles), null, ' ')); 72 | }, 73 | }; 74 | } 75 | -------------------------------------------------------------------------------- /src/lib/save-html.spec.ts: -------------------------------------------------------------------------------- 1 | /* Copyright 2021 Google LLC. 2 | SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | import { generateHtmlString } from './save-html'; 5 | import { createNode } from './test-util'; 6 | 7 | describe('generateHtmlString', function () { 8 | beforeAll(() => { 9 | jest.spyOn(global, 'fetch').mockImplementation(url => { 10 | return Promise.resolve({ 11 | text() { 12 | return Promise.resolve('body { width: 100% }'); 13 | }, 14 | } as Response); 15 | }); 16 | }); 17 | 18 | afterAll(() => { 19 | jest.clearAllMocks(); 20 | }); 21 | 22 | it('should return empty HTML string', async function () { 23 | const result = await generateHtmlString([], { version: '1' }, []); 24 | expect(result).toEqual( 25 | `\n\n\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/components/table/index.tsx: -------------------------------------------------------------------------------- 1 | /* Copyright 2021 Google LLC. 2 | SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | import { FunctionalComponent, h } from 'preact'; 5 | 6 | type Column = 7 | | { 8 | field: string; 9 | display?: string; 10 | className?: string; 11 | } 12 | | string; 13 | 14 | interface Props { 15 | columns: Column[]; 16 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 17 | items: Array<{ [key: string]: any }>; 18 | class?: string; 19 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 20 | onRowClick?: (row: h.JSX.HTMLAttributes, item: any) => void; 21 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 22 | onRowEnter?: (row: h.JSX.HTMLAttributes, item: any) => void; 23 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 24 | onRowLeave?: (row: h.JSX.HTMLAttributes, item: any) => void; 25 | } 26 | 27 | const Table: FunctionalComponent = props => { 28 | const { items, columns } = props; 29 | return ( 30 | 31 | 32 | 33 | {columns.map((col, colIndex) => { 34 | const display = typeof col === 'string' ? col : col.display ?? col.field; 35 | const className = typeof col === 'string' ? undefined : col.className; 36 | return ( 37 | 40 | ); 41 | })} 42 | 43 | 44 | 45 | {items.map((item, index) => { 46 | const row = ( 47 | props.onRowClick?.(row as h.JSX.HTMLAttributes, item)} 50 | onMouseEnter={() => props.onRowEnter?.(row as h.JSX.HTMLAttributes, item)} 51 | onMouseLeave={() => props.onRowLeave?.(row as h.JSX.HTMLAttributes, item)} 52 | > 53 | {columns.map((col, colIndex) => { 54 | const key = typeof col === 'string' ? col : col.field; 55 | const className = typeof col === 'string' ? undefined : col.className; 56 | return ( 57 | 60 | ); 61 | })} 62 | 63 | ); 64 | return row; 65 | })} 66 | 67 |
38 | {display} 39 |
58 | {item[key]?.toString()} 59 |
68 | ); 69 | }; 70 | 71 | export default Table; 72 | -------------------------------------------------------------------------------- /src/components/report/index.tsx: -------------------------------------------------------------------------------- 1 | /* Copyright 2021 Google LLC. 2 | SPDX-License-Identifier: Apache-2.0 */ 3 | 4 | import { Component, createRef, h } from 'preact'; 5 | import { generateHtmlString } from '../../lib/save-html'; 6 | import { truncate } from '../../lib/string-util'; 7 | import Results from '../../routes/results'; 8 | import AuditSummary from '../summary'; 9 | import style from './style.css'; 10 | import { version } from '../../../package.json'; 11 | import { waitFor } from '../../lib/wait-util'; 12 | import { makeAuditDetailsSerializable } from '../../lib/audits/audit-util'; 13 | 14 | interface Props { 15 | title: string; 16 | auditUrl: string; 17 | auditResults: AuditDetails; 18 | tree: TreeNode | undefined; 19 | icon?: string; 20 | } 21 | 22 | export default class Report extends Component { 23 | private reportElement = createRef(); 24 | private errorsReady = false; 25 | private warningsReady = false; 26 | 27 | public async getHtml(): Promise { 28 | if (this.props.tree) { 29 | await waitFor( 30 | () => { 31 | return this.reportElement.current && this.errorsReady && this.warningsReady; 32 | }, 33 | 2000, 34 | 100, 35 | ); 36 | const html = await generateHtmlString([this.reportElement.current!], { 37 | version, 38 | auditResults: makeAuditDetailsSerializable(this.props.auditResults), 39 | tree: this.props.tree, 40 | }); 41 | return html; 42 | } 43 | return ''; 44 | } 45 | 46 | render(): JSX.Element { 47 | const { auditResults, auditUrl, title, icon } = this.props; 48 | 49 | return ( 50 |
51 |
52 | {icon ? Webpage icon : null} 53 |
54 |

{title}

55 |

{truncate(auditUrl, 100)}

56 |
57 |
58 | 64 |

Recommendations

65 |
66 | { 69 | this.errorsReady = true; 70 | }} 71 | /> 72 |
73 |

Common mistakes

74 |
75 | { 78 | this.warningsReady = true; 79 | }} 80 | /> 81 |
82 |
83 | ); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 = /