├── .node-version ├── .gitignore ├── src ├── icons │ ├── 128.png │ ├── 48.png │ ├── crown-logo-19-active.png │ ├── crown-logo-38-active.png │ ├── crown-logo-19-inactive.png │ └── crown-logo-38-inactive.png ├── service_worker.js ├── manifest_chrome.json ├── components │ ├── show-meta-tags-component │ │ ├── show-meta-tags-component.css │ │ └── show-meta-tags-component.js │ ├── design-mode-component │ │ ├── design-mode-component.css │ │ └── design-mode-component.js │ ├── highlight-component │ │ ├── highlight-component.css │ │ └── highlight-component.js │ └── content-blocks-component │ │ ├── content-blocks-component.css │ │ └── content-blocks-component.js ├── manifest_firefox.json ├── popup │ ├── ab_tests.js │ ├── extract_path.js │ ├── popup.css │ ├── reset.css │ ├── content_links.js │ ├── environment.js │ ├── external_links.js │ ├── lib │ │ └── mustache.min.js │ └── popup.js ├── service_utils │ ├── ab_bucket_store.js │ ├── icon.js │ └── ab_test_settings.js ├── fetch-page-data.js ├── manifest_base.json └── popup.html ├── docs ├── screenshots.gif └── releasing.md ├── spec ├── javascripts │ ├── fixtures │ │ ├── gem-c-button.html │ │ ├── gem-c-label.html │ │ ├── app-c-back-to-top.html │ │ ├── meta-tags.html │ │ ├── content-blocks.html │ │ └── gem-c-breadcrumbs.html │ ├── show_meta_tags.spec.js │ ├── design_mode_component.spec.js │ ├── ab_tests.spec.js │ ├── environment.spec.js │ ├── extract_path.spec.js │ ├── content_blocks_component.spec.js │ ├── content_links.spec.js │ ├── events │ │ └── ab_bucket_store.spec.js │ ├── highlight_component.spec.js │ └── external_links.spec.js ├── helpers │ ├── helpers.js │ ├── polyfills │ │ └── String.startsWith.js │ └── jasmine-jquery.js └── support │ └── jasmine-browser.json ├── .github ├── dependabot.yml └── workflows │ ├── actionlint.yml │ ├── ci.yml │ └── release.yml ├── utils ├── check-firefox-version.mjs └── check-chrome-version.mjs ├── package.json └── README.md /.node-version: -------------------------------------------------------------------------------- 1 | 20.12.2 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | manifest.json -------------------------------------------------------------------------------- /src/icons/128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alphagov/govuk-browser-extension/HEAD/src/icons/128.png -------------------------------------------------------------------------------- /src/icons/48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alphagov/govuk-browser-extension/HEAD/src/icons/48.png -------------------------------------------------------------------------------- /src/service_worker.js: -------------------------------------------------------------------------------- 1 | /* global importScripts */ 2 | 3 | importScripts('./service_utils/icon.js') 4 | -------------------------------------------------------------------------------- /docs/screenshots.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alphagov/govuk-browser-extension/HEAD/docs/screenshots.gif -------------------------------------------------------------------------------- /spec/javascripts/fixtures/gem-c-button.html: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /src/manifest_chrome.json: -------------------------------------------------------------------------------- 1 | { 2 | "background": { 3 | "service_worker": "service_worker.js" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/icons/crown-logo-19-active.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alphagov/govuk-browser-extension/HEAD/src/icons/crown-logo-19-active.png -------------------------------------------------------------------------------- /src/icons/crown-logo-38-active.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alphagov/govuk-browser-extension/HEAD/src/icons/crown-logo-38-active.png -------------------------------------------------------------------------------- /spec/helpers/helpers.js: -------------------------------------------------------------------------------- 1 | function pluck (array, key) { 2 | return array.map(function (object) { 3 | return object[key] 4 | }) 5 | } 6 | -------------------------------------------------------------------------------- /src/icons/crown-logo-19-inactive.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alphagov/govuk-browser-extension/HEAD/src/icons/crown-logo-19-inactive.png -------------------------------------------------------------------------------- /src/icons/crown-logo-38-inactive.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alphagov/govuk-browser-extension/HEAD/src/icons/crown-logo-38-inactive.png -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: / 5 | dependency-type: production 6 | schedule: 7 | interval: daily 8 | -------------------------------------------------------------------------------- /spec/javascripts/fixtures/gem-c-label.html: -------------------------------------------------------------------------------- 1 |
2 | 5 |
6 | -------------------------------------------------------------------------------- /src/components/show-meta-tags-component/show-meta-tags-component.css: -------------------------------------------------------------------------------- 1 | #govuk-chrome-toolkit-banner{ 2 | border:2px solid #000; 3 | background:#ffc; 4 | text-align:left; 5 | padding:1em; 6 | } 7 | -------------------------------------------------------------------------------- /spec/helpers/polyfills/String.startsWith.js: -------------------------------------------------------------------------------- 1 | if (!String.prototype.startsWith) { 2 | String.prototype.startsWith = function (searchString, position) { 3 | return this.substr(position || 0, searchString.length) === searchString 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/manifest_firefox.json: -------------------------------------------------------------------------------- 1 | { 2 | "browser_specific_settings": { 3 | "gecko": { 4 | "id": "{0b9f2e3f-6db3-4272-851b-b363ddcf862a}" 5 | } 6 | }, 7 | "background": { 8 | "scripts": [ 9 | "service_worker.js" 10 | ] 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /spec/javascripts/fixtures/app-c-back-to-top.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Contents 6 | 7 | -------------------------------------------------------------------------------- /.github/workflows/actionlint.yml: -------------------------------------------------------------------------------- 1 | name: Lint GitHub Actions 2 | on: 3 | push: 4 | paths: ['.github/**'] 5 | jobs: 6 | actionlint: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | with: 11 | show-progress: false 12 | - uses: alphagov/govuk-infrastructure/.github/actions/actionlint@main 13 | -------------------------------------------------------------------------------- /spec/javascripts/fixtures/meta-tags.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |

Content

8 | 9 | -------------------------------------------------------------------------------- /src/components/design-mode-component/design-mode-component.css: -------------------------------------------------------------------------------- 1 | .govuk-panel.design-mode-component__banner { 2 | color: #fff; 3 | background-color: #1d70b8; 4 | margin-bottom: 0; 5 | padding: 10px; 6 | position: sticky; 7 | position: -webkit-sticky; 8 | top: 0; /* required */ 9 | z-index: 100; 10 | } 11 | 12 | .govuk-panel.design-mode-component__banner .govuk-panel__body { 13 | font-size: 20px; 14 | font-weight: bold; 15 | } 16 | -------------------------------------------------------------------------------- /src/components/highlight-component/highlight-component.css: -------------------------------------------------------------------------------- 1 | .highlight-component { 2 | cursor: pointer; 3 | outline: 3px solid #ffdd00; 4 | position: relative; 5 | } 6 | 7 | .highlight-component:after { 8 | background-color: #ffdd00; 9 | color: #0B0C0C; 10 | content: attr(data-app-name)attr(data-component-name); 11 | font-family: sans-serif; 12 | font-size: 16px; 13 | font-weight: normal; 14 | 15 | padding-left: 3px; 16 | padding-bottom: 3px; 17 | position: absolute; 18 | right: 0; 19 | top: 0; 20 | z-index: 1; 21 | } 22 | -------------------------------------------------------------------------------- /src/popup/ab_tests.js: -------------------------------------------------------------------------------- 1 | var Popup = Popup || {} 2 | 3 | Popup.findActiveAbTests = function (abTestBuckets) { 4 | return Object.keys(abTestBuckets).map(function (abTestName) { 5 | var currentBucket = abTestBuckets[abTestName].currentBucket 6 | var allowedBuckets = abTestBuckets[abTestName].allowedBuckets 7 | 8 | return { 9 | testName: abTestName, 10 | buckets: allowedBuckets.map(function (bucketName) { 11 | return { 12 | bucketName: bucketName, 13 | class: currentBucket === bucketName ? 'ab-bucket-selected' : '' 14 | } 15 | }) 16 | } 17 | }) 18 | } 19 | -------------------------------------------------------------------------------- /spec/javascripts/fixtures/content-blocks.html: -------------------------------------------------------------------------------- 1 |

Hello!

2 | 3 |

Here is some text

4 | 5 |

enquiries@companieshouse.gov.uk

6 | 7 |

And some more text

8 | 9 |

enquiries@companieshouse.gov.uk

10 | -------------------------------------------------------------------------------- /spec/javascripts/fixtures/gem-c-breadcrumbs.html: -------------------------------------------------------------------------------- 1 |
2 |
    3 |
  1. 4 | Section 5 |
  2. 6 | 7 |
  3. 8 | Sub-section 9 |
  4. 10 |
11 |
12 | -------------------------------------------------------------------------------- /utils/check-firefox-version.mjs: -------------------------------------------------------------------------------- 1 | import fetch from 'node-fetch'; 2 | import { existsSync } from 'node:fs'; 3 | 4 | const response = await fetch(`https://addons.mozilla.org/api/v5/addons/search/?guid=${process.env.FIREFOX_EXTENSION_ID}`); 5 | const body = await response.json(); 6 | 7 | const publishedVersion = body.results[0].current_version.version 8 | const currentVersion = process.env.CURRENT_VERSION 9 | 10 | if (publishedVersion === currentVersion) { 11 | console.log(`Currently published version is ${publishedVersion} - nothing to do`) 12 | process.exit(1) 13 | } 14 | 15 | if (!existsSync(`build/govuk-browser-extension-firefox-${currentVersion}.zip`)) { 16 | console.log(`The latest version is ${currentVersion}, but a build has not been uploaded`) 17 | process.exit(1) 18 | } 19 | 20 | -------------------------------------------------------------------------------- /src/components/content-blocks-component/content-blocks-component.css: -------------------------------------------------------------------------------- 1 | .highlight-content-block { 2 | cursor: pointer; 3 | background-color: #ffdd00; 4 | position: relative; 5 | } 6 | 7 | .highlight-content-block:hover:after { 8 | background-color: #ffdd00; 9 | color: #0B0C0C; 10 | content: attr(data-document-type); 11 | font-family: sans-serif; 12 | font-size: 12px; 13 | font-weight: normal; 14 | padding-left: 3px; 15 | padding-bottom: 3px; 16 | position: absolute; 17 | left: 0; 18 | top: 100%; 19 | z-index: 1; 20 | } 21 | 22 | .govuk-chrome-content-blocks-banner { 23 | padding: 20px; 24 | background-color: #f3f2f1; 25 | } 26 | 27 | .govuk-chrome-content-blocks-banner__subhead:first-letter { 28 | text-transform: uppercase; 29 | } 30 | 31 | .govuk-chrome-content-blocks-banner__button { 32 | margin-left: 10px; 33 | } 34 | -------------------------------------------------------------------------------- /utils/check-chrome-version.mjs: -------------------------------------------------------------------------------- 1 | import chromeWebstoreUpload from 'chrome-webstore-upload'; 2 | import { existsSync } from 'node:fs'; 3 | 4 | const store = chromeWebstoreUpload({ 5 | extensionId: process.env.CHROME_EXTENSION_ID, 6 | clientId: process.env.CHROME_CLIENT_ID, 7 | clientSecret: process.env.CHROME_CLIENT_SECRET, 8 | refreshToken: process.env.CHROME_REFRESH_TOKEN, 9 | }); 10 | 11 | const response = await store.get(); 12 | 13 | const publishedVersion = response.crxVersion; 14 | 15 | const currentVersion = process.env.CURRENT_VERSION 16 | 17 | if (publishedVersion === currentVersion) { 18 | console.log(`Currently published version is ${publishedVersion} - nothing to do`) 19 | process.exit(1) 20 | } 21 | 22 | if (!existsSync(`build/govuk-browser-extension-chrome-${currentVersion}.zip`)) { 23 | console.log(`The latest version is ${currentVersion}, but a build has not been uploaded`) 24 | process.exit(1) 25 | } 26 | 27 | 28 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "devDependencies": { 3 | "chrome-webstore-upload": "^3.2.0", 4 | "jasmine-browser-runner": "^3.0.0", 5 | "jasmine-core": "^5.10.0", 6 | "node-fetch": "^3.3.2", 7 | "standardx": "^7.0.0" 8 | }, 9 | "scripts": { 10 | "lint:js": "standardx 'spec/javascripts/**/*.js' 'src/**/*.js'", 11 | "lint:js:fix": "npm run lint:js -- --fix", 12 | "test": "jasmine-browser-runner runSpecs", 13 | "build": "bash build.sh", 14 | "check:firefox": "node utils/check-firefox-version.mjs", 15 | "check:chrome": "node utils/check-chrome-version.mjs" 16 | }, 17 | "eslintConfig": { 18 | "env": { 19 | "browser": true, 20 | "jasmine": true 21 | }, 22 | "rules": { 23 | "no-var": 0, 24 | "no-unused-vars": 0, 25 | "no-use-before-define": 0 26 | } 27 | }, 28 | "standardx": { 29 | "global": [ 30 | "chrome", 31 | "Mustache", 32 | "fetch", 33 | "Popup", 34 | "pluck" 35 | ] 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | codeql-sast: 11 | name: CodeQL SAST scan 12 | uses: alphagov/govuk-infrastructure/.github/workflows/codeql-analysis.yml@main 13 | permissions: 14 | security-events: write 15 | 16 | dependency-review: 17 | name: Dependency Review scan 18 | uses: alphagov/govuk-infrastructure/.github/workflows/dependency-review.yml@main 19 | 20 | test: 21 | name: Test Extension JS 22 | runs-on: ubuntu-latest 23 | steps: 24 | - name: Checkout repository 25 | uses: actions/checkout@v4 26 | 27 | - name: Setup Node 28 | uses: actions/setup-node@v4 29 | with: 30 | node-version-file: '.node-version' 31 | 32 | - name: Install Dependencies 33 | shell: bash 34 | run: npm install 35 | 36 | - name: Lint 37 | run: npm run lint:js 38 | 39 | - name: Run Jasmine 40 | run: npm test 41 | -------------------------------------------------------------------------------- /spec/javascripts/show_meta_tags.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /* global ShowMetaTagsComponent loadFixtures */ 3 | 4 | describe('Toggling meta tags', function () { 5 | var showMetaTagsBannerId = 'govuk-chrome-toolkit-banner' 6 | var showMetaTagsBannerElement 7 | var showMetaTagsComponent 8 | 9 | beforeEach(function () { 10 | window.chrome = { 11 | runtime: { 12 | onMessage: { 13 | addListener: function () {} 14 | }, 15 | sendMessage: function () {} 16 | } 17 | } 18 | 19 | loadFixtures('meta-tags.html') 20 | 21 | showMetaTagsComponent = new ShowMetaTagsComponent() 22 | showMetaTagsComponent.toggleMetaTags() 23 | 24 | showMetaTagsBannerElement = document.querySelector(`#${showMetaTagsBannerId}`) 25 | }) 26 | 27 | it('shows meta tags with name and content', function () { 28 | expect(showMetaTagsBannerElement.textContent).toMatch(/foo/) 29 | }) 30 | 31 | it("doesn't show meta tags that use property instead of name", function () { 32 | // No particular reason for this, it just doesn't 33 | expect(showMetaTagsBannerElement.textContent).not.toMatch(/og:image/) 34 | }) 35 | 36 | it('removes the banner when toggled off', function () { 37 | showMetaTagsComponent.toggleMetaTags() 38 | 39 | expect(showMetaTagsBannerElement).not.toBeVisible() 40 | }) 41 | }) 42 | -------------------------------------------------------------------------------- /spec/support/jasmine-browser.json: -------------------------------------------------------------------------------- 1 | { 2 | "srcDir": "src", 3 | "srcFiles": [ 4 | "**/ab_bucket_store.js", 5 | "lib/jquery.min.js", 6 | "popup/ab_tests.js", 7 | "popup/content_links.js", 8 | "popup/environment.js", 9 | "popup/extract_path.js", 10 | "popup/external_links.js", 11 | "components/content-blocks-component/content-blocks-component.js", 12 | "components/highlight-component/highlight-component.js", 13 | "components/design-mode-component/design-mode-component.js", 14 | "components/show-meta-tags-component/show-meta-tags-component.js" 15 | ], 16 | "specDir": "spec", 17 | "specFiles": [ 18 | "**/content_blocks_component.spec.js", 19 | "**/show_meta_tags.spec.js", 20 | "**/highlight_component.spec.js", 21 | "**/extract_path.spec.js", 22 | "**/external_links.spec.js", 23 | "**/environment.spec.js", 24 | "**/design_mode_component.spec.js", 25 | "**/content_links.spec.js", 26 | "**/ab_tests.spec.js", 27 | "**/ab_bucket_store.spec.js" 28 | ], 29 | "helpers": [ 30 | "helpers/jquery.min.js", 31 | "helpers/jasmine-jquery.js", 32 | "helpers/helpers.js", 33 | "helpers/polyfills/String.startsWith.js" 34 | ], 35 | "env": { 36 | "stopSpecOnExpectationFailure": false, 37 | "stopOnSpecFailure": false, 38 | "random": true 39 | }, 40 | "browser": { 41 | "name": "headlessChrome" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/service_utils/ab_bucket_store.js: -------------------------------------------------------------------------------- 1 | // This script is executed in the background. 2 | // 3 | // It stores the environment-specific state of the user's A/B testing buckets in 4 | // memory, until the browser or the extension is next reloaded. 5 | /* global abTest */ 6 | 7 | // eslint-disable-next-line no-unused-vars 8 | var abBucketStore = (function () { 9 | function createStore () { 10 | var abTestBuckets = {} 11 | 12 | function addAbTests (initialBuckets, hostname) { 13 | abTestBuckets[hostname] = abTestBuckets[hostname] || {} 14 | 15 | Object.keys(initialBuckets).forEach(function (testName) { 16 | // Add any A/B tests that are not already defined, but do not overwrite 17 | // any that we are already tracking. 18 | if (!abTestBuckets[hostname][testName]) { 19 | abTestBuckets[hostname][testName] = initialBuckets[testName] 20 | } 21 | }) 22 | } 23 | 24 | function getAll (hostname) { 25 | return abTestBuckets[hostname] || {} 26 | } 27 | 28 | function setBucket (testName, bucket, hostname) { 29 | /* eslint-disable-next-line */ 30 | abTest = abTestBuckets[hostname][testName] 31 | abTest.currentBucket = bucket 32 | abTestBuckets[hostname][testName] = abTest 33 | } 34 | 35 | return { 36 | addAbTests: addAbTests, 37 | getAll: getAll, 38 | setBucket: setBucket 39 | } 40 | } 41 | 42 | return { 43 | createStore: createStore 44 | } 45 | }()) 46 | -------------------------------------------------------------------------------- /src/popup/extract_path.js: -------------------------------------------------------------------------------- 1 | var Popup = Popup || {} 2 | 3 | // Extract the relevant path from a location, such as `/foo` from URLs like 4 | // `www.gov.uk/foo` and `www.gov.uk/api/content/foo`. 5 | Popup.extractPath = function (location, pathname, renderingApplication) { 6 | const url = new URL(location) 7 | var extractedPath 8 | 9 | if (location.includes('api/content')) { 10 | extractedPath = pathname.replace('api/content/', '') 11 | } else if (location.includes('anonymous_feedback')) { 12 | extractedPath = extractQueryParameter(location, 'path') 13 | } else if (location.includes('content-data')) { 14 | extractedPath = pathname.replace('metrics/', '') 15 | } else if (/\.?nationalarchives\.gov\.uk$/.test(url.hostname)) { 16 | extractedPath = pathname.split('https://www.gov.uk')[1] 17 | } else if (location.includes('api/search.json')) { 18 | extractedPath = extractQueryParameter(location, 'filter_link') 19 | } else if (/api.*\.json/.test(location)) { 20 | extractedPath = pathname.replace('api/', '').replace('.json', '') 21 | } else if (location.includes('visualise')) { 22 | extractedPath = pathname.replace('/y/visualise', '') 23 | } else if (/www|draft-origin/.test(location)) { 24 | extractedPath = pathname 25 | } 26 | 27 | if (extractedPath) { 28 | return extractedPath.replace('//', '/') 29 | } 30 | 31 | function extractQueryParameter (location, parameterName) { 32 | return location.split(parameterName + '=')[1].split('&')[0] 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/fetch-page-data.js: -------------------------------------------------------------------------------- 1 | // This script is called by the popup, but runs inside the main thread, so it 2 | // has access to the current page. It sends a message back to the popup with 3 | // the information needed to render it. 4 | 5 | chrome.runtime.sendMessage({ 6 | action: 'populatePopup', 7 | currentLocation: window.location.href, 8 | currentHost: window.location.hostname, 9 | currentOrigin: window.location.origin, 10 | currentPathname: window.location.pathname, 11 | renderingApplication: getMetatag('govuk:rendering-app'), 12 | abTestBuckets: getAbTestBuckets(), 13 | windowHeight: window.innerHeight, 14 | highlightState: false // window.highlightComponent.state 15 | }) 16 | 17 | function getMetatag (name) { 18 | var meta = document.getElementsByTagName('meta')[name] 19 | return meta && meta.getAttribute('content') 20 | } 21 | 22 | function getAbTestBuckets () { 23 | var abMetaTags = document.querySelectorAll('meta[name="govuk:ab-test"]') 24 | 25 | var metaTagPattern = /([\w-]+):([\w-]+)/ 26 | var buckets = {} 27 | 28 | abMetaTags.forEach(function (metaTag) { 29 | var testNameAndBucket = metaTagPattern.exec(metaTag.content) 30 | var testName = testNameAndBucket[1] 31 | var currentBucket = testNameAndBucket[2] 32 | var allowedBuckets = 33 | (metaTag.dataset.allowedVariants || 'A,B').split(',') 34 | 35 | buckets[testName] = { 36 | currentBucket: currentBucket, 37 | allowedBuckets: allowedBuckets 38 | } 39 | }) 40 | 41 | return buckets 42 | } 43 | -------------------------------------------------------------------------------- /src/service_utils/icon.js: -------------------------------------------------------------------------------- 1 | // This script runs in the service worker in Chrome. It will activate the small 2 | // greyed out GOV.UK logo in the Chrome menu bar whenever we're on a gov.uk page. 3 | chrome.declarativeContent.onPageChanged.removeRules(async () => { 4 | chrome.declarativeContent.onPageChanged.addRules([{ 5 | conditions: [ 6 | new chrome.declarativeContent.PageStateMatcher({ 7 | pageUrl: { hostSuffix: 'www.gov.uk' } 8 | }), 9 | new chrome.declarativeContent.PageStateMatcher({ 10 | pageUrl: { hostSuffix: 'dev.gov.uk' } 11 | }), 12 | new chrome.declarativeContent.PageStateMatcher({ 13 | pageUrl: { hostSuffix: 'publishing.service.gov.uk' } 14 | }) 15 | ], 16 | actions: [ 17 | new chrome.declarativeContent.SetIcon({ 18 | imageData: { 19 | 19: await loadImageData('icons/crown-logo-19-active.png'), 20 | 38: await loadImageData('icons/crown-logo-38-active.png') 21 | } 22 | }), 23 | chrome.declarativeContent.ShowAction 24 | ? new chrome.declarativeContent.ShowAction() 25 | : new chrome.declarativeContent.ShowPageAction() 26 | ] 27 | }]) 28 | }) 29 | 30 | async function loadImageData (url) { 31 | const img = await createImageBitmap(await (await fetch(url)).blob()) 32 | const { width: w, height: h } = img 33 | const canvas = new OffscreenCanvas(w, h) 34 | const ctx = canvas.getContext('2d') 35 | ctx.drawImage(img, 0, 0, w, h) 36 | return ctx.getImageData(0, 0, w, h) 37 | } 38 | -------------------------------------------------------------------------------- /src/components/design-mode-component/design-mode-component.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /* global DesignModeComponent */ 3 | 4 | function DesignModeComponent () { 5 | this.state = false 6 | chrome.runtime.onMessage.addListener(function (request) { 7 | if (request.trigger === 'toggleDesignMode') { 8 | this.toggleDesignMode() 9 | } 10 | }.bind(this)) 11 | } 12 | 13 | DesignModeComponent.prototype.toggleDesignMode = function () { 14 | this.state = !this.state 15 | 16 | window.document.designMode = this.state ? 'on' : 'off' 17 | this.toggleDesignModeBanner() 18 | this.sendState() 19 | } 20 | 21 | DesignModeComponent.prototype.toggleDesignModeBanner = function () { 22 | var designModeBannerId = 'govuk-chrome-toolkit-design-mode-banner' 23 | if (this.state) { 24 | var designModeBanner = ` 25 |
26 |
27 | You are in design mode. 28 |
29 |
30 | ` 31 | var designModeWrapper = document.createElement('div') 32 | designModeWrapper.innerHTML = designModeBanner 33 | document.body.prepend(designModeWrapper) 34 | } else { 35 | var designModeBannerElement = document.querySelector(`#${designModeBannerId}`) 36 | designModeBannerElement.remove() 37 | } 38 | } 39 | 40 | DesignModeComponent.prototype.sendState = function () { 41 | chrome.runtime.sendMessage({ 42 | action: 'designModeState', 43 | designModeState: this.state 44 | }) 45 | } 46 | -------------------------------------------------------------------------------- /spec/javascripts/design_mode_component.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /* global DesignModeComponent */ 3 | 4 | describe('Toggling design mode', function () { 5 | var designModeBannerId = 'govuk-chrome-toolkit-design-mode-banner' 6 | var designModeBannerElement 7 | var designModeComponent 8 | 9 | beforeEach(function () { 10 | // Mock addListener function to call toggleDesignMode trigger when initialized 11 | window.chrome = { 12 | runtime: { 13 | onMessage: { 14 | addListener: function (callback) { 15 | /* eslint-disable-next-line */ 16 | callback({ trigger: 'toggleDesignMode' }) 17 | } 18 | }, 19 | sendMessage: function () {} 20 | } 21 | } 22 | designModeComponent = new DesignModeComponent() 23 | designModeBannerElement = document.querySelector(`#${designModeBannerId}`) 24 | }) 25 | 26 | it('shows design mode banner', function () { 27 | expect(designModeBannerElement.textContent).toMatch(/You are in design mode./) 28 | }) 29 | 30 | it('removes the banner when toggled off', function () { 31 | designModeComponent.toggleDesignMode() 32 | expect(designModeBannerElement).not.toBeVisible() 33 | }) 34 | 35 | it('design mode is on when toggled on', function () { 36 | expect(window.document.designMode).toEqual('on') 37 | }) 38 | 39 | it('design mode is off when toggled off', function () { 40 | designModeComponent.toggleDesignMode() 41 | expect(window.document.designMode).toEqual('off') 42 | }) 43 | }) 44 | -------------------------------------------------------------------------------- /src/manifest_base.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "GOV.UK Browser Extension", 4 | "description": "Switch between GOV.UK environments and content", 5 | "homepage_url": "https://github.com/alphagov/govuk-browser-extension", 6 | "version": "1.30.2", 7 | "content_scripts": [ 8 | { 9 | "matches": 10 | [ 11 | "https://*.gov.uk/*" 12 | ], 13 | "all_frames": true, 14 | "js": [ 15 | "popup/lib/mustache.min.js", 16 | "components/highlight-component/highlight-component.js", 17 | "components/content-blocks-component/content-blocks-component.js", 18 | "components/design-mode-component/design-mode-component.js", 19 | "components/show-meta-tags-component/show-meta-tags-component.js" 20 | ], 21 | "css": [ 22 | "components/highlight-component/highlight-component.css", 23 | "components/content-blocks-component/content-blocks-component.css", 24 | "components/design-mode-component/design-mode-component.css", 25 | "components/show-meta-tags-component/show-meta-tags-component.css" 26 | ] 27 | } 28 | ], 29 | "icons": { 30 | "48": "icons/48.png", 31 | "128": "icons/128.png" 32 | }, 33 | "permissions": [ 34 | "cookies", 35 | "declarativeContent", 36 | "scripting", 37 | "tabs", 38 | "webRequest" 39 | ], 40 | "action": { 41 | "default_icon": { 42 | "19": "icons/crown-logo-19-inactive.png", 43 | "38": "icons/crown-logo-38-inactive.png" 44 | }, 45 | "default_title": "GOV.UK", 46 | "default_popup": "popup.html" 47 | }, 48 | "content_security_policy": {}, 49 | "host_permissions": [ 50 | "http://*.gov.uk/*", 51 | "https://*.gov.uk/*" 52 | ] 53 | } 54 | -------------------------------------------------------------------------------- /src/popup/popup.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | width: 550px; 5 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; 6 | } 7 | 8 | #content .loading-message { 9 | text-align: center; 10 | padding: 20px; 11 | } 12 | 13 | .envs { 14 | padding: 0; 15 | background-color: #0B0C0C; 16 | } 17 | 18 | .envs a { 19 | color: #fff; 20 | padding: 15px 8px; 21 | display: inline-block; 22 | -webkit-tap-highlight-color: rgba(0, 0, 0, 0.3); 23 | } 24 | 25 | .envs a.current, 26 | .envs a:hover { 27 | background-color: #1d70b8; 28 | } 29 | 30 | .add-top-border { 31 | margin-top: 10px; 32 | padding-top: 10px; 33 | border-top: 1px solid #ccc; 34 | } 35 | 36 | .add-top-border:empty { 37 | display: none; 38 | } 39 | 40 | li a { 41 | display: block; 42 | padding: 7px 10px; 43 | color: #0B0C0C; 44 | text-decoration: none; 45 | -webkit-tap-highlight-color: rgba(0, 0, 0, 0.3); 46 | } 47 | 48 | li a:focus, 49 | .envs a:focus { 50 | color: #0B0C0C; 51 | background-color: #ffdd00; 52 | outline: 3px solid transparent; 53 | box-shadow: 0 -2px #ffdd00, 0 4px #0b0c0c; 54 | text-decoration: none; 55 | } 56 | 57 | li a img { 58 | vertical-align: middle; 59 | } 60 | 61 | li a.current, 62 | li a:hover { 63 | background-color: #1d70b8; 64 | color: #fff; 65 | } 66 | 67 | .ab-test-name, .ab-test-bucket { 68 | display: inline-block; 69 | vertical-align: middle; 70 | padding-left: 10px; 71 | } 72 | 73 | .ab-test-name { 74 | width: 250px; 75 | white-space: nowrap; 76 | overflow: hidden; 77 | text-overflow: ellipsis; 78 | } 79 | 80 | .ab-test-bucket { 81 | padding: 6px 9px; 82 | cursor: pointer; 83 | } 84 | 85 | .ab-bucket-selected, .ab-test-bucket:hover { 86 | color: #fff; 87 | background-color: #1d70b8; 88 | } 89 | -------------------------------------------------------------------------------- /docs/releasing.md: -------------------------------------------------------------------------------- 1 | # Releasing the extension 2 | 3 | 1. Install `jq`. For example, on mac, you can do it using brew 'brew install jq' 4 | 2. Update the version in `manifest_base.json` 5 | 3. Run `npm run build` 6 | 4. Create a Pull Request with the new package committed 7 | 5. Once the Pull Request is merged, the latest version is released via the [Release workflow](https://github.com/alphagov/govuk-browser-extension/blob/main/.github/workflows/release.yml) 8 | 9 | ## How the secrets are managed 10 | 11 | Ths secrets for both platforms are stored in the repo, and managed as follows: 12 | 13 | ### Firefox 14 | 15 | Extension API keys managed via the shared account in the [Firefox developer hub](https://addons.mozilla.org/en-US/developers/) 16 | 17 | Account details are in the [AWS Secrets Manager](https://eu-west-1.console.aws.amazon.com/secretsmanager). See the 18 | documentation in [Retrieve a credential from Secrets Manager](https://docs.publishing.service.gov.uk/manual/secrets-manager.html#retrieve-a-credential-from-secrets-manager) 19 | 20 | ### Chrome 21 | 22 | There is a `chrome-webstore-upload` project in Google Cloud, which is accessible by everyone in the 23 | google-chrome-developers@digital.cabinet-office.gov.uk group. 24 | 25 | If you do not have access to the group, then you can ask to be added to the 26 | [govuk google chrome developers google group](https://groups.google.com/a/digital.cabinet-office.gov.uk/g/google-chrome-developers). 27 | 28 | If you need to regenerate the API keys for any reason, you can [follow the instructions here](https://github.com/fregante/chrome-webstore-upload-keys?tab=readme-ov-file) 29 | 30 | ## Note 31 | 32 | Firefox and chrome currently disagree on few things with respect to V3 of manifest.json, so inorder to accommodate for 33 | both the browser, we would need a separate build for each browser with their manifest.json catering to each of them. To 34 | do this, we have created two manifest.json for each browser and have updated build script to generate separate 35 | manifest.json for each of them during the build. 36 | -------------------------------------------------------------------------------- /src/popup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 60 | 61 |
62 |

Loading...

63 |
64 | 65 | 66 | -------------------------------------------------------------------------------- /src/popup/reset.css: -------------------------------------------------------------------------------- 1 | /* 2 | html5doctor.com Reset Stylesheet 3 | v1.6.1 4 | Last Updated: 2010-09-17 5 | Author: Richard Clark - http://richclarkdesign.com 6 | Twitter: @rich_clark 7 | */ 8 | 9 | html, body, div, span, object, iframe, 10 | h1, h2, h3, h4, h5, h6, p, blockquote, pre, 11 | abbr, address, cite, code, 12 | del, dfn, em, img, ins, kbd, q, samp, 13 | small, strong, sub, sup, var, 14 | b, i, 15 | dl, dt, dd, ol, ul, li, 16 | fieldset, form, label, legend, 17 | table, caption, tbody, tfoot, thead, tr, th, td, 18 | article, aside, canvas, details, figcaption, figure, 19 | footer, header, hgroup, menu, nav, section, summary, 20 | time, mark, audio, video { 21 | margin:0; 22 | padding:0; 23 | border:0; 24 | outline:0; 25 | font-size:100%; 26 | vertical-align:baseline; 27 | background:transparent; 28 | } 29 | 30 | body { 31 | line-height:1; 32 | } 33 | 34 | article,aside,details,figcaption,figure, 35 | footer,header,hgroup,menu,nav,section { 36 | display:block; 37 | } 38 | 39 | nav ul { 40 | list-style:none; 41 | } 42 | 43 | blockquote, q { 44 | quotes:none; 45 | } 46 | 47 | blockquote:before, blockquote:after, 48 | q:before, q:after { 49 | content:''; 50 | content:none; 51 | } 52 | 53 | a { 54 | margin:0; 55 | padding:0; 56 | font-size:100%; 57 | vertical-align:baseline; 58 | background:transparent; 59 | } 60 | 61 | /* change colours to suit your needs */ 62 | ins { 63 | background-color:#ff9; 64 | color:#000; 65 | text-decoration:none; 66 | } 67 | 68 | /* change colours to suit your needs */ 69 | mark { 70 | background-color:#ff9; 71 | color:#000; 72 | font-style:italic; 73 | font-weight:bold; 74 | } 75 | 76 | del { 77 | text-decoration: line-through; 78 | } 79 | 80 | abbr[title], dfn[title] { 81 | border-bottom:1px dotted; 82 | cursor:help; 83 | } 84 | 85 | table { 86 | border-collapse:collapse; 87 | border-spacing:0; 88 | } 89 | 90 | /* change border colour to suit your needs */ 91 | hr { 92 | display:block; 93 | height:1px; 94 | border:0; 95 | border-top:1px solid #cccccc; 96 | margin:1em 0; 97 | padding:0; 98 | } 99 | 100 | input, select { 101 | vertical-align:middle; 102 | } 103 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | release: 10 | env: 11 | CHROME_EXTENSION_ID: dclfaikcemljbaoagjnedmlppnbiljen 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout repository 15 | uses: actions/checkout@v4 16 | - name: Setup Node 17 | uses: actions/setup-node@v4 18 | with: 19 | node-version-file: '.node-version' 20 | - name: Install Dependencies 21 | shell: bash 22 | run: npm install 23 | - name: Set environment variables from the manifest 24 | run: | 25 | echo "FIREFOX_EXTENSION_ID=$(jq -s -r ".[0].browser_specific_settings.gecko.id" < src/manifest_firefox.json)" >> "$GITHUB_ENV" 26 | echo "CURRENT_VERSION=$(jq -s -r '.[0].version' < src/manifest_base.json)" >> "$GITHUB_ENV" 27 | - name: Check if Firefox version can be released 28 | id: firefox_check 29 | continue-on-error: true 30 | run: npm run check:firefox 31 | - name: Check if Chrome version can be released 32 | id: chrome_check 33 | continue-on-error: true 34 | run: npm run check:chrome 35 | env: 36 | CHROME_CLIENT_ID: ${{ secrets.CHROME_CLIENT_ID }} 37 | CHROME_CLIENT_SECRET: ${{ secrets.CHROME_CLIENT_SECRET }} 38 | CHROME_REFRESH_TOKEN: ${{ secrets.CHROME_REFRESH_TOKEN }} 39 | - name: Upload & release Firefox 40 | uses: wdzeng/firefox-addon@v1 41 | if: steps.firefox_check.outcome == 'success' 42 | with: 43 | addon-guid: ${{ env.FIREFOX_EXTENSION_ID }} 44 | xpi-path: build/govuk-browser-extension-firefox-${{ env.CURRENT_VERSION }}.zip 45 | self-hosted: false 46 | jwt-issuer: ${{ secrets.FIREFOX_JWT_ISSUER }} 47 | jwt-secret: ${{ secrets.FIREFOX_JWT_SECRET }} 48 | - name: Upload & release Chrome 49 | if: steps.chrome_check.outcome == 'success' 50 | uses: mnao305/chrome-extension-upload@v5.0.0 51 | with: 52 | file-path: build/govuk-browser-extension-chrome-${{ env.CURRENT_VERSION }}.zip 53 | extension-id: ${{ env.CHROME_EXTENSION_ID }} 54 | client-id: ${{ secrets.CHROME_CLIENT_ID }} 55 | client-secret: ${{ secrets.CHROME_CLIENT_SECRET }} 56 | refresh-token: ${{ secrets.CHROME_REFRESH_TOKEN }} 57 | 58 | -------------------------------------------------------------------------------- /src/components/show-meta-tags-component/show-meta-tags-component.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | function ShowMetaTagsComponent () { 3 | this.isMetaTagsDisplayed = false 4 | 5 | chrome.runtime.onMessage.addListener(function (request) { 6 | if (request.trigger === 'toggleMetaTags') { 7 | this.toggleMetaTags() 8 | } 9 | }.bind(this)) 10 | } 11 | 12 | ShowMetaTagsComponent.prototype.toggleMetaTags = function () { 13 | if (this.isMetaTagsDisplayed) { 14 | this.hideMetaTags() 15 | } else { 16 | this.showMetaTags() 17 | } 18 | 19 | this.sendState() 20 | } 21 | 22 | ShowMetaTagsComponent.prototype.showMetaTags = function () { 23 | var titleElement = document.querySelector('title') 24 | var titleText = '' 25 | if (titleElement) { 26 | titleText = titleElement.textContent 27 | } 28 | 29 | var titleTag = ` 30 |

31 | title (${titleText.length}): 32 | ${titleText} 33 |

34 | ` 35 | 36 | var metaTagContainer = document.createElement('div') 37 | metaTagContainer.setAttribute('id', 'govuk-chrome-toolkit-banner') 38 | // insert titleTag into metaTagContainer 39 | metaTagContainer.insertAdjacentHTML('beforeend', titleTag) 40 | 41 | var metaTags = document.querySelectorAll('meta') 42 | metaTags.forEach(function (metaTag) { 43 | var metaTagName = metaTag.getAttribute('name') 44 | if (metaTagName === null) { 45 | return 46 | } 47 | 48 | var metaTagContent = metaTag.getAttribute('content') 49 | if (metaTagContent === null) { 50 | metaTagContent = '' 51 | } 52 | 53 | var metaTagInfo = ` 54 |

55 | ${metaTagName} (${metaTagContent.length}): 56 | ${metaTagContent} 57 |

58 | ` 59 | 60 | // insert metaTagInfo into metaTagContainer 61 | metaTagContainer.insertAdjacentHTML('beforeend', metaTagInfo) 62 | }) 63 | 64 | document.body.prepend(metaTagContainer) 65 | 66 | this.isMetaTagsDisplayed = true 67 | } 68 | 69 | ShowMetaTagsComponent.prototype.hideMetaTags = function () { 70 | var hideMetaTagsBanner = document.querySelector('#govuk-chrome-toolkit-banner') 71 | hideMetaTagsBanner.remove() 72 | 73 | this.isMetaTagsDisplayed = false 74 | } 75 | 76 | ShowMetaTagsComponent.prototype.sendState = function () { 77 | chrome.runtime.sendMessage({ 78 | action: 'showMetaTagsState', 79 | metaTagsState: this.isMetaTagsDisplayed 80 | }) 81 | } 82 | -------------------------------------------------------------------------------- /src/service_utils/ab_test_settings.js: -------------------------------------------------------------------------------- 1 | // This script is executed in the background. 2 | // 3 | // - It sets the appropriate request headers like `GOVUK-ABTest-NewNavigation` 4 | // so that applications in integration, staging and development will respond 5 | // with the correct A/B variant. It gets the current variant from the meta tags. 6 | // - Responds to messages to change the current A/B variant. It updates the 7 | // headers it will send and set a cookie like Fastly would. 8 | var abTestSettings = (function () { 9 | var abBucketStore = chrome.extension.getBackgroundPage().abBucketStore.createStore() 10 | 11 | function initialize (initialBuckets, url) { 12 | var hostname = extractHostname(url) 13 | 14 | abBucketStore.addAbTests(initialBuckets, hostname) 15 | return abBucketStore.getAll(hostname) 16 | } 17 | 18 | function updateCookie (name, bucket, url, callback) { 19 | var cookieName = 'ABTest-' + name 20 | 21 | chrome.cookies.get({ name: cookieName, url: url }, function (cookie) { 22 | if (cookie) { 23 | cookie.value = bucket 24 | 25 | var updatedCookie = { 26 | name: cookieName, 27 | value: bucket, 28 | url: url, 29 | path: cookie.path, 30 | expirationDate: cookie.expirationDate 31 | } 32 | 33 | chrome.cookies.set(updatedCookie, callback) 34 | } else { 35 | callback() 36 | } 37 | }) 38 | } 39 | 40 | function setBucket (testName, bucketName, url, callback) { 41 | abBucketStore.setBucket(testName, bucketName, extractHostname(url)) 42 | updateCookie(testName, bucketName, url, callback) 43 | } 44 | 45 | function addAbHeaders (details) { 46 | var abTestBuckets = abBucketStore.getAll(extractHostname(details.url)) 47 | 48 | Object.keys(abTestBuckets).forEach(function (abTestName) { 49 | details.requestHeaders.push({ 50 | name: 'GOVUK-ABTest-' + abTestName, 51 | value: abTestBuckets[abTestName].currentBucket 52 | }) 53 | }) 54 | 55 | return { requestHeaders: details.requestHeaders } 56 | } 57 | 58 | function extractHostname (url) { 59 | return new URL(url).hostname 60 | } 61 | 62 | chrome.webRequest.onBeforeSendHeaders.addListener( 63 | addAbHeaders, 64 | { urls: ['*://*.gov.uk/*'] }, 65 | ['requestHeaders', 'blocking'] 66 | ) 67 | 68 | return { 69 | initialize: initialize, 70 | setBucket: setBucket 71 | } 72 | }()) 73 | -------------------------------------------------------------------------------- /spec/javascripts/ab_tests.spec.js: -------------------------------------------------------------------------------- 1 | describe('Popup.findActiveAbTests', function () { 2 | it('returns no A/B tests if none are active', function () { 3 | var abTests = Popup.findActiveAbTests({}) 4 | 5 | expect(abTests).toEqual([]) 6 | }) 7 | 8 | it('finds all A/B tests', function () { 9 | var abTests = Popup.findActiveAbTests({ 10 | 'first-AB-test-name': { 11 | currentBucket: 'some-value', 12 | allowedBuckets: ['some-value', 'B'] 13 | }, 14 | 'second-AB-test-name': { 15 | currentBucket: 'other-value', 16 | allowedBuckets: ['other-value', 'B'] 17 | }, 18 | 'third-AB-test-name': { 19 | currentBucket: 'yet-another-value', 20 | allowedBuckets: ['A', 'yet-another-value'] 21 | } 22 | }) 23 | 24 | expect(abTests.length).toEqual(3) 25 | expect(abTests[0].testName).toEqual('first-AB-test-name') 26 | expect(abTests[1].testName).toEqual('second-AB-test-name') 27 | expect(abTests[2].testName).toEqual('third-AB-test-name') 28 | }) 29 | 30 | it('returns A and B buckets', function () { 31 | var abTests = Popup.findActiveAbTests({ 32 | 'some-AB-test-name': { 33 | currentBucket: 'A', 34 | allowedBuckets: ['A', 'B'] 35 | } 36 | }) 37 | 38 | expect(abTests[0].buckets.length).toEqual(2) 39 | expect(abTests[0].buckets[0].bucketName).toEqual('A') 40 | expect(abTests[0].buckets[1].bucketName).toEqual('B') 41 | }) 42 | 43 | it("highlights 'A' bucket if user is in 'A' group", function () { 44 | var abTests = Popup.findActiveAbTests({ 45 | 'some-AB-test-name': { 46 | currentBucket: 'B', 47 | allowedBuckets: ['A', 'B'] 48 | }, 49 | 'other-AB-test-name': { 50 | currentBucket: 'A', 51 | allowedBuckets: ['A', 'B'] 52 | } 53 | }) 54 | 55 | expect(abTests[0].buckets[0].class).toEqual('') 56 | expect(abTests[0].buckets[1].class).toEqual('ab-bucket-selected') 57 | 58 | expect(abTests[1].buckets[0].class).toEqual('ab-bucket-selected') 59 | expect(abTests[1].buckets[1].class).toEqual('') 60 | }) 61 | 62 | it("doesn't highlight any buckets if variant is unknown", function () { 63 | var abTests = Popup.findActiveAbTests({ 64 | 'some-AB-test-name': { 65 | currentBucket: 'Unknown', 66 | allowedBuckets: ['A', 'B'] 67 | } 68 | }) 69 | 70 | expect(abTests[0].buckets[0].class).toEqual('') 71 | expect(abTests[0].buckets[1].class).toEqual('') 72 | }) 73 | }) 74 | -------------------------------------------------------------------------------- /src/popup/content_links.js: -------------------------------------------------------------------------------- 1 | var Popup = Popup || {} 2 | 3 | // Given a location, generate links to different content presentations 4 | Popup.generateContentLinks = function (location, origin, pathname, currentEnvironment, renderingApplication) { 5 | var path = Popup.extractPath(location, pathname, renderingApplication) 6 | 7 | // If no path can be found (which means we're probably in a publishing app) 8 | // Similarly if we're on GOVUK account, not many of the links are relevant 9 | if (!path || origin.includes('www.account')) { 10 | return [] 11 | } 12 | 13 | // Origin looks like 'https://www.gov.uk' or 'https://www-origin.integration.publishing.service.gov.uk/' or similar. 14 | if (origin === 'http://webarchive.nationalarchives.gov.uk' || 15 | /draft-origin|content-data|support/.test(origin)) { 16 | origin = 'https://www.gov.uk' 17 | } 18 | 19 | var links = [] 20 | 21 | // If we're on the homepage there's not much to show. 22 | links.push({ name: 'On GOV.UK', url: origin + path }) 23 | links.push({ name: 'Content item (JSON)', url: currentEnvironment.origin + '/api/content' + path }) 24 | links.push({ name: 'Search data (JSON)', url: origin + '/api/search.json?filter_link=' + path }) 25 | links.push({ name: 'Draft (may not always work)', url: currentEnvironment.protocol + '://draft-origin.' + currentEnvironment.serviceDomain + path }) 26 | links.push({ name: 'User feedback', url: currentEnvironment.protocol + '://support.' + currentEnvironment.serviceDomain + '/anonymous_feedback?path=' + path }) 27 | links.push({ name: 'National Archives', url: 'http://webarchive.nationalarchives.gov.uk/*/https://www.gov.uk' + path }) 28 | links.push({ name: 'View data about page on Content Data', url: currentEnvironment.protocol + '://content-data.' + currentEnvironment.serviceDomain + '/metrics' + path }) 29 | links.push({ name: 'Check for content problems in Siteimprove', url: 'https://my2.siteimprove.com/QualityAssurance/1054012/Overview/Search?SearchIn=Url&Query=' + path }) 30 | links.push({ name: 'View structured data', url: 'https://search.google.com/structured-data/testing-tool/u/0/#url=https://www.gov.uk' + path }) 31 | 32 | var currentUrl = origin + path 33 | 34 | if (renderingApplication === 'smartanswers') { 35 | links.push({ name: 'SmartAnswers: Visualise', url: currentUrl.replace(/\/y.*$/, '') + '/y/visualise' }) 36 | } 37 | 38 | return links.map(function (link) { 39 | link.class = link.url === location ? 'current' : '' 40 | return link 41 | }) 42 | } 43 | -------------------------------------------------------------------------------- /spec/javascripts/environment.spec.js: -------------------------------------------------------------------------------- 1 | describe('Popup.environment', function () { 2 | function createEnvironmentForUrl (location) { 3 | var a = document.createElement('a') 4 | a.href = location 5 | return Popup.environment(a.href, a.hostname, a.origin).allEnvironments 6 | } 7 | 8 | it('returns the correct environment links when the user is on production', function () { 9 | var envs = createEnvironmentForUrl( 10 | 'https://www.gov.uk/browse/disabilities?foo=bar' 11 | ) 12 | 13 | var urls = pluck(envs, 'url') 14 | 15 | expect(urls).toEqual([ 16 | 'https://www.gov.uk/browse/disabilities?foo=bar', 17 | 'https://www.staging.publishing.service.gov.uk/browse/disabilities?foo=bar', 18 | 'https://www.integration.publishing.service.gov.uk/browse/disabilities?foo=bar', 19 | 'http://www.dev.gov.uk/browse/disabilities?foo=bar' 20 | ]) 21 | }) 22 | 23 | it('returns the correct variants for development', function () { 24 | var envs = createEnvironmentForUrl( 25 | 'http://www.dev.gov.uk/browse/disabilities?foo=bar' 26 | ) 27 | 28 | var urls = pluck(envs, 'url') 29 | 30 | expect(urls).toEqual([ 31 | 'https://www.gov.uk/browse/disabilities?foo=bar', 32 | 'https://www.staging.publishing.service.gov.uk/browse/disabilities?foo=bar', 33 | 'https://www.integration.publishing.service.gov.uk/browse/disabilities?foo=bar', 34 | 'http://www.dev.gov.uk/browse/disabilities?foo=bar' 35 | ]) 36 | }) 37 | 38 | it('shows production, staging, integration and development on publisher-apps', function () { 39 | var envs = createEnvironmentForUrl( 40 | 'https://signon.publishing.service.gov.uk/' 41 | ) 42 | 43 | var environmentNames = pluck(envs, 'name') 44 | 45 | expect(environmentNames).toEqual([ 46 | 'Production', 'Staging', 'Integration', 'Development' 47 | ]) 48 | }) 49 | 50 | it('Only shows production, staging and development on the GOVUK Account', function () { 51 | var envs = createEnvironmentForUrl( 52 | 'https://www.account.publishing.service.gov.uk/' 53 | ) 54 | 55 | var environmentNames = pluck(envs, 'name') 56 | 57 | expect(environmentNames).toEqual([ 58 | 'Production', 'Staging', 'Development' 59 | ]) 60 | }) 61 | 62 | it('correctly identifies the current environment', function () { 63 | var forProd = createEnvironmentForUrl( 64 | 'https://www.gov.uk/browse/disabilities?foo=bar' 65 | ) 66 | 67 | expect(forProd[0].class).toEqual('current') 68 | }) 69 | 70 | it('returns nothing if not on GOV.UK', function () { 71 | var links = createEnvironmentForUrl( 72 | 'http://webarchive.nationalarchives.gov.uk/*/https://www.gov.uk/jobsearch' 73 | ) 74 | 75 | expect(links).toEqual({ name: 'GOV.UK', url: 'https://www.gov.uk' }) 76 | }) 77 | }) 78 | -------------------------------------------------------------------------------- /spec/javascripts/extract_path.spec.js: -------------------------------------------------------------------------------- 1 | describe('Popup.extractPath', function () { 2 | it('returns nothing for publishing applications', function () { 3 | var path = Popup.extractPath('https://publisher.publishing.service.gov.uk/foo/bar', '/foo/bar') 4 | 5 | expect(path).toBeUndefined() 6 | }) 7 | 8 | it('returns the path for draft URLs', function () { 9 | var path = Popup.extractPath('https://draft-origin.staging.publishing.service.gov.uk/browse/disabilities', '/browse/disabilities') 10 | 11 | expect(path).toEqual('/browse/disabilities') 12 | }) 13 | 14 | it('returns the path for frontend applications', function () { 15 | var path = Popup.extractPath('https://www.gov.uk/browse/disabilities', '/browse/disabilities') 16 | 17 | expect(path).toBe('/browse/disabilities') 18 | }) 19 | 20 | it('returns the path for origin frontend applications', function () { 21 | var path = Popup.extractPath('https://www-origin.gov.uk/browse/disabilities', '/browse/disabilities') 22 | 23 | expect(path).toBe('/browse/disabilities') 24 | }) 25 | 26 | it('returns the path for content-store pages', function () { 27 | var path = Popup.extractPath('https://www.gov.uk/api/content/browse/disabilities', '/api/content/browse/disabilities') 28 | 29 | expect(path).toBe('/browse/disabilities') 30 | }) 31 | 32 | it('returns the path for URLs with double slashes', function () { 33 | var path = Popup.extractPath('https://www.gov.uk/api/content//browse/disabilities', '/api/content//browse/disabilities') 34 | 35 | expect(path).toBe('/browse/disabilities') 36 | }) 37 | 38 | it('returns the path for search pages', function () { 39 | var path = Popup.extractPath('https://www.gov.uk/api/search.json?filter_link=/browse/disabilities', '/api/search.json') 40 | 41 | expect(path).toBe('/browse/disabilities') 42 | }) 43 | 44 | it('returns the path for cache-busted search pages', function () { 45 | var path = Popup.extractPath('https://www.gov.uk/api/search.json?filter_link=/browse/disabilities&c=some_cache_buster', '/api/search.json') 46 | 47 | expect(path).toBe('/browse/disabilities') 48 | }) 49 | 50 | it('returns the path for national archives pages', function () { 51 | var path = Popup.extractPath('http://webarchive.nationalarchives.gov.uk/*/https://www.gov.uk/some/page', '/*/https://www.gov.uk/some/page') 52 | 53 | expect(path).toBe('/some/page') 54 | }) 55 | 56 | it('returns the path for the smart answers visualisation', function () { 57 | var path = Popup.extractPath('https://www.gov.uk/maternity-paternity-calculator/y/visualise', '/maternity-paternity-calculator/y/visualise') 58 | 59 | expect(path).toBe('/maternity-paternity-calculator') 60 | }) 61 | 62 | it('returns the path for the content data manager', function () { 63 | var path = Popup.extractPath('https://content-data.publishing.service.gov.uk/metrics/browse/disabilities', '/metrics/browse/disabilities') 64 | 65 | expect(path).toBe('/browse/disabilities') 66 | }) 67 | }) 68 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GOV.UK Toolkit for Chrome and Firefox 2 | 3 | Allows easy switching between the different GOV.UK environments and content representations. Inspired by the [govuk-bookmarklets](https://github.com/dsingleton/govuk-bookmarklets). 4 | 5 | ![Screenshot](docs/screenshots.gif) 6 | 7 | ## Installation 8 | 9 | The extension is [downloadable on the Chrome web store](https://chrome.google.com/webstore/detail/govuk-toolkit/dclfaikcemljbaoagjnedmlppnbiljen) and [AMO for Firefox](https://addons.mozilla.org/en-GB/firefox/addon/govuk-browser-extension-ff/). 10 | 11 | If you don't want to install from your browser's web store for security reasons, you can install a local non-self updating copy. 12 | 13 | ### For Chrome: 14 | 15 | 1. [Download the source from GitHub](https://github.com/alphagov/govuk-browser-extension/archive/main.zip) and unzip. 16 | 2. Visit [chrome://extensions](chrome://extensions) in your browser. 17 | 3. Ensure that the Developer mode checkbox in the top right-hand corner is checked. 18 | 4. Click `Load unpacked extension…` to pop up a file selection dialog. 19 | 5. Navigate to `src` in the extension directory, and select it. 20 | 6. Visit any page on [GOV.UK](https://www.gov.uk). 21 | 22 | Source: [Getting Started: Building a Chrome Extension](https://developer.chrome.com/extensions/getstarted#unpacked). 23 | 24 | ### For Firefox: 25 | 26 | Extensions installed using the following instructions are only active while Firefox 27 | is open and are removed on exit. Permanently-active extensions can be only be 28 | installed from packages signed by Mozilla. 29 | 30 | 1. [Download the source from GitHub](https://github.com/alphagov/govuk-browser-extension/archive/main.zip) and unzip. 31 | 2. Visit [about:debugging](about:debugging/runtime/this-firefox) in your browser. 32 | 3. Click `Load Temporary Add-on` to pop up a file selection dialog. 33 | 4. Navigate to `src` in the extension directory, and select `manifest.json`. 34 | 5. Visit any page on [GOV.UK](https://www.gov.uk). 35 | 36 | Source: [Temporary installation in Firefox](https://developer.mozilla.org/en-US/Add-ons/WebExtensions/Temporary_Installation_in_Firefox). 37 | 38 | ## Running the tests 39 | 40 | You'll need jasmine-browser, which you can set up with: 41 | 42 | ``` 43 | $ npm install 44 | ``` 45 | 46 | You can then run the tests with: 47 | 48 | ``` 49 | $ npm test 50 | ``` 51 | 52 | This will start a server and run the tests in a browser (chrome by default). 53 | 54 | If you want the browser to remain open with the test results, you can use 55 | 56 | ``` 57 | $ npx jasmine-browser-runner serve 58 | ``` 59 | 60 | ..then navigate to http://localhost:8888/ 61 | 62 | ## Releasing the extension 63 | 64 | 1. Update the version number in `src/manifest_base.json` 65 | 1. Run `build.sh` to create the build artifacts and ensure they're committed 66 | 1. Create a PR - when a new version is merged to `main`, a new version of the extension is automatically packaged up and published to 67 | Firefox Add-ons and to Chrome web store. See [releasing.md](https://github.com/alphagov/govuk-browser-extension/blob/main/docs/releasing.md) 68 | 69 | ### License 70 | 71 | MIT License 72 | -------------------------------------------------------------------------------- /src/popup/environment.js: -------------------------------------------------------------------------------- 1 | var Popup = Popup || {} 2 | 3 | // Given a location, hostname and origin, generate URLs for all GOV.UK environments. 4 | // 5 | // Returns a hash with envs, including one with `class: "current"` to show 6 | // the current environment. 7 | Popup.environment = function (location, host, origin) { 8 | function isPartOfGOVUK () { 9 | return /^(www|.*\.publishing\.service|(www\.)?dev)\.gov\.uk$/.test(host) 10 | } 11 | 12 | function isGOVUKAccount () { 13 | return /^(www\.account.*\.service\.gov\.uk|login\.service\.dev)$/.test(host) 14 | } 15 | 16 | if (!isPartOfGOVUK()) { 17 | return { 18 | allEnvironments: { 19 | name: 'GOV.UK', 20 | url: 'https://www.gov.uk' 21 | }, 22 | currentEnvironment: { 23 | name: 'Production', 24 | protocol: 'https', 25 | serviceDomain: 'publishing.service.gov.uk', 26 | host: 'https://www.gov.uk', 27 | origin: origin 28 | } 29 | } 30 | } 31 | 32 | var ENVIRONMENTS = [ 33 | { 34 | name: 'Production', 35 | protocol: 'https', 36 | serviceDomain: 'publishing.service.gov.uk', 37 | host: 'https://www.gov.uk', 38 | origin: origin 39 | }, 40 | { 41 | name: 'Staging', 42 | protocol: 'https', 43 | serviceDomain: 'staging.publishing.service.gov.uk', 44 | host: 'https://www.staging.publishing.service.gov.uk', 45 | origin: origin 46 | }, 47 | { 48 | name: 'Integration', 49 | protocol: 'https', 50 | serviceDomain: 'integration.publishing.service.gov.uk', 51 | host: 'https://www.integration.publishing.service.gov.uk', 52 | origin: origin 53 | }, 54 | { 55 | name: 'Development', 56 | protocol: 'http', 57 | serviceDomain: 'dev.gov.uk', 58 | host: 'http://www.dev.gov.uk', 59 | origin: origin 60 | } 61 | ] 62 | 63 | var application = isGOVUKAccount() ? host.split('.')[1] : host.split('.')[0] 64 | var inFrontend = application.includes('www') && !isGOVUKAccount() 65 | var environments = ENVIRONMENTS 66 | 67 | var currentEnvironment 68 | 69 | var allEnvironments = environments.map(function (env) { 70 | if (inFrontend) { 71 | var replacement = env.host 72 | } else if (isGOVUKAccount()) { 73 | replacement = env.protocol + '://www.' + application + '.' + env.serviceDomain 74 | } else { 75 | replacement = env.protocol + '://' + application + '.' + env.serviceDomain 76 | } 77 | 78 | env.url = location.replace(origin, replacement) 79 | if (env.name === 'Development' && isGOVUKAccount()) { 80 | // The GOV.UK Account is a special snowflake app with a special snowflake dev environment URL 81 | env.url = 'http://www.login.service.dev.gov.uk/' 82 | } 83 | 84 | if (location === env.url) { 85 | env.class = 'current' 86 | currentEnvironment = env 87 | } else { 88 | env.class = '' 89 | } 90 | 91 | return env 92 | }).filter(function (env) { 93 | // GOV.UK Account does not have an Integration environment – remove this option from the list of environments 94 | if (env.name === 'Integration' && isGOVUKAccount()) return false 95 | return env 96 | }) 97 | 98 | return { 99 | allEnvironments: allEnvironments, 100 | currentEnvironment: currentEnvironment 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/components/content-blocks-component/content-blocks-component.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | function ContentBlocksComponent () { 4 | this.contentBlocksHighlighted = false 5 | this.contentBlocks = Array.from(document.querySelectorAll('[data-content-block]')) 6 | 7 | chrome.runtime.onMessage.addListener(function (request) { 8 | if (request.trigger === 'toggleContentBlocks') { 9 | this.toggleHighlight() 10 | } 11 | }.bind(this)) 12 | } 13 | 14 | ContentBlocksComponent.prototype.toggleHighlight = function () { 15 | for (var i = 0; i < this.contentBlocks.length; i++) { 16 | this.contentBlocks[i].classList.toggle('highlight-content-block', !this.contentBlocksHighlighted) 17 | this.contentBlocks[i].setAttribute('data-content-block-page-identifier', `content_block_${i}`) 18 | } 19 | 20 | if (this.contentBlocksHighlighted) { 21 | this.hideContentBlocksBanner() 22 | } else { 23 | this.showContentBlocksBanner() 24 | } 25 | 26 | this.contentBlocksHighlighted = !this.contentBlocksHighlighted 27 | 28 | this.sendState() 29 | } 30 | 31 | ContentBlocksComponent.prototype.hideContentBlocksBanner = function () { 32 | var wrapper = document.querySelector('.govuk-chrome-content-blocks-banner') 33 | 34 | wrapper.remove() 35 | } 36 | 37 | ContentBlocksComponent.prototype.showContentBlocksBanner = function () { 38 | var wrapper = document.createElement('div') 39 | wrapper.classList.add('govuk-chrome-content-blocks-banner') 40 | 41 | var title = `

42 | Content Blocks 43 |

` 44 | 45 | wrapper.insertAdjacentHTML('beforeend', title) 46 | 47 | if (this.contentBlocks.length) { 48 | var instances = Map.groupBy(this.contentBlocks, function (contentBlock) { 49 | var documentType = contentBlock.dataset.documentType.replace('content_block', '').replaceAll('_', ' ') 50 | return `${documentType} - ${contentBlock.innerText}` 51 | }) 52 | 53 | instances.forEach(function (blocks, category) { 54 | var blockTitle = `

55 | ${category} 56 |

` 57 | wrapper.insertAdjacentHTML('beforeend', blockTitle) 58 | var output = '' 59 | 60 | blocks.forEach(function (contentBlock, index) { 61 | output += ` 62 | 65 | Jump to instance ${index + 1} 66 | ` 67 | }) 68 | 69 | wrapper.insertAdjacentHTML('beforeend', output) 70 | }) 71 | 72 | wrapper.querySelectorAll('.govuk-chrome-content-blocks-banner__button').forEach(function (element) { 73 | element.addEventListener('click', function (e) { 74 | e.preventDefault() 75 | var id = e.target.dataset.contentBlockTarget 76 | var element = document.querySelector(`[data-content-block-page-identifier=${id}]`) 77 | element.scrollIntoView() 78 | }) 79 | }) 80 | } else { 81 | wrapper.insertAdjacentHTML('beforeend', '

No content blocks in use on this page

') 82 | } 83 | 84 | document.body.prepend(wrapper) 85 | } 86 | 87 | ContentBlocksComponent.prototype.sendState = function () { 88 | chrome.runtime.sendMessage({ 89 | action: 'contentBlockState', 90 | highlightState: this.contentBlocksHighlighted 91 | }) 92 | } 93 | -------------------------------------------------------------------------------- /spec/javascripts/content_blocks_component.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | /* global ContentBlocksComponent loadFixtures */ 4 | 5 | describe('Content blocks component', function () { 6 | var banner 7 | var contentBlocksComponent 8 | 9 | beforeEach(function () { 10 | window.chrome = { 11 | runtime: { 12 | onMessage: { 13 | addListener: function () {} 14 | }, 15 | sendMessage: function () {} 16 | } 17 | } 18 | }) 19 | 20 | describe('when a page has content blocks', function () { 21 | beforeEach(function () { 22 | loadFixtures('content-blocks.html') 23 | 24 | contentBlocksComponent = new ContentBlocksComponent() 25 | contentBlocksComponent.toggleHighlight() 26 | 27 | banner = document.querySelector('.govuk-chrome-content-blocks-banner') 28 | }) 29 | 30 | it('highlights content blocks', function () { 31 | var contentBlocks = document.querySelectorAll('.content-block') 32 | 33 | contentBlocks.forEach(function (el) { 34 | expect(el).toHaveClass('highlight-content-block') 35 | }) 36 | }) 37 | 38 | it('shows content blocks in the banner', function () { 39 | expect(banner).not.toBeNullish() 40 | 41 | var header = banner.querySelector('.govuk-chrome-content-blocks-banner__heading') 42 | var subheading = banner.querySelector('.govuk-chrome-content-blocks-banner__subhead') 43 | var buttons = banner.querySelectorAll('.govuk-chrome-content-blocks-banner__button') 44 | 45 | expect(header.innerText).toEqual('Content Blocks') 46 | expect(subheading.innerText).toEqual('email address - enquiries@companieshouse.gov.uk') 47 | expect(buttons.length).toEqual(2) 48 | }) 49 | 50 | it('allows content blocks to be linked to', function () { 51 | var realQuerySelector = document.querySelector 52 | var buttons = banner.querySelectorAll('.govuk-chrome-content-blocks-banner__button') 53 | 54 | var stubs = Array.from(buttons).map(function (button) { 55 | var stub = document.createElement('span') 56 | stub.setAttribute('data-content-block-page-identifier', button.dataset.contentBlockTarget) 57 | return stub 58 | }) 59 | 60 | spyOn(document, 'querySelector').and.callFake(function (selector) { 61 | var stub = stubs.find(function (stub) { 62 | return stub.matches(selector) 63 | }) 64 | 65 | if (stub) { 66 | return stub 67 | } else { 68 | return realQuerySelector.call(document, selector) 69 | } 70 | }) 71 | 72 | buttons.forEach(function (button) { 73 | var stub = stubs.find(function (stub) { 74 | return stub.dataset.contentBlockPageIdentifier === button.dataset.contentBlockTarget 75 | }) 76 | var scrollspy = spyOn(stub, 'scrollIntoView') 77 | 78 | button.dispatchEvent(new Event('click')) 79 | 80 | expect(scrollspy).toHaveBeenCalled() 81 | }) 82 | }) 83 | }) 84 | 85 | describe('When no content blocks are present on a page', function () { 86 | beforeEach(function () { 87 | loadFixtures('app-c-back-to-top.html') 88 | 89 | contentBlocksComponent = new ContentBlocksComponent() 90 | contentBlocksComponent.toggleHighlight() 91 | 92 | banner = document.querySelector('.govuk-chrome-content-blocks-banner') 93 | }) 94 | 95 | it('shows a message', function () { 96 | expect(banner.innerText).toMatch('No content blocks in use on this page') 97 | }) 98 | }) 99 | }) 100 | -------------------------------------------------------------------------------- /src/components/highlight-component/highlight-component.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | function HighlightComponent () { 4 | this.isComponentsHighlighted = false 5 | this.components = extractComponentsFromPage() 6 | 7 | // Get an array of components on the page. 8 | function extractComponentsFromPage () { 9 | var componentsOnPage = document.querySelectorAll('[class*="app-c"], [class*="gem-c"]') 10 | var componentsOnPageArray = Array.from(componentsOnPage) 11 | return componentsOnPageArray.reduce(function (array, element) { 12 | var componentRegex = /(app-c-|gem-c-)([^ _\n]*(?=[ \n]|$))/ 13 | // Get the value of the components class attribute 14 | var elementClassName = null 15 | if (typeof element.className === 'string') { 16 | elementClassName = element.className 17 | } 18 | // Check if it's an app or gem component 19 | var match = false 20 | 21 | if (elementClassName) { 22 | match = elementClassName.match(componentRegex) 23 | } 24 | 25 | if (match) { 26 | array.push({ 27 | element: element, // 28 | prefix: match[1], // componentType 29 | name: match[2] // componentName 30 | }) 31 | } 32 | 33 | return array 34 | }, []) 35 | } 36 | 37 | // This is looping over the components and for each component in the array it will call the setupComponent method and pass in the component to setup. 38 | this.components.forEach(setupComponent.bind(this)) 39 | 40 | // This method, is going to modify the HTML for each component, it'll set the attribute data-component-name and data-app-name, 41 | function setupComponent (component) { 42 | component.element.setAttribute('data-component-name', component.name) 43 | component.element.setAttribute('data-app-name', component.prefix) 44 | 45 | // the method will add a click event (listener), it'll then open a new window with the documentationUrl for that component. 46 | component.element.addEventListener('click', function (event) { 47 | if (this.isComponentsHighlighted) { 48 | event.stopPropagation() // prevent event bubbling 49 | event.preventDefault() 50 | window.open(Helpers.documentationUrl(component)) 51 | } 52 | }.bind(this)) 53 | } 54 | 55 | chrome.runtime.onMessage.addListener(function (request) { 56 | if (request.trigger === 'toggleComponents') { 57 | this.toggleComponents() 58 | } 59 | }.bind(this)) 60 | } 61 | 62 | HighlightComponent.prototype.toggleComponents = function () { 63 | this.isComponentsHighlighted = !this.isComponentsHighlighted 64 | for (var i = 0; i < this.components.length; i++) { 65 | this.components[i].element.classList.toggle('highlight-component', this.isComponentsHighlighted) 66 | } 67 | 68 | this.sendState() 69 | } 70 | 71 | HighlightComponent.prototype.sendState = function () { 72 | chrome.runtime.sendMessage({ 73 | action: 'highlightState', 74 | highlightState: this.isComponentsHighlighted 75 | }) 76 | } 77 | 78 | var Helpers = { 79 | documentationUrl: function (component) { 80 | if (component.prefix.startsWith('app-c')) { 81 | return 'https://' + this.appHostname() + '.herokuapp.com/component-guide/' + component.name 82 | } else if (component.prefix.startsWith('gem-c')) { 83 | return 'https://govuk-publishing-components.herokuapp.com/component-guide/' + component.name.replace(/-/g, '_') 84 | } 85 | }, 86 | 87 | substitutions: { 88 | collections: 'govuk-collections' 89 | }, 90 | 91 | appHostname: function () { 92 | var renderingElement = document.querySelector('meta[name="govuk:rendering-app"]') 93 | var renderingApp = renderingElement.getAttribute('content') 94 | return this.substitutions[renderingApp] || renderingApp 95 | } 96 | 97 | } 98 | -------------------------------------------------------------------------------- /spec/javascripts/content_links.spec.js: -------------------------------------------------------------------------------- 1 | describe('PopupView.generateContentLinks', function () { 2 | var PROD_ENV = { protocol: 'https', serviceDomain: 'publishing.service.gov.uk', origin: 'https://www.gov.uk' } 3 | var DRAFT_PROD_ENV = { protocol: 'https', serviceDomain: 'publishing.service.gov.uk', origin: 'https://draft-origin.publishing.service.gov.uk' } 4 | 5 | it('returns the correct URIs', function () { 6 | var links = Popup.generateContentLinks( 7 | 'https://www.gov.uk/browse/disabilities?foo=bar', 8 | 'https://www.gov.uk', 9 | '/browse/disabilities', 10 | PROD_ENV 11 | ) 12 | 13 | var urls = pluck(links, 'url') 14 | 15 | expect(urls).toEqual([ 16 | 'https://www.gov.uk/browse/disabilities', 17 | 'https://www.gov.uk/api/content/browse/disabilities', 18 | 'https://www.gov.uk/api/search.json?filter_link=/browse/disabilities', 19 | 'https://draft-origin.publishing.service.gov.uk/browse/disabilities', 20 | 'https://support.publishing.service.gov.uk/anonymous_feedback?path=/browse/disabilities', 21 | 'http://webarchive.nationalarchives.gov.uk/*/https://www.gov.uk/browse/disabilities', 22 | 'https://content-data.publishing.service.gov.uk/metrics/browse/disabilities', 23 | 'https://my2.siteimprove.com/QualityAssurance/1054012/Overview/Search?SearchIn=Url&Query=/browse/disabilities', 24 | 'https://search.google.com/structured-data/testing-tool/u/0/#url=https://www.gov.uk/browse/disabilities' 25 | ]) 26 | }) 27 | 28 | it('returns the draft URIs for non-prod environments', function () { 29 | var links = Popup.generateContentLinks( 30 | 'https://www.gov.uk/browse/disabilities?foo=bar', 31 | 'https://www.gov.uk', 32 | '/browse/disabilities', 33 | { protocol: 'https', serviceDomain: 'staging.publishing.service.gov.uk' } 34 | ) 35 | 36 | var urls = pluck(links, 'url') 37 | 38 | expect(urls).toContain( 39 | 'https://draft-origin.staging.publishing.service.gov.uk/browse/disabilities' 40 | ) 41 | }) 42 | 43 | it('does not generate URIs for publishing apps (non-www pages)', function () { 44 | var links = Popup.generateContentLinks( 45 | 'https://search-admin.publishing.service.gov.uk/queries', 46 | 'https://search-admin.publishing.service.gov.uk', 47 | '/queries', 48 | PROD_ENV 49 | ) 50 | 51 | expect(links).toEqual([]) 52 | }) 53 | 54 | it("only generates URLs for publishing-apps when it's the support application", function () { 55 | var links = Popup.generateContentLinks( 56 | 'https://support.publishing.service.gov.uk/anonymous_feedback?path=/browse/disabilities', 57 | 'https://support.publishing.service.gov.uk', 58 | '/anonymous_feedback', 59 | PROD_ENV 60 | ) 61 | 62 | var urls = pluck(links, 'url') 63 | 64 | expect(urls).toEqual([ 65 | 'https://www.gov.uk/browse/disabilities', 66 | 'https://www.gov.uk/api/content/browse/disabilities', 67 | 'https://www.gov.uk/api/search.json?filter_link=/browse/disabilities', 68 | 'https://draft-origin.publishing.service.gov.uk/browse/disabilities', 69 | 'https://support.publishing.service.gov.uk/anonymous_feedback?path=/browse/disabilities', 70 | 'http://webarchive.nationalarchives.gov.uk/*/https://www.gov.uk/browse/disabilities', 71 | 'https://content-data.publishing.service.gov.uk/metrics/browse/disabilities', 72 | 'https://my2.siteimprove.com/QualityAssurance/1054012/Overview/Search?SearchIn=Url&Query=/browse/disabilities', 73 | 'https://search.google.com/structured-data/testing-tool/u/0/#url=https://www.gov.uk/browse/disabilities' 74 | ]) 75 | }) 76 | 77 | it('generates a link for smart answers', function () { 78 | var links = Popup.generateContentLinks( 79 | 'https://www.gov.uk/smart-answer/y/question-1', 80 | 'https://www.gov.uk', 81 | '/smart-answer/y/question-1', 82 | PROD_ENV, 83 | 'smartanswers' 84 | ) 85 | 86 | var urls = pluck(links, 'url') 87 | 88 | expect(urls).toContain('https://www.gov.uk/smart-answer/y/visualise') 89 | }) 90 | 91 | it('generates correct link for content API on draft stack', function () { 92 | var links = Popup.generateContentLinks( 93 | 'https://draft-origin.publishing.service.gov.uk/apply-for-and-manage-a-gov-uk-domain-name', 94 | 'https://draft-origin.publishing.service.gov.uk', 95 | '/apply-for-and-manage-a-gov-uk-domain-name', 96 | DRAFT_PROD_ENV, 97 | 'collections' 98 | ) 99 | 100 | expect(links[1]).toEqual({ 101 | name: 'Content item (JSON)', 102 | url: 'https://draft-origin.publishing.service.gov.uk/api/content/apply-for-and-manage-a-gov-uk-domain-name', 103 | class: '' 104 | }) 105 | }) 106 | }) 107 | -------------------------------------------------------------------------------- /spec/javascripts/events/ab_bucket_store.spec.js: -------------------------------------------------------------------------------- 1 | /* global abBucketStore */ 2 | 3 | describe('abBucketStore', function () { 4 | it('is initialized empty', function () { 5 | var store = abBucketStore.createStore() 6 | 7 | expect(store.getAll()).toEqual({}) 8 | }) 9 | 10 | describe('addAbTests', function () { 11 | it('does nothing if no tests are added to an already-empty store', function () { 12 | var store = abBucketStore.createStore() 13 | store.addAbTests({}, 'example.com') 14 | 15 | expect(store.getAll('example.com')).toEqual({}) 16 | }) 17 | 18 | it('adds tests to empty store', function () { 19 | var store = abBucketStore.createStore() 20 | store.addAbTests({ 21 | testName1: 'bucket1', 22 | testName2: 'bucket2' 23 | }, 'example.com') 24 | 25 | expect(store.getAll('example.com')).toEqual({ 26 | testName1: 'bucket1', 27 | testName2: 'bucket2' 28 | }) 29 | }) 30 | 31 | it('appends new tests', function () { 32 | var store = abBucketStore.createStore() 33 | store.addAbTests({ 34 | originalTest1: 'originalBucket1', 35 | originalTest2: 'originalBucket2' 36 | }, 'example.com') 37 | 38 | store.addAbTests({ 39 | originalTest1: 'updatedBucket1', 40 | newTest1: 'newBucket1', 41 | originalTest2: 'updatedBucket2', 42 | newTest2: 'newBucket2' 43 | }, 'example.com') 44 | 45 | expect(store.getAll('example.com')).toEqual({ 46 | originalTest1: 'originalBucket1', 47 | originalTest2: 'originalBucket2', 48 | newTest1: 'newBucket1', 49 | newTest2: 'newBucket2' 50 | }) 51 | }) 52 | 53 | it('stores A/B tests by domain name', function () { 54 | var store = abBucketStore.createStore() 55 | store.addAbTests({ 56 | integrationAbTest: 'bucketOnInt' 57 | }, 'www-origin.integration.publishing.service.gov.uk') 58 | store.addAbTests({ 59 | productionAbTest: 'bucketOnProd' 60 | }, 'www.gov.uk') 61 | 62 | expect(store.getAll('www-origin.integration.publishing.service.gov.uk')).toEqual({ 63 | integrationAbTest: 'bucketOnInt' 64 | }) 65 | expect(store.getAll('www.gov.uk')).toEqual({ 66 | productionAbTest: 'bucketOnProd' 67 | }) 68 | }) 69 | }) 70 | 71 | describe('getAll', function () { 72 | it('returns no A/B tests if none have been stored for that domain', function () { 73 | var store = abBucketStore.createStore() 74 | store.addAbTests({ 75 | someAbTest: 'someBucket' 76 | }, 'www.gov.uk') 77 | 78 | expect(store.getAll('example.com')).toEqual({}) 79 | }) 80 | }) 81 | 82 | describe('setBucket', function () { 83 | it('updates value of existing bucket', function () { 84 | var store = abBucketStore.createStore() 85 | store.addAbTests({ 86 | testName1: { 87 | currentBucket: 'originalBucket1', 88 | allowedBuckets: ['originalBucket1', 'updatedBucket'] 89 | }, 90 | testName2: { 91 | currentBucket: 'originalBucket2', 92 | allowedBuckets: ['originalBucket1', 'originalBucket2'] 93 | } 94 | }, 'example.com') 95 | 96 | store.setBucket('testName1', 'updatedBucket', 'example.com') 97 | 98 | expect(store.getAll('example.com')).toEqual({ 99 | testName1: { 100 | currentBucket: 'updatedBucket', 101 | allowedBuckets: ['originalBucket1', 'updatedBucket'] 102 | }, 103 | testName2: { 104 | currentBucket: 'originalBucket2', 105 | allowedBuckets: ['originalBucket1', 'originalBucket2'] 106 | } 107 | }) 108 | }) 109 | 110 | it('updates bucket with matching domain name', function () { 111 | var store = abBucketStore.createStore() 112 | store.addAbTests({ 113 | abTestName: { 114 | currentBucket: 'originalBucket', 115 | allowedBuckets: ['originalBucket', 'updatedBucket'] 116 | } 117 | }, 'www-origin.integration.publishing.service.gov.uk') 118 | store.addAbTests({ 119 | abTestName: { 120 | currentBucket: 'originalBucket', 121 | allowedBuckets: ['originalBucket', 'updatedBucket'] 122 | } 123 | }, 'www.gov.uk') 124 | 125 | store.setBucket('abTestName', 'updatedBucket', 'www.gov.uk') 126 | 127 | expect(store.getAll('www-origin.integration.publishing.service.gov.uk')).toEqual({ 128 | abTestName: { 129 | currentBucket: 'originalBucket', 130 | allowedBuckets: ['originalBucket', 'updatedBucket'] 131 | } 132 | }) 133 | expect(store.getAll('www.gov.uk')).toEqual({ 134 | abTestName: { 135 | currentBucket: 'updatedBucket', 136 | allowedBuckets: ['originalBucket', 'updatedBucket'] 137 | } 138 | }) 139 | }) 140 | }) 141 | }) 142 | -------------------------------------------------------------------------------- /src/popup/external_links.js: -------------------------------------------------------------------------------- 1 | var Popup = Popup || {} 2 | 3 | // With the content item we can generate a bunch of external links. 4 | Popup.generateExternalLinks = function (contentItem, env) { 5 | // Not all publishing_apps name corresponds to the name of the 6 | // alphagov repo. 7 | function publishingAppNameToRepo (appName) { 8 | var APP_NAMES_TO_REPOS = { 9 | smartanswers: 'smart-answers' 10 | } 11 | 12 | return APP_NAMES_TO_REPOS[appName] || appName 13 | } 14 | 15 | // Not all rendering_apps name corresponds to the name of the 16 | // alphagov repo. 17 | function renderingAppNameToRepo (appName) { 18 | var APP_NAMES_TO_REPOS = { 19 | smartanswers: 'smart-answers', 20 | 'whitehall-frontend': 'whitehall' 21 | } 22 | 23 | return APP_NAMES_TO_REPOS[appName] || appName 24 | } 25 | 26 | var links = [] 27 | 28 | generateEditLink(contentItem, env).forEach(element => { 29 | links.push(element) 30 | }) 31 | 32 | var schemaName = contentItem.schema_name || '' 33 | if (schemaName.indexOf('placeholder') !== -1) { 34 | schemaName = 'placeholder' 35 | } 36 | 37 | links.push({ 38 | name: 'Add tags in content-tagger', 39 | url: env.protocol + '://content-tagger.' + env.serviceDomain + '/content/' + contentItem.content_id 40 | }) 41 | 42 | links.push({ 43 | name: 'Rendering app: ' + contentItem.rendering_app, 44 | url: 'https://docs.publishing.service.gov.uk/apps/' + renderingAppNameToRepo(contentItem.rendering_app) + '.html' 45 | }) 46 | 47 | links.push({ 48 | name: 'Publishing app: ' + contentItem.publishing_app, 49 | url: 'https://docs.publishing.service.gov.uk/apps/' + publishingAppNameToRepo(contentItem.publishing_app) + '.html' 50 | }) 51 | 52 | links.push({ 53 | name: 'Content schema: ' + schemaName, 54 | url: 'https://docs.publishing.service.gov.uk/content-schemas/' + schemaName + '.html' 55 | }) 56 | 57 | links.push({ 58 | name: 'Document type: ' + contentItem.document_type, 59 | url: 'https://docs.publishing.service.gov.uk/document-types/' + contentItem.document_type + '.html' 60 | }) 61 | 62 | return links.filter(function (item) { return item !== undefined }) 63 | } 64 | 65 | function generateEditLink (contentItem, env) { 66 | if (contentItem.document_type === 'topic') { 67 | return [{ 68 | name: 'Edit in collections-publisher', 69 | url: env.protocol + '://collections-publisher.' + env.serviceDomain + '/specialist-sector-pages/' + contentItem.content_id 70 | }] 71 | } else if (contentItem.document_type === 'step_by_step_nav') { 72 | return [{ 73 | name: 'Look up in collections-publisher', 74 | url: env.protocol + '://collections-publisher.' + env.serviceDomain + '/step-by-step-pages' 75 | }] 76 | } else if (contentItem.document_type === 'mainstream_browse_page') { 77 | return [{ 78 | name: 'Edit in collections-publisher', 79 | url: env.protocol + '://collections-publisher.' + env.serviceDomain + '/mainstream-browse-pages/' + contentItem.content_id 80 | }] 81 | } else if (contentItem.document_type === 'taxon') { 82 | return [{ 83 | name: 'Edit in content-tagger', 84 | url: env.protocol + '://content-tagger.' + env.serviceDomain + '/taxons/' + contentItem.content_id 85 | }] 86 | } else if (contentItem.publishing_app === 'publisher') { 87 | return [{ 88 | name: 'Look up in Mainstream Publisher', 89 | url: env.protocol + '://publisher.' + env.serviceDomain + '/by-content-id/' + contentItem.content_id 90 | }] 91 | } else if (contentItem.publishing_app === 'content-publisher') { 92 | return [{ 93 | name: 'Edit in Content Publisher', 94 | url: env.protocol + '://content-publisher.' + env.serviceDomain + '/documents/' + contentItem.content_id + ':' + contentItem.locale 95 | }] 96 | } else if (contentItem.publishing_app === 'whitehall') { 97 | return [{ 98 | name: 'Edit in Whitehall Publisher', 99 | url: env.protocol + '://whitehall-admin.' + env.serviceDomain + '/government/admin/by-content-id/' + contentItem.content_id 100 | }] 101 | } else if (contentItem.document_type === 'manual') { 102 | return [{ 103 | name: 'Edit in Manuals Publisher', 104 | url: env.protocol + '://manuals-publisher.' + env.serviceDomain + '/manuals/' + contentItem.content_id 105 | }] 106 | } else if (contentItem.publishing_app === 'specialist-publisher') { 107 | return [{ 108 | name: 'Edit in Specialist Publisher', 109 | url: env.protocol + '://specialist-publisher.' + env.serviceDomain + '/' + contentItem.document_type.replace(/_/g, '-') + 's/' + contentItem.content_id 110 | }] 111 | } else if (contentItem.publishing_app === 'smartanswers') { 112 | return [ 113 | { 114 | name: 'View flow page in Github', 115 | url: 'https://github.com/alphagov/smart-answers/tree/main/app/flows' + contentItem.base_path.replace(/-/g, '_') + '_flow.rb' 116 | }, 117 | { 118 | name: 'View flow details folder in Github', 119 | url: 'https://github.com/alphagov/smart-answers/tree/main/app/flows' + contentItem.base_path.replace(/-/g, '_') + '_flow' 120 | } 121 | ] 122 | } else { 123 | return [] 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /spec/javascripts/highlight_component.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /* global HighlightComponent loadFixtures spyOnEvent setFixtures Helpers */ 3 | 4 | describe('Toggling component highlighting', function () { 5 | var breadcrumbsElement 6 | var highlightComponent 7 | 8 | beforeEach(function () { 9 | loadFixtures('gem-c-breadcrumbs.html') 10 | 11 | // Mock addListener function to call toggleComponents trigger when initialized 12 | window.chrome = { 13 | runtime: { 14 | onMessage: { 15 | addListener: function (callback) { 16 | /* eslint-disable-next-line */ 17 | callback({ trigger: 'toggleComponents' }) 18 | } 19 | }, 20 | sendMessage: function () {} 21 | } 22 | } 23 | 24 | highlightComponent = new HighlightComponent() 25 | 26 | breadcrumbsElement = document.querySelector('#jasmine-fixtures .gem-c-breadcrumbs') 27 | }) 28 | 29 | it('highlights govuk components', function () { 30 | expect(breadcrumbsElement).toHaveClass('highlight-component') 31 | }) 32 | 33 | it('exposes the component name as data attribute', function () { 34 | expect(breadcrumbsElement.dataset.componentName).toEqual('breadcrumbs') 35 | }) 36 | 37 | it('exposes the app name as data attribute', function () { 38 | expect(breadcrumbsElement.dataset.appName).toEqual('gem-c-') 39 | }) 40 | 41 | it("adds the ability to click through to the component's documentation", function () { 42 | spyOn(window, 'open').and.callThrough() 43 | var clickEvent = spyOnEvent(breadcrumbsElement, 'click') 44 | 45 | breadcrumbsElement.click() 46 | 47 | expect(clickEvent).toHaveBeenTriggered() 48 | expect(window.open).toHaveBeenCalledWith( 49 | 'https://govuk-publishing-components.herokuapp.com/component-guide/breadcrumbs' 50 | ) 51 | }) 52 | 53 | it('removes the highlight when toggled off', function () { 54 | highlightComponent.toggleComponents() 55 | 56 | expect(breadcrumbsElement).not.toHaveClass('highlight-component') 57 | }) 58 | 59 | it('removes the click functionality when toggled off', function () { 60 | spyOn(window, 'open').and.callThrough() 61 | highlightComponent.toggleComponents() 62 | 63 | var clickEvent = spyOnEvent(breadcrumbsElement, 'click') 64 | 65 | breadcrumbsElement.click() 66 | 67 | expect(clickEvent).toHaveBeenTriggered() 68 | expect(window.open).not.toHaveBeenCalled() 69 | }) 70 | }) 71 | 72 | describe('highlightComponent', function () { 73 | beforeEach(function () { 74 | window.chrome = { 75 | runtime: { 76 | onMessage: { 77 | addListener: function (callback) { } 78 | }, 79 | sendMessage: function () {} 80 | } 81 | } 82 | }) 83 | 84 | describe('components', function () { 85 | var html 86 | 87 | beforeEach(function () { 88 | loadFixtures( 89 | 'app-c-back-to-top.html', 90 | 'gem-c-breadcrumbs.html', 91 | 'gem-c-button.html', 92 | 'gem-c-label.html' 93 | ) 94 | 95 | html = document.querySelector('#jasmine-fixtures') 96 | }) 97 | 98 | it('builds an array of components', function () { 99 | var highlightComponent = new HighlightComponent() 100 | 101 | expect(highlightComponent.components).toEqual( 102 | [ 103 | { 104 | name: 'back-to-top', 105 | prefix: 'app-c-', 106 | element: html.querySelector('.app-c-back-to-top') 107 | }, 108 | { 109 | name: 'breadcrumbs', 110 | prefix: 'gem-c-', 111 | element: html.querySelector('.gem-c-breadcrumbs') 112 | }, 113 | { 114 | name: 'button', 115 | prefix: 'gem-c-', 116 | element: html.querySelector('.gem-c-button') 117 | }, 118 | { 119 | name: 'label', 120 | prefix: 'gem-c-', 121 | element: html.querySelector('.gem-c-label') 122 | } 123 | ] 124 | ) 125 | }) 126 | }) 127 | 128 | describe('toggleComponents', function () { 129 | it('toggles the internal state', function () { 130 | var highlightComponent = new HighlightComponent() 131 | 132 | expect(highlightComponent.isComponentsHighlighted).toEqual(false) 133 | 134 | highlightComponent.toggleComponents() 135 | expect(highlightComponent.isComponentsHighlighted).toEqual(true) 136 | 137 | highlightComponent.toggleComponents() 138 | expect(highlightComponent.isComponentsHighlighted).toEqual(false) 139 | }) 140 | 141 | it('toggles the highlight-component class', function () { 142 | loadFixtures('gem-c-button.html') 143 | 144 | var highlightComponent = new HighlightComponent() 145 | 146 | var buttonElement = document.querySelector('#jasmine-fixtures .gem-c-button') 147 | expect(buttonElement).not.toHaveClass('highlight-component') 148 | 149 | highlightComponent.toggleComponents() 150 | expect(buttonElement).toHaveClass('highlight-component') 151 | 152 | highlightComponent.toggleComponents() 153 | expect(buttonElement).not.toHaveClass('highlight-component') 154 | }) 155 | }) 156 | }) 157 | 158 | describe('Helpers.documentationUrl', function () { 159 | it("creates the correct URL for 'app' components with substitution", function () { 160 | setFixtures('') 161 | Helpers.substitutions = { 162 | collections: 'another_host' 163 | } 164 | expect( 165 | Helpers.documentationUrl({ 166 | prefix: 'app-c', 167 | name: 'back-to-top' 168 | }) 169 | ).toEqual( 170 | 'https://another_host.herokuapp.com/component-guide/back-to-top' 171 | ) 172 | }) 173 | 174 | it("creates the correct URL for 'app' components without substitution", function () { 175 | setFixtures('') 176 | Helpers.substitutions = { 177 | collections: 'another_host' 178 | } 179 | expect( 180 | Helpers.documentationUrl({ 181 | prefix: 'app-c', 182 | name: 'back-to-top' 183 | }) 184 | ).toEqual( 185 | 'https://rendering_app.herokuapp.com/component-guide/back-to-top' 186 | ) 187 | }) 188 | 189 | it("creates the correct URL for 'gem' components", function () { 190 | setFixtures('') 191 | Helpers.substitutions = { 192 | collections: 'another_host' 193 | } 194 | expect( 195 | Helpers.documentationUrl({ 196 | prefix: 'gem-c', 197 | name: 'label' 198 | }) 199 | ).toEqual( 200 | 'https://govuk-publishing-components.herokuapp.com/component-guide/label' 201 | ) 202 | }) 203 | }) 204 | -------------------------------------------------------------------------------- /spec/javascripts/external_links.spec.js: -------------------------------------------------------------------------------- 1 | describe('Popup.generateExternalLinks', function () { 2 | var PROD_ENV = { protocol: 'https', serviceDomain: 'publishing.service.gov.uk' } 3 | 4 | it('generates a link to the rendering app GitHub', function () { 5 | var contentItem = { 6 | rendering_app: 'collections' 7 | } 8 | 9 | var links = Popup.generateExternalLinks(contentItem, PROD_ENV) 10 | 11 | expect(links).toContain({ 12 | name: 'Rendering app: collections', 13 | url: 'https://docs.publishing.service.gov.uk/apps/collections.html' 14 | }) 15 | }) 16 | 17 | it('generates a Github link when the rendering app does not match the repository name', function () { 18 | var contentItem = { 19 | rendering_app: 'smartanswers', 20 | base_path: '/my-smart-answer' 21 | } 22 | 23 | var links = Popup.generateExternalLinks(contentItem, PROD_ENV) 24 | 25 | expect(links).toContain({ 26 | name: 'Rendering app: smartanswers', 27 | url: 'https://docs.publishing.service.gov.uk/apps/smart-answers.html' 28 | }) 29 | }) 30 | 31 | it('generates a link to the publishing app in the docs', function () { 32 | var contentItem = { 33 | publishing_app: 'collections-publisher' 34 | } 35 | 36 | var links = Popup.generateExternalLinks(contentItem, PROD_ENV) 37 | 38 | expect(links).toContain({ 39 | name: 'Publishing app: collections-publisher', 40 | url: 'https://docs.publishing.service.gov.uk/apps/collections-publisher.html' 41 | }) 42 | }) 43 | 44 | it('generates the correct docs link when a publishing app does not match the repository name', function () { 45 | var contentItem = { 46 | publishing_app: 'smartanswers', 47 | base_path: '/my-smart-answer' 48 | } 49 | 50 | var links = Popup.generateExternalLinks(contentItem, PROD_ENV) 51 | 52 | expect(links).toContain({ 53 | name: 'Publishing app: smartanswers', 54 | url: 'https://docs.publishing.service.gov.uk/apps/smart-answers.html' 55 | }) 56 | }) 57 | 58 | it('generates a link to the content schema', function () { 59 | var contentItem = { 60 | schema_name: 'topic' 61 | } 62 | 63 | var links = Popup.generateExternalLinks(contentItem, PROD_ENV) 64 | 65 | expect(links).toContain({ 66 | name: 'Content schema: topic', 67 | url: 'https://docs.publishing.service.gov.uk/content-schemas/topic.html' 68 | }) 69 | }) 70 | 71 | it('correctly links to placeholder schemas', function () { 72 | var contentItem = { 73 | schema_name: 'placeholder_something_or_other' 74 | } 75 | 76 | var links = Popup.generateExternalLinks(contentItem, PROD_ENV) 77 | 78 | expect(links).toContain({ 79 | name: 'Content schema: placeholder', 80 | url: 'https://docs.publishing.service.gov.uk/content-schemas/placeholder.html' 81 | }) 82 | }) 83 | 84 | it('correctly links to document types', function () { 85 | var contentItem = { 86 | document_type: 'announcement' 87 | } 88 | 89 | var links = Popup.generateExternalLinks(contentItem, PROD_ENV) 90 | 91 | expect(links).toContain({ 92 | name: 'Document type: announcement', 93 | url: 'https://docs.publishing.service.gov.uk/document-types/announcement.html' 94 | }) 95 | }) 96 | 97 | it('generates edit links for topics', function () { 98 | var contentItem = { 99 | content_id: '4d8568c4-67f2-48da-a578-5ac6f35b69b4', 100 | document_type: 'topic' 101 | } 102 | 103 | var links = Popup.generateExternalLinks(contentItem, PROD_ENV) 104 | 105 | expect(links).toContain({ 106 | name: 'Edit in collections-publisher', 107 | url: 'https://collections-publisher.publishing.service.gov.uk/specialist-sector-pages/4d8568c4-67f2-48da-a578-5ac6f35b69b4' 108 | }) 109 | }) 110 | 111 | it('generates edit links for mainstream browse pages', function () { 112 | var contentItem = { 113 | content_id: '4d8568c4-67f2-48da-a578-5ac6f35b69b4', 114 | document_type: 'mainstream_browse_page' 115 | } 116 | 117 | var links = Popup.generateExternalLinks(contentItem, PROD_ENV) 118 | 119 | expect(links).toContain({ 120 | name: 'Edit in collections-publisher', 121 | url: 'https://collections-publisher.publishing.service.gov.uk/mainstream-browse-pages/4d8568c4-67f2-48da-a578-5ac6f35b69b4' 122 | }) 123 | }) 124 | 125 | it('generates edit links for step by steps', function () { 126 | var contentItem = { 127 | content_id: '4d8568c4-67f2-48da-a578-5ac6f35b69b4', 128 | document_type: 'step_by_step_nav' 129 | } 130 | 131 | var links = Popup.generateExternalLinks(contentItem, PROD_ENV) 132 | 133 | expect(links).toContain({ 134 | name: 'Look up in collections-publisher', 135 | url: 'https://collections-publisher.publishing.service.gov.uk/step-by-step-pages' 136 | }) 137 | }) 138 | 139 | it('generates edit links for mainstream items', function () { 140 | var contentItem = { 141 | content_id: '4d8568c4-67f2-48da-a578-5ac6f35b69b4', 142 | publishing_app: 'publisher', 143 | base_path: '/certifying-a-document' 144 | } 145 | 146 | var links = Popup.generateExternalLinks(contentItem, PROD_ENV) 147 | 148 | expect(links).toContain({ 149 | name: 'Look up in Mainstream Publisher', 150 | url: 'https://publisher.publishing.service.gov.uk/by-content-id/4d8568c4-67f2-48da-a578-5ac6f35b69b4' 151 | }) 152 | }) 153 | 154 | it('generates edit links for Whitehall items', function () { 155 | var contentItem = { 156 | content_id: '4d8568c4-67f2-48da-a578-5ac6f35b69b4', 157 | publishing_app: 'whitehall' 158 | } 159 | 160 | var links = Popup.generateExternalLinks(contentItem, PROD_ENV) 161 | 162 | expect(links).toContain({ 163 | name: 'Edit in Whitehall Publisher', 164 | url: 'https://whitehall-admin.publishing.service.gov.uk/government/admin/by-content-id/4d8568c4-67f2-48da-a578-5ac6f35b69b4' 165 | }) 166 | }) 167 | 168 | it('generates edit links for Specalist Publisher items', function () { 169 | var contentItem = { 170 | publishing_app: 'specialist-publisher', 171 | content_id: '4dd888e6-e890-4498-9913-b89e4e5a0059', 172 | document_type: 'business_finance_support_scheme' 173 | } 174 | 175 | var links = Popup.generateExternalLinks(contentItem, PROD_ENV) 176 | 177 | expect(links).toContain({ 178 | name: 'Edit in Specialist Publisher', 179 | url: 'https://specialist-publisher.publishing.service.gov.uk/business-finance-support-schemes/4dd888e6-e890-4498-9913-b89e4e5a0059' 180 | }) 181 | }) 182 | 183 | it('generates edit links for Content Publisher items', function () { 184 | var contentItem = { 185 | publishing_app: 'content-publisher', 186 | content_id: '4d8568c4-67f2-48da-a578-5ac6f35b69b4', 187 | locale: 'cy' 188 | } 189 | 190 | var links = Popup.generateExternalLinks(contentItem, PROD_ENV) 191 | 192 | expect(links).toContain({ 193 | name: 'Edit in Content Publisher', 194 | url: 'https://content-publisher.publishing.service.gov.uk/documents/4d8568c4-67f2-48da-a578-5ac6f35b69b4:cy' 195 | }) 196 | }) 197 | 198 | it('includes a link to content-tagger', function () { 199 | var contentItem = { 200 | content_id: '4d8568c4-67f2-48da-a578-5ac6f35b69b4' 201 | } 202 | 203 | var links = Popup.generateExternalLinks(contentItem, PROD_ENV) 204 | 205 | expect(links).toContain({ 206 | name: 'Add tags in content-tagger', 207 | url: 'https://content-tagger.publishing.service.gov.uk/content/4d8568c4-67f2-48da-a578-5ac6f35b69b4' 208 | }) 209 | }) 210 | 211 | it('includes an edit link to content-tagger', function () { 212 | var contentItem = { 213 | content_id: '4d8568c4-67f2-48da-a578-5ac6f35b69b4', 214 | document_type: 'taxon' 215 | } 216 | 217 | var links = Popup.generateExternalLinks(contentItem, PROD_ENV) 218 | 219 | expect(links).toContain({ 220 | name: 'Edit in content-tagger', 221 | url: 'https://content-tagger.publishing.service.gov.uk/taxons/4d8568c4-67f2-48da-a578-5ac6f35b69b4' 222 | }) 223 | }) 224 | }) 225 | -------------------------------------------------------------------------------- /src/popup/lib/mustache.min.js: -------------------------------------------------------------------------------- 1 | (function defineMustache(global,factory){if(typeof exports==="object"&&exports&&typeof exports.nodeName!=="string"){factory(exports)}else if(typeof define==="function"&&define.amd){define(["exports"],factory)}else{global.Mustache={};factory(Mustache)}})(this,function mustacheFactory(mustache){var objectToString=Object.prototype.toString;var isArray=Array.isArray||function isArrayPolyfill(object){return objectToString.call(object)==="[object Array]"};function isFunction(object){return typeof object==="function"}function typeStr(obj){return isArray(obj)?"array":typeof obj}function escapeRegExp(string){return string.replace(/[\-\[\]{}()*+?.,\\\^$|#\s]/g,"\\$&")}function hasProperty(obj,propName){return obj!=null&&typeof obj==="object"&&propName in obj}var regExpTest=RegExp.prototype.test;function testRegExp(re,string){return regExpTest.call(re,string)}var nonSpaceRe=/\S/;function isWhitespace(string){return!testRegExp(nonSpaceRe,string)}var entityMap={"&":"&","<":"<",">":">",'"':""","'":"'","/":"/"};function escapeHtml(string){return String(string).replace(/[&<>"'\/]/g,function fromEntityMap(s){return entityMap[s]})}var whiteRe=/\s*/;var spaceRe=/\s+/;var equalsRe=/\s*=/;var curlyRe=/\s*\}/;var tagRe=/#|\^|\/|>|\{|&|=|!/;function parseTemplate(template,tags){if(!template)return[];var sections=[];var tokens=[];var spaces=[];var hasTag=false;var nonSpace=false;function stripSpace(){if(hasTag&&!nonSpace){while(spaces.length)delete tokens[spaces.pop()]}else{spaces=[]}hasTag=false;nonSpace=false}var openingTagRe,closingTagRe,closingCurlyRe;function compileTags(tagsToCompile){if(typeof tagsToCompile==="string")tagsToCompile=tagsToCompile.split(spaceRe,2);if(!isArray(tagsToCompile)||tagsToCompile.length!==2)throw new Error("Invalid tags: "+tagsToCompile);openingTagRe=new RegExp(escapeRegExp(tagsToCompile[0])+"\\s*");closingTagRe=new RegExp("\\s*"+escapeRegExp(tagsToCompile[1]));closingCurlyRe=new RegExp("\\s*"+escapeRegExp("}"+tagsToCompile[1]))}compileTags(tags||mustache.tags);var scanner=new Scanner(template);var start,type,value,chr,token,openSection;while(!scanner.eos()){start=scanner.pos;value=scanner.scanUntil(openingTagRe);if(value){for(var i=0,valueLength=value.length;i0?sections[sections.length-1][4]:nestedTokens;break;default:collector.push(token)}}return nestedTokens}function Scanner(string){this.string=string;this.tail=string;this.pos=0}Scanner.prototype.eos=function eos(){return this.tail===""};Scanner.prototype.scan=function scan(re){var match=this.tail.match(re);if(!match||match.index!==0)return"";var string=match[0];this.tail=this.tail.substring(string.length);this.pos+=string.length;return string};Scanner.prototype.scanUntil=function scanUntil(re){var index=this.tail.search(re),match;switch(index){case-1:match=this.tail;this.tail="";break;case 0:match="";break;default:match=this.tail.substring(0,index);this.tail=this.tail.substring(index)}this.pos+=match.length;return match};function Context(view,parentContext){this.view=view;this.cache={".":this.view};this.parent=parentContext}Context.prototype.push=function push(view){return new Context(view,this)};Context.prototype.lookup=function lookup(name){var cache=this.cache;var value;if(cache.hasOwnProperty(name)){value=cache[name]}else{var context=this,names,index,lookupHit=false;while(context){if(name.indexOf(".")>0){value=context.view;names=name.split(".");index=0;while(value!=null&&index")value=this.renderPartial(token,context,partials,originalTemplate);else if(symbol==="&")value=this.unescapedValue(token,context);else if(symbol==="name")value=this.escapedValue(token,context);else if(symbol==="text")value=this.rawValue(token);if(value!==undefined)buffer+=value}return buffer};Writer.prototype.renderSection=function renderSection(token,context,partials,originalTemplate){var self=this;var buffer="";var value=context.lookup(token[1]);function subRender(template){return self.render(template,context,partials)}if(!value)return;if(isArray(value)){for(var j=0,valueLength=value.length;j { 11 | const queryOptions = { active: true, lastFocusedWindow: true } 12 | // `tab` will either be a `tabs.Tab` instance or `undefined`. 13 | const [tab] = await chrome.tabs.query(queryOptions) 14 | 15 | chrome.scripting.executeScript({ 16 | target: { tabId: tab.id }, 17 | func: () => { 18 | window.highlightComponent = window.highlightComponent || new HighlightComponent() 19 | window.higlightContentBlocksComponent = window.higlightContentBlocksComponent || new ContentBlocksComponent() 20 | window.designModeComponent = window.designModeComponent || new DesignModeComponent() 21 | window.showMetaTagsComponent = window.showMetaTagsComponent || new ShowMetaTagsComponent() 22 | } 23 | }) 24 | 25 | chrome.scripting.executeScript({ 26 | target: { tabId: tab.id }, 27 | files: ['fetch-page-data.js'] 28 | }) 29 | }) 30 | 31 | // This listener waits for the `populatePopup` message to be sent, from 32 | // fetch-page-data.js (called above). It will forward the location to our main 33 | // render function. 34 | chrome.runtime.onMessage.addListener(function (request, _sender) { 35 | switch (request.action) { 36 | case 'populatePopup': 37 | renderPopup( 38 | request.currentLocation, 39 | request.currentHost, 40 | request.currentOrigin, 41 | request.currentPathname, 42 | request.renderingApplication, 43 | request.windowHeight, 44 | null // abTestBuckets 45 | ) 46 | break 47 | case 'highlightState': 48 | toggleLinkText( 49 | '#highlight-components', 50 | 'Stop highlighting components', 51 | 'Highlight Components', 52 | request.highlightState 53 | ) 54 | break 55 | case 'contentBlockState': 56 | toggleLinkText( 57 | '#highlight-content-blocks', 58 | 'Stop highlighting content blocks', 59 | 'Highlight Content Blocks', 60 | request.highlightState 61 | ) 62 | break 63 | case 'showMetaTagsState': 64 | toggleLinkText( 65 | '#highlight-meta-tags', 66 | 'Hide meta tags', 67 | 'Show meta tags', 68 | request.metaTagsState 69 | ) 70 | break 71 | case 'designModeState': 72 | toggleLinkText( 73 | '#toggle-design-mode', 74 | 'Turn off design mode', 75 | 'Turn on design mode', 76 | request.designModeState 77 | ) 78 | break 79 | default: 80 | break 81 | } 82 | }) 83 | 84 | function toggleLinkText (selector, onValue, offValue, state) { 85 | var toggleLink = document.querySelector(selector) 86 | if (state) { 87 | toggleLink.textContent = onValue 88 | } else { 89 | toggleLink.textContent = offValue 90 | } 91 | } 92 | 93 | // Render the popup. 94 | function renderPopup (location, hostname, origin, pathname, renderingApplication, windowHeight, abTestBuckets) { 95 | // Creates a view object with the data and render a template with it. 96 | var view = createView(location, hostname, origin, pathname, renderingApplication, abTestBuckets) 97 | 98 | var contentStore = view.contentLinks.find(function (el) { return el.name === 'Content item (JSON)' }) 99 | 100 | if (windowHeight < 600) { 101 | var popupContent = document.querySelector('#content') 102 | popupContent.style.overflowY = 'scroll' 103 | popupContent.style.height = windowHeight + 'px' 104 | } 105 | 106 | if (contentStore) { 107 | renderViewWithExternalLinks(contentStore.url, view, location) 108 | } else { 109 | renderView(view, location) 110 | } 111 | } 112 | 113 | async function renderViewWithExternalLinks (contentStoreUrl, view, location) { 114 | try { 115 | const response = await fetch(contentStoreUrl) 116 | const responseJson = await response.json() 117 | 118 | // update the external links array 119 | view.externalLinks = Popup.generateExternalLinks(responseJson, view.currentEnvironment) 120 | renderView(view, location) 121 | } catch (error) { 122 | renderView(view, location) 123 | } 124 | } 125 | 126 | async function renderView (view, currentUrl) { 127 | var template = document.querySelector('#template').innerHTML 128 | var popupContent = document.querySelector('#content') 129 | popupContent.innerHTML = Mustache.render(template, view) 130 | setupClicks() 131 | 132 | setupAbToggles(currentUrl) 133 | 134 | const queryOptions = { active: true, lastFocusedWindow: true } 135 | // `tab` will either be a `tabs.Tab` instance or `undefined`. 136 | const [tab] = await chrome.tabs.query(queryOptions) 137 | 138 | chrome.scripting.executeScript({ 139 | target: { tabId: tab.id }, 140 | func: () => { 141 | window.highlightComponent.sendState() 142 | window.higlightContentBlocksComponent.sendState() 143 | window.showMetaTagsComponent.sendState() 144 | window.designModeComponent.sendState() 145 | } 146 | }) 147 | } 148 | 149 | function setupClicks () { 150 | // Clicking on a link won't open the tab because we're in a separate window. 151 | // Open external links (to GitHub etc) in a new tab. 152 | var externalLinks = document.querySelectorAll('a.js-external') 153 | 154 | externalLinks.forEach(function (externalLink) { 155 | externalLink.addEventListener('click', function (e) { 156 | e.stopPropagation() 157 | if (userOpensPageInNewWindow(e)) { 158 | return 159 | } 160 | var externalLinkHref = externalLink.getAttribute('href') 161 | chrome.tabs.create({ url: externalLinkHref }) 162 | }) 163 | }) 164 | 165 | // Clicking normal links should change the current tab. The popup will not 166 | // update itself automatically, we need to re-render the popup manually. 167 | var rerenderPopup = document.querySelectorAll('a.js-rerender-popup') 168 | 169 | rerenderPopup.forEach(function (reRender) { 170 | reRender.addEventListener('click', function (e) { 171 | if (userOpensPageInNewWindow(e)) { 172 | return 173 | } 174 | e.preventDefault() 175 | 176 | var reRenderHref = reRender.getAttribute('href') 177 | chrome.tabs.update({ url: reRenderHref }) 178 | 179 | // This will provide us with a `location` object just like `window.location`. 180 | var location = document.createElement('a') 181 | location.href = reRenderHref 182 | 183 | // TODO: we're not actually re-rendering the popup correctly here, because 184 | // we don't have access to the DOM here. This is a temporary solution to 185 | // make most functionality work after the user clicks a button in the popup. 186 | renderPopup(location.href, location.hostname, location.origin, location.pathname, {}) 187 | }) 188 | }) 189 | 190 | document.querySelector('#highlight-components').addEventListener('click', function (e) { 191 | e.preventDefault() 192 | sendChromeTabMessage('toggleComponents') 193 | }) 194 | 195 | document.querySelector('#highlight-content-blocks').addEventListener('click', function (e) { 196 | e.preventDefault() 197 | sendChromeTabMessage('toggleContentBlocks') 198 | }) 199 | 200 | document.querySelector('#highlight-meta-tags').addEventListener('click', function (e) { 201 | e.preventDefault() 202 | sendChromeTabMessage('toggleMetaTags') 203 | }) 204 | 205 | document.querySelector('#toggle-design-mode').addEventListener('click', function (e) { 206 | e.preventDefault() 207 | sendChromeTabMessage('toggleDesignMode') 208 | }) 209 | } 210 | 211 | // Best guess if the user wants a new window opened. 212 | // https://stackoverflow.com/questions/20087368/how-to-detect-if-user-it-trying-to-open-a-link-in-a-new-tab 213 | function userOpensPageInNewWindow (e) { 214 | return e.ctrlKey || e.shiftKey || e.metaKey || (e.button && e.button === 1) 215 | } 216 | 217 | function setupAbToggles (url) { 218 | var abTestBuckets = document.querySelectorAll('.ab-test-bucket') 219 | 220 | abTestBuckets.forEach(function (abTestBucket) { 221 | abTestBucket.addEventListener('click', function (e) { 222 | var abTestSettings = chrome.extension.getBackgroundPage().abTestSettings 223 | 224 | abTestSettings.setBucket( 225 | abTestBucket.dataset.testName, 226 | abTestBucket.dataset.bucket, 227 | url, 228 | function () { 229 | var testBuckets = document.querySelectorAll('.ab-test-bucket') 230 | for (var testBucket of testBuckets) { 231 | testBucket.classList.remove('ab-bucket-selected') 232 | } 233 | abTestBucket.classList.add('ab-bucket-selected') 234 | chrome.tabs.reload(tab.id, { bypassCache: true }) 235 | } 236 | ) 237 | }) 238 | }) 239 | } 240 | 241 | // This is the view object. It takes a location, hostname, origin, the name of the 242 | // rendering app and a list of A/B test buckets and creates an object with all 243 | // URLs and other view data to render the popup. 244 | function createView (location, hostname, origin, pathname, renderingApplication, abTestBuckets) { 245 | var environment = Popup.environment(location, hostname, origin) 246 | var contentLinks = Popup.generateContentLinks(location, origin, pathname, environment.currentEnvironment, renderingApplication) 247 | // var abTests = Popup.findActiveAbTests(abTestBuckets) 248 | 249 | return { 250 | environments: environment.allEnvironments, 251 | currentEnvironment: environment.currentEnvironment, 252 | contentLinks: contentLinks, 253 | // external links will be populated by a call to the content store 254 | externalLinks: [], 255 | abTests: [] // abTests 256 | } 257 | } 258 | 259 | function sendChromeTabMessage (trigger) { 260 | chrome.tabs.query({ active: true, currentWindow: true }, function (tabs) { 261 | var govukTab = tabs[0] 262 | chrome.tabs.sendMessage(govukTab.id, { trigger: trigger }) 263 | }) 264 | } 265 | }()) 266 | -------------------------------------------------------------------------------- /spec/helpers/jasmine-jquery.js: -------------------------------------------------------------------------------- 1 | /*! 2 | Jasmine-jQuery: a set of jQuery helpers for Jasmine tests. 3 | 4 | Version 2.1.1 5 | 6 | https://github.com/velesin/jasmine-jquery 7 | 8 | Copyright (c) 2010-2014 Wojciech Zawistowski, Travis Jeffery 9 | 10 | Permission is hereby granted, free of charge, to any person obtaining 11 | a copy of this software and associated documentation files (the 12 | "Software"), to deal in the Software without restriction, including 13 | without limitation the rights to use, copy, modify, merge, publish, 14 | distribute, sublicense, and/or sell copies of the Software, and to 15 | permit persons to whom the Software is furnished to do so, subject to 16 | the following conditions: 17 | 18 | The above copyright notice and this permission notice shall be 19 | included in all copies or substantial portions of the Software. 20 | 21 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 22 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 23 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 24 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 25 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 26 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 27 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 28 | */ 29 | 30 | (function (root, factory) { 31 | if (typeof module !== 'undefined' && module.exports && typeof exports !== 'undefined') { 32 | factory(root, root.jasmine, require('jquery')); 33 | } else { 34 | factory(root, root.jasmine, root.jQuery); 35 | } 36 | }((function() {return this; })(), function (window, jasmine, $) { "use strict"; 37 | 38 | jasmine.spiedEventsKey = function (selector, eventName) { 39 | return [$(selector).selector, eventName].toString() 40 | } 41 | 42 | jasmine.getFixtures = function () { 43 | return jasmine.currentFixtures_ = jasmine.currentFixtures_ || new jasmine.Fixtures() 44 | } 45 | 46 | jasmine.getStyleFixtures = function () { 47 | return jasmine.currentStyleFixtures_ = jasmine.currentStyleFixtures_ || new jasmine.StyleFixtures() 48 | } 49 | 50 | jasmine.Fixtures = function () { 51 | this.containerId = 'jasmine-fixtures' 52 | this.fixturesCache_ = {} 53 | this.fixturesPath = '__spec__/javascripts/fixtures' 54 | } 55 | 56 | jasmine.Fixtures.prototype.set = function (html) { 57 | this.cleanUp() 58 | return this.createContainer_(html) 59 | } 60 | 61 | jasmine.Fixtures.prototype.appendSet= function (html) { 62 | this.addToContainer_(html) 63 | } 64 | 65 | jasmine.Fixtures.prototype.preload = function () { 66 | this.read.apply(this, arguments) 67 | } 68 | 69 | jasmine.Fixtures.prototype.load = function () { 70 | this.cleanUp() 71 | this.createContainer_(this.read.apply(this, arguments)) 72 | } 73 | 74 | jasmine.Fixtures.prototype.appendLoad = function () { 75 | this.addToContainer_(this.read.apply(this, arguments)) 76 | } 77 | 78 | jasmine.Fixtures.prototype.read = function () { 79 | var htmlChunks = [] 80 | , fixtureUrls = arguments 81 | 82 | for(var urlCount = fixtureUrls.length, urlIndex = 0; urlIndex < urlCount; urlIndex++) { 83 | htmlChunks.push(this.getFixtureHtml_(fixtureUrls[urlIndex])) 84 | } 85 | 86 | return htmlChunks.join('') 87 | } 88 | 89 | jasmine.Fixtures.prototype.clearCache = function () { 90 | this.fixturesCache_ = {} 91 | } 92 | 93 | jasmine.Fixtures.prototype.cleanUp = function () { 94 | $('#' + this.containerId).remove() 95 | } 96 | 97 | jasmine.Fixtures.prototype.sandbox = function (attributes) { 98 | var attributesToSet = attributes || {} 99 | return $('
').attr(attributesToSet) 100 | } 101 | 102 | jasmine.Fixtures.prototype.createContainer_ = function (html) { 103 | var container = $('
') 104 | .attr('id', this.containerId) 105 | .html(html) 106 | 107 | $(document.body).append(container) 108 | return container 109 | } 110 | 111 | jasmine.Fixtures.prototype.addToContainer_ = function (html){ 112 | var container = $(document.body).find('#'+this.containerId).append(html) 113 | 114 | if (!container.length) { 115 | this.createContainer_(html) 116 | } 117 | } 118 | 119 | jasmine.Fixtures.prototype.getFixtureHtml_ = function (url) { 120 | if (typeof this.fixturesCache_[url] === 'undefined') { 121 | this.loadFixtureIntoCache_(url) 122 | } 123 | return this.fixturesCache_[url] 124 | } 125 | 126 | jasmine.Fixtures.prototype.loadFixtureIntoCache_ = function (relativeUrl) { 127 | var self = this 128 | , url = this.makeFixtureUrl_(relativeUrl) 129 | , htmlText = '' 130 | , request = $.ajax({ 131 | async: false, // must be synchronous to guarantee that no tests are run before fixture is loaded 132 | cache: false, 133 | url: url, 134 | dataType: 'html', 135 | success: function (data, status, $xhr) { 136 | htmlText = $xhr.responseText 137 | } 138 | }).fail(function ($xhr, status, err) { 139 | throw new Error('Fixture could not be loaded: ' + url + ' (status: ' + status + ', message: ' + err.message + ')') 140 | }) 141 | 142 | var scripts = $($.parseHTML(htmlText, true)).find('script[src]') || []; 143 | 144 | scripts.each(function(){ 145 | $.ajax({ 146 | async: false, // must be synchronous to guarantee that no tests are run before fixture is loaded 147 | cache: false, 148 | dataType: 'script', 149 | url: $(this).attr('src'), 150 | success: function (data, status, $xhr) { 151 | htmlText += '' 152 | }, 153 | error: function ($xhr, status, err) { 154 | throw new Error('Script could not be loaded: ' + url + ' (status: ' + status + ', message: ' + err.message + ')') 155 | } 156 | }); 157 | }) 158 | 159 | self.fixturesCache_[relativeUrl] = htmlText; 160 | } 161 | 162 | jasmine.Fixtures.prototype.makeFixtureUrl_ = function (relativeUrl){ 163 | return this.fixturesPath.match('/$') ? this.fixturesPath + relativeUrl : this.fixturesPath + '/' + relativeUrl 164 | } 165 | 166 | jasmine.Fixtures.prototype.proxyCallTo_ = function (methodName, passedArguments) { 167 | return this[methodName].apply(this, passedArguments) 168 | } 169 | 170 | 171 | jasmine.StyleFixtures = function () { 172 | this.fixturesCache_ = {} 173 | this.fixturesNodes_ = [] 174 | this.fixturesPath = 'spec/fixtures' 175 | } 176 | 177 | jasmine.StyleFixtures.prototype.set = function (css) { 178 | this.cleanUp() 179 | this.createStyle_(css) 180 | } 181 | 182 | jasmine.StyleFixtures.prototype.appendSet = function (css) { 183 | this.createStyle_(css) 184 | } 185 | 186 | jasmine.StyleFixtures.prototype.preload = function () { 187 | this.read_.apply(this, arguments) 188 | } 189 | 190 | jasmine.StyleFixtures.prototype.load = function () { 191 | this.cleanUp() 192 | this.createStyle_(this.read_.apply(this, arguments)) 193 | } 194 | 195 | jasmine.StyleFixtures.prototype.appendLoad = function () { 196 | this.createStyle_(this.read_.apply(this, arguments)) 197 | } 198 | 199 | jasmine.StyleFixtures.prototype.cleanUp = function () { 200 | while(this.fixturesNodes_.length) { 201 | this.fixturesNodes_.pop().remove() 202 | } 203 | } 204 | 205 | jasmine.StyleFixtures.prototype.createStyle_ = function (html) { 206 | var styleText = $('
').html(html).text() 207 | , style = $('') 208 | 209 | this.fixturesNodes_.push(style) 210 | $('head').append(style) 211 | } 212 | 213 | jasmine.StyleFixtures.prototype.clearCache = jasmine.Fixtures.prototype.clearCache 214 | jasmine.StyleFixtures.prototype.read_ = jasmine.Fixtures.prototype.read 215 | jasmine.StyleFixtures.prototype.getFixtureHtml_ = jasmine.Fixtures.prototype.getFixtureHtml_ 216 | jasmine.StyleFixtures.prototype.loadFixtureIntoCache_ = jasmine.Fixtures.prototype.loadFixtureIntoCache_ 217 | jasmine.StyleFixtures.prototype.makeFixtureUrl_ = jasmine.Fixtures.prototype.makeFixtureUrl_ 218 | jasmine.StyleFixtures.prototype.proxyCallTo_ = jasmine.Fixtures.prototype.proxyCallTo_ 219 | 220 | jasmine.getJSONFixtures = function () { 221 | return jasmine.currentJSONFixtures_ = jasmine.currentJSONFixtures_ || new jasmine.JSONFixtures() 222 | } 223 | 224 | jasmine.JSONFixtures = function () { 225 | this.fixturesCache_ = {} 226 | this.fixturesPath = 'spec/javascripts/fixtures/json' 227 | } 228 | 229 | jasmine.JSONFixtures.prototype.load = function () { 230 | this.read.apply(this, arguments) 231 | return this.fixturesCache_ 232 | } 233 | 234 | jasmine.JSONFixtures.prototype.read = function () { 235 | var fixtureUrls = arguments 236 | 237 | for(var urlCount = fixtureUrls.length, urlIndex = 0; urlIndex < urlCount; urlIndex++) { 238 | this.getFixtureData_(fixtureUrls[urlIndex]) 239 | } 240 | 241 | return this.fixturesCache_ 242 | } 243 | 244 | jasmine.JSONFixtures.prototype.clearCache = function () { 245 | this.fixturesCache_ = {} 246 | } 247 | 248 | jasmine.JSONFixtures.prototype.getFixtureData_ = function (url) { 249 | if (!this.fixturesCache_[url]) this.loadFixtureIntoCache_(url) 250 | return this.fixturesCache_[url] 251 | } 252 | 253 | jasmine.JSONFixtures.prototype.loadFixtureIntoCache_ = function (relativeUrl) { 254 | var self = this 255 | , url = this.fixturesPath.match('/$') ? this.fixturesPath + relativeUrl : this.fixturesPath + '/' + relativeUrl 256 | 257 | $.ajax({ 258 | async: false, // must be synchronous to guarantee that no tests are run before fixture is loaded 259 | cache: false, 260 | dataType: 'json', 261 | url: url, 262 | success: function (data) { 263 | self.fixturesCache_[relativeUrl] = data 264 | }, 265 | error: function ($xhr, status, err) { 266 | throw new Error('JSONFixture could not be loaded: ' + url + ' (status: ' + status + ', message: ' + err.message + ')') 267 | } 268 | }) 269 | } 270 | 271 | jasmine.JSONFixtures.prototype.proxyCallTo_ = function (methodName, passedArguments) { 272 | return this[methodName].apply(this, passedArguments) 273 | } 274 | 275 | jasmine.jQuery = function () {} 276 | 277 | jasmine.jQuery.browserTagCaseIndependentHtml = function (html) { 278 | return $('
').append(html).html() 279 | } 280 | 281 | jasmine.jQuery.elementToString = function (element) { 282 | return $(element).map(function () { return this.outerHTML; }).toArray().join(', ') 283 | } 284 | 285 | var data = { 286 | spiedEvents: {} 287 | , handlers: [] 288 | } 289 | 290 | jasmine.jQuery.events = { 291 | spyOn: function (selector, eventName) { 292 | var handler = function (e) { 293 | var calls = (typeof data.spiedEvents[jasmine.spiedEventsKey(selector, eventName)] !== 'undefined') ? data.spiedEvents[jasmine.spiedEventsKey(selector, eventName)].calls : 0 294 | data.spiedEvents[jasmine.spiedEventsKey(selector, eventName)] = { 295 | args: arguments, // jasmine.util.argsToArray(arguments), 296 | calls: ++calls 297 | } 298 | } 299 | 300 | $(selector).on(eventName, handler) 301 | data.handlers.push(handler) 302 | 303 | return { 304 | selector: selector, 305 | eventName: eventName, 306 | handler: handler, 307 | reset: function (){ 308 | delete data.spiedEvents[jasmine.spiedEventsKey(selector, eventName)] 309 | }, 310 | calls: { 311 | count: function () { 312 | return data.spiedEvents[jasmine.spiedEventsKey(selector, eventName)] ? 313 | data.spiedEvents[jasmine.spiedEventsKey(selector, eventName)].calls : 0; 314 | }, 315 | any: function () { 316 | return data.spiedEvents[jasmine.spiedEventsKey(selector, eventName)] ? 317 | !!data.spiedEvents[jasmine.spiedEventsKey(selector, eventName)].calls : false; 318 | } 319 | } 320 | } 321 | }, 322 | 323 | args: function (selector, eventName) { 324 | var actualArgs = data.spiedEvents[jasmine.spiedEventsKey(selector, eventName)].args 325 | 326 | if (!actualArgs) { 327 | throw "There is no spy for " + eventName + " on " + selector.toString() + ". Make sure to create a spy using spyOnEvent." 328 | } 329 | 330 | return actualArgs 331 | }, 332 | 333 | wasTriggered: function (selector, eventName) { 334 | return !!(data.spiedEvents[jasmine.spiedEventsKey(selector, eventName)]) 335 | }, 336 | 337 | wasTriggeredWith: function (selector, eventName, expectedArgs, util, customEqualityTesters) { 338 | var actualArgs = jasmine.jQuery.events.args(selector, eventName).slice(1) 339 | 340 | if (Object.prototype.toString.call(expectedArgs) !== '[object Array]') 341 | actualArgs = actualArgs[0] 342 | 343 | return util.equals(actualArgs, expectedArgs, customEqualityTesters) 344 | }, 345 | 346 | wasPrevented: function (selector, eventName) { 347 | var spiedEvent = data.spiedEvents[jasmine.spiedEventsKey(selector, eventName)] 348 | , args = (jasmine.util.isUndefined(spiedEvent)) ? {} : spiedEvent.args 349 | , e = args ? args[0] : undefined 350 | 351 | return e && e.isDefaultPrevented() 352 | }, 353 | 354 | wasStopped: function (selector, eventName) { 355 | var spiedEvent = data.spiedEvents[jasmine.spiedEventsKey(selector, eventName)] 356 | , args = (jasmine.util.isUndefined(spiedEvent)) ? {} : spiedEvent.args 357 | , e = args ? args[0] : undefined 358 | 359 | return e && e.isPropagationStopped() 360 | }, 361 | 362 | cleanUp: function () { 363 | data.spiedEvents = {} 364 | data.handlers = [] 365 | } 366 | } 367 | 368 | var hasProperty = function (actualValue, expectedValue) { 369 | if (expectedValue === undefined) 370 | return actualValue !== undefined 371 | 372 | return actualValue === expectedValue 373 | } 374 | 375 | beforeEach(function () { 376 | jasmine.addMatchers({ 377 | toHaveClass: function () { 378 | return { 379 | compare: function (actual, className) { 380 | return { pass: $(actual).hasClass(className) } 381 | } 382 | } 383 | }, 384 | 385 | toHaveCss: function () { 386 | return { 387 | compare: function (actual, css) { 388 | var stripCharsRegex = /[\s;\"\']/g 389 | for (var prop in css) { 390 | var value = css[prop] 391 | // see issue #147 on gh 392 | ;if ((value === 'auto') && ($(actual).get(0).style[prop] === 'auto')) continue 393 | var actualStripped = $(actual).css(prop).replace(stripCharsRegex, '') 394 | var valueStripped = value.replace(stripCharsRegex, '') 395 | if (actualStripped !== valueStripped) return { pass: false } 396 | } 397 | return { pass: true } 398 | } 399 | } 400 | }, 401 | 402 | toBeVisible: function () { 403 | return { 404 | compare: function (actual) { 405 | return { pass: $(actual).is(':visible') } 406 | } 407 | } 408 | }, 409 | 410 | toBeHidden: function () { 411 | return { 412 | compare: function (actual) { 413 | return { pass: $(actual).is(':hidden') } 414 | } 415 | } 416 | }, 417 | 418 | toBeSelected: function () { 419 | return { 420 | compare: function (actual) { 421 | return { pass: $(actual).is(':selected') } 422 | } 423 | } 424 | }, 425 | 426 | toBeChecked: function () { 427 | return { 428 | compare: function (actual) { 429 | return { pass: $(actual).is(':checked') } 430 | } 431 | } 432 | }, 433 | 434 | toBeEmpty: function () { 435 | return { 436 | compare: function (actual) { 437 | return { pass: $(actual).is(':empty') } 438 | } 439 | } 440 | }, 441 | 442 | toBeInDOM: function () { 443 | return { 444 | compare: function (actual) { 445 | return { pass: $.contains(document.documentElement, $(actual)[0]) } 446 | } 447 | } 448 | }, 449 | 450 | toExist: function () { 451 | return { 452 | compare: function (actual) { 453 | return { pass: $(actual).length } 454 | } 455 | } 456 | }, 457 | 458 | toHaveLength: function () { 459 | return { 460 | compare: function (actual, length) { 461 | return { pass: $(actual).length === length } 462 | } 463 | } 464 | }, 465 | 466 | toHaveAttr: function () { 467 | return { 468 | compare: function (actual, attributeName, expectedAttributeValue) { 469 | return { pass: hasProperty($(actual).attr(attributeName), expectedAttributeValue) } 470 | } 471 | } 472 | }, 473 | 474 | toHaveProp: function () { 475 | return { 476 | compare: function (actual, propertyName, expectedPropertyValue) { 477 | return { pass: hasProperty($(actual).prop(propertyName), expectedPropertyValue) } 478 | } 479 | } 480 | }, 481 | 482 | toHaveId: function () { 483 | return { 484 | compare: function (actual, id) { 485 | return { pass: $(actual).attr('id') == id } 486 | } 487 | } 488 | }, 489 | 490 | toHaveHtml: function () { 491 | return { 492 | compare: function (actual, html) { 493 | return { pass: $(actual).html() == jasmine.jQuery.browserTagCaseIndependentHtml(html) } 494 | } 495 | } 496 | }, 497 | 498 | toContainHtml: function () { 499 | return { 500 | compare: function (actual, html) { 501 | var actualHtml = $(actual).html() 502 | , expectedHtml = jasmine.jQuery.browserTagCaseIndependentHtml(html) 503 | 504 | return { pass: (actualHtml.indexOf(expectedHtml) >= 0) } 505 | } 506 | } 507 | }, 508 | 509 | toHaveText: function () { 510 | return { 511 | compare: function (actual, text) { 512 | var actualText = $(actual).text() 513 | var trimmedText = $.trim(actualText) 514 | 515 | if (text && $.isFunction(text.test)) { 516 | return { pass: text.test(actualText) || text.test(trimmedText) } 517 | } else { 518 | return { pass: (actualText == text || trimmedText == text) } 519 | } 520 | } 521 | } 522 | }, 523 | 524 | toContainText: function () { 525 | return { 526 | compare: function (actual, text) { 527 | var trimmedText = $.trim($(actual).text()) 528 | 529 | if (text && $.isFunction(text.test)) { 530 | return { pass: text.test(trimmedText) } 531 | } else { 532 | return { pass: trimmedText.indexOf(text) != -1 } 533 | } 534 | } 535 | } 536 | }, 537 | 538 | toHaveValue: function () { 539 | return { 540 | compare: function (actual, value) { 541 | return { pass: $(actual).val() === value } 542 | } 543 | } 544 | }, 545 | 546 | toHaveData: function () { 547 | return { 548 | compare: function (actual, key, expectedValue) { 549 | return { pass: hasProperty($(actual).data(key), expectedValue) } 550 | } 551 | } 552 | }, 553 | 554 | toContainElement: function () { 555 | return { 556 | compare: function (actual, selector) { 557 | return { pass: $(actual).find(selector).length } 558 | } 559 | } 560 | }, 561 | 562 | toBeMatchedBy: function () { 563 | return { 564 | compare: function (actual, selector) { 565 | return { pass: $(actual).filter(selector).length } 566 | } 567 | } 568 | }, 569 | 570 | toBeDisabled: function () { 571 | return { 572 | compare: function (actual, selector) { 573 | return { pass: $(actual).is(':disabled') } 574 | } 575 | } 576 | }, 577 | 578 | toBeFocused: function (selector) { 579 | return { 580 | compare: function (actual, selector) { 581 | return { pass: $(actual)[0] === $(actual)[0].ownerDocument.activeElement } 582 | } 583 | } 584 | }, 585 | 586 | toHandle: function () { 587 | return { 588 | compare: function (actual, event) { 589 | if ( !actual || actual.length === 0 ) return { pass: false }; 590 | var events = $._data($(actual).get(0), "events") 591 | 592 | if (!events || !event || typeof event !== "string") { 593 | return { pass: false } 594 | } 595 | 596 | var namespaces = event.split(".") 597 | , eventType = namespaces.shift() 598 | , sortedNamespaces = namespaces.slice(0).sort() 599 | , namespaceRegExp = new RegExp("(^|\\.)" + sortedNamespaces.join("\\.(?:.*\\.)?") + "(\\.|$)") 600 | 601 | if (events[eventType] && namespaces.length) { 602 | for (var i = 0; i < events[eventType].length; i++) { 603 | var namespace = events[eventType][i].namespace 604 | 605 | if (namespaceRegExp.test(namespace)) 606 | return { pass: true } 607 | } 608 | } else { 609 | return { pass: (events[eventType] && events[eventType].length > 0) } 610 | } 611 | 612 | return { pass: false } 613 | } 614 | } 615 | }, 616 | 617 | toHandleWith: function () { 618 | return { 619 | compare: function (actual, eventName, eventHandler) { 620 | if ( !actual || actual.length === 0 ) return { pass: false }; 621 | var normalizedEventName = eventName.split('.')[0] 622 | , stack = $._data($(actual).get(0), "events")[normalizedEventName] 623 | 624 | for (var i = 0; i < stack.length; i++) { 625 | if (stack[i].handler == eventHandler) return { pass: true } 626 | } 627 | 628 | return { pass: false } 629 | } 630 | } 631 | }, 632 | 633 | toHaveBeenTriggeredOn: function () { 634 | return { 635 | compare: function (actual, selector) { 636 | var result = { pass: jasmine.jQuery.events.wasTriggered(selector, actual) } 637 | 638 | result.message = result.pass ? 639 | "Expected event " + $(actual) + " not to have been triggered on " + selector : 640 | "Expected event " + $(actual) + " to have been triggered on " + selector 641 | 642 | return result; 643 | } 644 | } 645 | }, 646 | 647 | toHaveBeenTriggered: function (){ 648 | return { 649 | compare: function (actual) { 650 | var eventName = actual.eventName 651 | , selector = actual.selector 652 | , result = { pass: jasmine.jQuery.events.wasTriggered(selector, eventName) } 653 | 654 | result.message = result.pass ? 655 | "Expected event " + eventName + " not to have been triggered on " + selector : 656 | "Expected event " + eventName + " to have been triggered on " + selector 657 | 658 | return result 659 | } 660 | } 661 | }, 662 | 663 | toHaveBeenTriggeredOnAndWith: function (j$, customEqualityTesters) { 664 | return { 665 | compare: function (actual, selector, expectedArgs) { 666 | var wasTriggered = jasmine.jQuery.events.wasTriggered(selector, actual) 667 | , result = { pass: wasTriggered && jasmine.jQuery.events.wasTriggeredWith(selector, actual, expectedArgs, j$, customEqualityTesters) } 668 | 669 | if (wasTriggered) { 670 | var actualArgs = jasmine.jQuery.events.args(selector, actual, expectedArgs)[1] 671 | result.message = result.pass ? 672 | "Expected event " + actual + " not to have been triggered with " + jasmine.pp(expectedArgs) + " but it was triggered with " + jasmine.pp(actualArgs) : 673 | "Expected event " + actual + " to have been triggered with " + jasmine.pp(expectedArgs) + " but it was triggered with " + jasmine.pp(actualArgs) 674 | 675 | } else { 676 | // todo check on this 677 | result.message = result.pass ? 678 | "Expected event " + actual + " not to have been triggered on " + selector : 679 | "Expected event " + actual + " to have been triggered on " + selector 680 | } 681 | 682 | return result 683 | } 684 | } 685 | }, 686 | 687 | toHaveBeenPreventedOn: function () { 688 | return { 689 | compare: function (actual, selector) { 690 | var result = { pass: jasmine.jQuery.events.wasPrevented(selector, actual) } 691 | 692 | result.message = result.pass ? 693 | "Expected event " + actual + " not to have been prevented on " + selector : 694 | "Expected event " + actual + " to have been prevented on " + selector 695 | 696 | return result 697 | } 698 | } 699 | }, 700 | 701 | toHaveBeenPrevented: function () { 702 | return { 703 | compare: function (actual) { 704 | var eventName = actual.eventName 705 | , selector = actual.selector 706 | , result = { pass: jasmine.jQuery.events.wasPrevented(selector, eventName) } 707 | 708 | result.message = result.pass ? 709 | "Expected event " + eventName + " not to have been prevented on " + selector : 710 | "Expected event " + eventName + " to have been prevented on " + selector 711 | 712 | return result 713 | } 714 | } 715 | }, 716 | 717 | toHaveBeenStoppedOn: function () { 718 | return { 719 | compare: function (actual, selector) { 720 | var result = { pass: jasmine.jQuery.events.wasStopped(selector, actual) } 721 | 722 | result.message = result.pass ? 723 | "Expected event " + actual + " not to have been stopped on " + selector : 724 | "Expected event " + actual + " to have been stopped on " + selector 725 | 726 | return result; 727 | } 728 | } 729 | }, 730 | 731 | toHaveBeenStopped: function () { 732 | return { 733 | compare: function (actual) { 734 | var eventName = actual.eventName 735 | , selector = actual.selector 736 | , result = { pass: jasmine.jQuery.events.wasStopped(selector, eventName) } 737 | 738 | result.message = result.pass ? 739 | "Expected event " + eventName + " not to have been stopped on " + selector : 740 | "Expected event " + eventName + " to have been stopped on " + selector 741 | 742 | return result 743 | } 744 | } 745 | } 746 | }) 747 | 748 | jasmine.getEnv().addCustomEqualityTester(function(a, b) { 749 | if (a && b) { 750 | if (a instanceof $ || jasmine.isDomNode(a)) { 751 | var $a = $(a) 752 | 753 | if (b instanceof $) 754 | return $a.length == b.length && $a.is(b) 755 | 756 | return $a.is(b); 757 | } 758 | 759 | if (b instanceof $ || jasmine.isDomNode(b)) { 760 | var $b = $(b) 761 | 762 | if (a instanceof $) 763 | return a.length == $b.length && $b.is(a) 764 | 765 | return $b.is(a); 766 | } 767 | } 768 | }) 769 | 770 | jasmine.getEnv().addCustomEqualityTester(function (a, b) { 771 | if (a instanceof $ && b instanceof $ && a.size() == b.size()) 772 | return a.is(b) 773 | }) 774 | }) 775 | 776 | afterEach(function () { 777 | jasmine.getFixtures().cleanUp() 778 | jasmine.getStyleFixtures().cleanUp() 779 | jasmine.jQuery.events.cleanUp() 780 | }) 781 | 782 | window.readFixtures = function () { 783 | return jasmine.getFixtures().proxyCallTo_('read', arguments) 784 | } 785 | 786 | window.preloadFixtures = function () { 787 | jasmine.getFixtures().proxyCallTo_('preload', arguments) 788 | } 789 | 790 | window.loadFixtures = function () { 791 | jasmine.getFixtures().proxyCallTo_('load', arguments) 792 | } 793 | 794 | window.appendLoadFixtures = function () { 795 | jasmine.getFixtures().proxyCallTo_('appendLoad', arguments) 796 | } 797 | 798 | window.setFixtures = function (html) { 799 | return jasmine.getFixtures().proxyCallTo_('set', arguments) 800 | } 801 | 802 | window.appendSetFixtures = function () { 803 | jasmine.getFixtures().proxyCallTo_('appendSet', arguments) 804 | } 805 | 806 | window.sandbox = function (attributes) { 807 | return jasmine.getFixtures().sandbox(attributes) 808 | } 809 | 810 | window.spyOnEvent = function (selector, eventName) { 811 | return jasmine.jQuery.events.spyOn(selector, eventName) 812 | } 813 | 814 | window.preloadStyleFixtures = function () { 815 | jasmine.getStyleFixtures().proxyCallTo_('preload', arguments) 816 | } 817 | 818 | window.loadStyleFixtures = function () { 819 | jasmine.getStyleFixtures().proxyCallTo_('load', arguments) 820 | } 821 | 822 | window.appendLoadStyleFixtures = function () { 823 | jasmine.getStyleFixtures().proxyCallTo_('appendLoad', arguments) 824 | } 825 | 826 | window.setStyleFixtures = function (html) { 827 | jasmine.getStyleFixtures().proxyCallTo_('set', arguments) 828 | } 829 | 830 | window.appendSetStyleFixtures = function (html) { 831 | jasmine.getStyleFixtures().proxyCallTo_('appendSet', arguments) 832 | } 833 | 834 | window.loadJSONFixtures = function () { 835 | return jasmine.getJSONFixtures().proxyCallTo_('load', arguments) 836 | } 837 | 838 | window.getJSONFixture = function (url) { 839 | return jasmine.getJSONFixtures().proxyCallTo_('read', arguments)[url] 840 | } 841 | })); 842 | --------------------------------------------------------------------------------