Compute SHA256 from HTML

Compute and compare SHA codes

📺 Watch this recipe explained in the video Compute And Compare SHA-256 From HTMLopen in new window.

<div id="greeting">
  <span id="form">Hello</span>, <span id="target">World</span>!
</div>

Here is an example async function computing the SHA-256 digest using the browser built-in APIs. Take from Mozilla Docsopen in new window

async function digestMessage(message) {
  // encode as (utf-8) Uint8Array
  const msgUint8 = new TextEncoder().encode(message)
  const hashBuffer = await crypto.subtle.digest(
    'SHA-256',
    msgUint8,
  ) // hash the message
  const hashArray = Array.from(new Uint8Array(hashBuffer))
  const hashHex = hashArray
    .map((b) => b.toString(16).padStart(2, '0'))
    .join('') // convert bytes to hex string
  return hashHex
}

We can compute SHA of any element (I like trimming the HTML string before computing its digest)

cy.get('#greeting')
  .invoke('html')
  .invoke('trim')
  .then(digestMessage)
  .should('be.a', 'string')

Let's confirm the SHA changes when the HTML inside the element changes.

cy.get('#greeting')
  .invoke('html')
  .invoke('trim')
  .then(digestMessage)
  .should('be.a', 'string')
  .then((sha) => {
    // change something on the page
    cy.get('#target').invoke('text', 'You')
    cy.get('#greeting')
      .invoke('html')
      .invoke('trim')
      // sha changes
      .then(digestMessage)
      .should('not.equal', sha)
  })

If we plan to compute SHA across our project, we can create a custom child command

Cypress.Commands.add(
  'sha',
  { prevSubject: 'element' },
  ($el) => {
    // put a message to Command Log
    Cypress.log({ name: 'sha' })
    const html = $el.html().trim()
    return digestMessage(html)
  },
)

Let's use the new cy.sha command to confirm the SHA changes when the element's HTML changes

cy.get('#greeting')
  .sha()
  .then((t) => {
    cy.get('#target').invoke('text', 'everyone')
    cy.get('#greeting').sha().should('not.equal', t)
  })

Warning: the above test is bad practice. The SHA of HTML can change for many reasons, your tests should confirm the HTML changes in the expected way.

Avoid using SHA only

Using SHA hash code to confirm the element changes is a bad practice in opinion. It literally says "element's HTML should not be THAT", while a good practice would use a positive assertion "element should be X". There are lots of reasons the element's HTML changes - and those changes might not matter to the user at all. Here is a situation that passes the "SHA changes" test, but shows a broken page.

<button id="load">Load data</button>
<div id="output" />
<script>
  document
    .getElementById('load')
    .addEventListener('click', () => {
      document.getElementById('output').innerHTML = `
        <div>Error loading...</div>
      `
    })
</script>

Let's click on the button and confirm the output changes its content.

async function digestMessage(message) {
  // encode as (utf-8) Uint8Array
  const msgUint8 = new TextEncoder().encode(message)
  const hashBuffer = await crypto.subtle.digest(
    'SHA-256',
    msgUint8,
  ) // hash the message
  const hashArray = Array.from(new Uint8Array(hashBuffer))
  const hashHex = hashArray
    .map((b) => b.toString(16).padStart(2, '0'))
    .join('') // convert bytes to hex string
  return hashHex
}

Cypress.Commands.add(
  'sha',
  { prevSubject: 'element' },
  ($el) => {
    // put a message to Command Log
    Cypress.log({ name: 'sha' })
    const html = $el.html().trim()
    return digestMessage(html)
  },
)
cy.get('#output')
  .sha()
  .then((sha) => {
    cy.contains('button', 'Load data').click()
    cy.get('#output').sha().should('not.equal', sha)
  })

The page does change and the element's HTML has a new SHA, but is this a good test? Absolutely not. The page shows an error element, not successfully loaded data. The test should confirm one successful output, not try to assertion that a million different possible other outputs do not appear.

SHA code changes even when the only changes are invisible to the user, like whitespace around elements.

cy.log('a space at the end')
cy.wrap(
  Promise.all([
    digestMessage('<div>Hello</div>'),
    digestMessage('<div>Hello</div> '),
  ]),
  { log: false },
).then(([sha1, sha2]) => {
  expect(sha1, 'extra space at the end').to.not.equal(sha2)
})

Note: there is also a subtle timing bug in the above test, something that affects every test framework that relies only on promises or async/await syntax to execute its commands.

A better test

A much better test would confirm the specific expected output appears.

<button id="load">Load data</button>
<div id="output" />
<script>
  document
    .getElementById('load')
    .addEventListener('click', () => {
      const error = '<div class="error">Error loading...</div>'
      const data = '<div class="data">Joe 02177</div>'
      // change the limit to simulate different outcomes
      // if the limit is 0 then no errors will be shown ever
      // if the limit is 1 only the error is shown
      const showError = Math.random() < 0
      document.getElementById('output').innerHTML = showError
        ? error
        : data
    })
</script>
cy.contains('button', 'Load data').click()

Use a positive assertion of what should appear during successful flow

cy.get('#output .data')
  .should('be.visible')
  // if you know something about the data, check it
  .and('include.text', 'Joe')

After the positive assertion passes, confirm negative outcomes, like the error element should not exist

cy.get('.error').should('not.exist')

See also