-
-
Notifications
You must be signed in to change notification settings - Fork 1.2k
Track form submission events #5381
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Conversation
Analyzed 1025 tracker script variants for size changes. Main variants:
Important legacy variants:
Summary:
In total, 327 variants brotli size increased and 232 variants brotli size decreased. |
tracker/src/plausible.js
Outdated
function attachFormSubmitTracking() { | ||
document.addEventListener('submit', trackFormSubmission, true); | ||
} | ||
|
||
attachFormSubmitTracking(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
function attachFormSubmitTracking() { | |
document.addEventListener('submit', trackFormSubmission, true); | |
} | |
attachFormSubmitTracking(); | |
document.addEventListener('submit', trackFormSubmission, true); |
Unneeded indirection.
tracker/src/plausible.js
Outdated
if (COMPILE_CONFIG && config.trackFormSubmissions) { | ||
function trackFormSubmission(e) { | ||
if (e.target.hasAttribute('novalidate') || e.target.checkValidity()) { | ||
plausible('WP Form Completions', { props: { path: location.pathname } }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The event name is incorrect. Don't worry about wordpress in the current stage, we'll figure out a way to deal with their legacy events/goals later.
plausible('WP Form Completions', { props: { path: location.pathname } }); | |
plausible('Form submission', { props: { path: location.pathname } }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Went with 'Form Submission' because it's more inline with 'File Download' etc, exact name TBD.
bodyContent: string | ||
} | ||
|
||
export function initializePageDynamically( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This function creates yet another way data is passed to the page. Could we get by creating another version of tracker/test/fixtures/plausible-main.html and dynamically set bodyContent based on a query parameter in an already-set div?
This would keep the approach more consistent across all tests and would remove code from both server.js and yet another helper.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The background here is that I found existing tests hard to work on without a direct link between the HTML and actual test. I couldn't easily tell which parts of the fixture were for which test case.
I guess it wasn't clear that I am proposing this (and the accompanying dynamic route) as an iterative improvement over all existing test setups, including plausible-main and openPage.
As it's a proposal and WIP, I didn't yet refactor all other tests, nor give it all the features to be able to unify all tests. Consistency is not yet in place, but would be if we go forward with this.
The benefits of using this is that it brings almost all of the test setup inside the test. I think the diversity in form related tests proves its usefulness.
A downside is that my IDE doesn't help with this dynamic HTML. I believe it's possible to get most IDEs and prettier to work with it though.
const content = /* html */`
<form>...</form>
`
Good call to investigate the way we deliver bodyContent and the configured script. I didn't try passing the DOM over query param yet, but I don't see why it shouldn't work. I tried header first actually, and when that failed, landed on the Pagewright API that worked well enough for the proposal.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Tried passing this over query params and I got it to render the UI. However, the default form submit action replaces query params with fields from the form, which then results in the express server rendering an empty page in response... The Playwright API that I'm using doesn't have that issue. The Playwright API seems good enough to me, pretty direct as well.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Though on further thought, maybe the ideal would be to prerender the page or pages at the start of the test:
async function setContent(path, { bodyContent, headContent }) {
// Sends a POST request to an Express route, which
// instructs the server to render the provided HTML content
// and save it to the statically served folder on disk, at the `path`.
}
// Usage in test case:
await setContent(`/${testId}/any/url`, {
bodyContent: '<form><input type="submit" value="Submit"></form>',
headContent: '<script>...script snippet with config injected</script>'
})
await page.goto(`/${testId}/any/url`)
await page.click('input[type="submit"]')
// ...expectations
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I see, this makes sense. I didn't consider that form submissions navigate 🤦
I think integrating with the express server dynamically is quite tricky 😅
Regarding setContent
idea, perhaps creating a mock route would be simpler route?
E.g something like this.
const HTML_TEMPLATE = fs.readFileSync(...)
const ROUTE = "/formSubmissions"
async function openFormPage(page, { path, bodyContent, headContent, config }) {
const body = HTML_TEMPLATE.replace(...).replace(...)
// Ignorning the script-config schenanigans.
page.route(ROUTE, (route) => route.fulfill({ status: 200, contentType: 'text/html', body }))
await page.goto(ROUTE)
}
(I'm not sure whether path is behaviorally important, so made it constant here)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Love the idea of increasing cohesion in tests by inlining the HTML ❤️ I also find it hard to work with fixtures especially when they serve multiple different tests/assertions.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We decided that initializePageDynamically
utility will stay in and will be shared between tests that test features in depth.
I refactored this dynamic HTML not to go through our test server. Instead it's declared as a Playwright route. I didn't realize it at first, but route()
API actually has a few gotchas. AFAICT they don't apply to our situation yet, so it's mergable, just a bit annoying to create potential debt.
It overlaps a bit with openPage
helper in plausible-main.spec.js
, but that helper and its accompanying fixture are specifically built to test script initialization, config overrides, and feature toggling, which require much more cleverness than initializePageDynamically
. There's a general solution that covers both cases, but decided not to seek it yet.
tracker/test/support/test-utils.js
Outdated
@@ -231,3 +233,13 @@ function checkEqual(a, b) { | |||
function delay(ms) { | |||
return new Promise(resolve => setTimeout(resolve, ms)) | |||
} | |||
|
|||
export async function openPage(page, config, options = {}) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This 'helper' is still plausible-main specific. Let's move it back.
|
||
test('limitation: tracks _all_ forms on the same page, but _records them indistinguishably_', async ({ | ||
page | ||
}, { testId }) => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why do we need testId
in these tests? Looking at the test it is just noise, but I might be missing something.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
At the time you commented, it wasn't really needed, but now it is 😅 It ensures there's no interplay between dynamically populated pages and saves us from having to think of a name for the dynamic route.
}) | ||
}) | ||
|
||
test('tracks form submissions triggered with submit button', async ({ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nitpick, not blocking: Move this test first in the describe. Happy path first over 'limitations'
tracker/test/support/types.ts
Outdated
fileDownloads: boolean | ||
taggedEvents: boolean | ||
trackFormSubmissions: boolean | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: Formatting issues in this file.
scriptConfig: { ...DEFAULT_CONFIG, trackFormSubmissions: true }, | ||
bodyContent: ` | ||
<div> | ||
<form onsubmit="event.preventDefault(); console.log('Form submitted')"> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Q: Why do we do the event.preventDefault here and in other tests? Given that it's in the test code I assumed it's important for the correctness of the test, but it doesn't seem so.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Valid question! I agree that the test should contain only that which is strictly necessary. This is to eliminate page reload from form submit actions as the cause of test flakiness.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think that makes sense, though you still seem to struggle with flakiness. Could we remove it from the inlined html here and move it to the template page?
It being here makes the reader of the test think it's somehow related to the scenario under test (instead of testing harness).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
They're still needed to avoid flakiness due to Playwright Firefox version. We can think of them as scenarios where the customer's form has a custom onsubmit handler. Most of them can be deleted when we update playwright to make the test scenarios a bit shorter.
await expectPlausibleInAction(page, { | ||
action: async () => { | ||
await page.goto(`/dynamic/${testId}`) | ||
await page.click('button#trigger') |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
await page.click('button#trigger') | |
await page.click('#submit') |
Nit: terminology
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do you mean that I should apply this suggestion AND also change the id
of the button? I am specifically using an id
that makes it distinguishable from submit clicks in other tests.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah, makes sense. In that case:
Nit: perhaps #javascript-submit
would be a better id?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Went with trigger-FormElement-submit
2b0f454
to
5d64bc4
Compare
3290bae
to
e5c2440
Compare
0427ecf
to
67250d2
Compare
// this conditional is needed because fetch with keepalive is not implemented in the version of Firefox used by Playwright: | ||
// can be removed once the Firefox version is >= v133 | ||
browserName === "firefox" | ||
? 'onsubmit="event.preventDefault(); setTimeout(() => {this.submit()}, 200)"' | ||
: "" | ||
}> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There's a better syntax for skipping tests: https://playwright.dev/docs/test-annotations#conditionally-skip-a-test
test('skip this test', async ({ page, browserName }) => {
test.skip(browserName === 'firefox', 'Still working on it');
});
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for the link! I'll definitely use that when I want to skip tests by browser. At the moment, this test is not skipped though, right? It is demonstrating that even on older versions of Firefox without keepalive support, there's a chance that the Form Submission event is sent.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Currently this test is two different tests: one on firefox, one elsewhere.
Given that the expected behavior is broken on our playwright firefox version, I think skipping is a better option and communicates intent better. Both options can also be easily cleaned up in a future PR.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can you clarify what the intent is and who we are communicating it to?
As I see it, the intent of the test suite is to communicate how the feature works, and we are communicating it to each other and the engineers of our users.
This branched test (which I agree is effectively two tests, just with less repetition) currently communicates more than a skipped test: the feature still works on older versions of Firefox if the submit navigation is not really fast.
(window.plausible = | ||
window.plausible || | ||
function () { | ||
(window.plausible.q = window.plausible.q || []).push(arguments); | ||
}), | ||
(window.plausible.init = function (i) { | ||
window.plausible.o = i || {}; | ||
}); | ||
var script = document.createElement("script"); | ||
(script.type = "text/javascript"), | ||
(script.defer = !0), | ||
(script.src = "<%= plausible_script_url %>"); | ||
var r = document.getElementsByTagName("script")[0]; | ||
r.parentNode.insertBefore(script, r); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
non-blocking q: Why is this beautified? It'd be better to take this snippet:
└> node compile.js --web-snippet
<script>
window.plausible=window.plausible||function(){(window.plausible.q=window.plausible.q||[]).push(arguments)},window.plausible.init=function(i){window.plausible.o=i||{}};var script=document.createElement("script");script.type="text/javascript",script.defer=!0,script.src="<%= plausible_script_url %>";var r=document.getElementsByTagName("script")[0];r.parentNode.insertBefore(script,r);
plausible.init()
</script>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah, I did take that exactly snippet, but when I autoformat the HTML template, it adds whitespace to the script, which makes it look like the snippet source. I didn't see much point in fighting with that, it's just whitespace. The bigger improvement would be to make an actual hard link between the compiled snippet and dynamic page snippet.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've hard-linked the two in 7622b23
It's not exactly fool-proof in my opinion and I could roll the commit back, but at least it takes the manual diffing out.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's roll it back for now, not important and can make more consistent later if needed.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've rolled it back, but I don't understand your point on making it more consistent later if needed. How can it be any more consistent than using the same function that node compile.js --web-snippet
is using?
@@ -0,0 +1,306 @@ | |||
import { test, Page } from "@playwright/test"; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: Let's remove the semicolons for consistency, most specs don't use them
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Well spotted, but are semicolon related comments really worth our time and the CI resource use?
I don't think so, which is why I prepared and shared prettier and linter configurations for the tracker two times in the last 4 weeks, once at the very start of this endeavor. Let's apply them and regain our focus on what matters.
https://github.com/plausible/analytics/compare/script-v2/add-linter-prettier?expand=1
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agreed that linter/prettier is the better way to solve this, but given that the tracker codebase is mostly semi-colon free why add them in this PR? Fixing it later when we introduce linter may lead to merge conflicts whilst removing them now is free.
scriptConfig: { ...DEFAULT_CONFIG, formSubmissions: true }, | ||
bodyContent: ` | ||
<div> | ||
<form onsubmit="event.preventDefault(); console.log('Form 1 submitted')"> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Q: Is the event.preventDefault needed?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yep! Otherwise submitting it causes navigation, which in turn sometimes causes the test to be flaky on Firefox because of the older version of Firefox that Playwright uses.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's skip the test on firefox instead then and remove the skip once we've upgraded firefox.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What's the benefit of skipping them?
They run on Firefox and other browsers, and prove that the feature works for forms where the onsubmit handler is custom, which is a commonplace in the web apps that I've worked on.
Did you disagree with this take: #5381 (comment)?
I think this is ready to be merged after these comments are resolved: |
@macobo Thanks for highlighting the blockers. I've made changes to the best of my conscience. Looking at the discussions, the arguments put forth, and the resulting changes, I don't think they were worth blocking over. |
Changes
TODO
Understand why tests are flaky
Fix flakiness in tests
Figure out if form submission tracking is too eager, catching forms that haven't passed custom validation
checkValidity
but also respectingnovalidate
attribute on the form element. Note: we don't respect theformnovalidate
that can be set on the submit input: another limitation.Figure out what to do to catch forms that are submitted with FormElement.submit(), which doesn't go through
onsubmit
event handlersFormElement.requestSubmit()
or to manually track such submits.Figure out if and how to support forms that are not present on script load (a'la form that is inserted into the DOM when user expands a specific page section)
TODO in future PRs
Figure out if and how to distinguish forms on websites that are using hash routing
Figure out exact event configuration: what event name to use and what props to include
Allow setting the option in onboarding, autocreate and autodelete related goal
Tests
Changelog
Documentation
Dark mode