Catch playable ad rejections before you submit, not three days later in a rejection email.
adpreflight checks a hand-coded HTML5 playable against Unity Ads and Google App-campaigns rule
packs. It reads the build statically, then runs it once in a headless container shim to verify one
thing dynamically. It prints a verdict per network naming the exact rule that failed.
No install, no clone:
npx github:marko-builds/adpreflight ./build.zip --networks unity,google
Input can be a .zip, a directory, or a single .html file. Add --json for machine-readable
output, --static-only to skip the dynamic stage.
Try it on the committed fixtures:
git clone https://github.com/marko-builds/adpreflight && cd adpreflight
npm install
node dist/cli.js fixtures/broken.zip --networks unity,google # exits 1, names 12 rule failures
node dist/cli.js fixtures/clean.zip --networks unity,google # exits 0
A reconstructed pattern, not a specific real creative. This is what the classic Unity round trip looks like:
Before. You build a playable, zip it, upload it, and wait. Two or three days later the
rejection arrives: "creative does not meet specifications." No rule name, no line number. You start
guessing. The build was 5.4 MB extracted (the limit is 5 MB, measured on the inlined file, not the
zip you looked at). It also pulled a font from a CDN, and its call-to-action navigated with
window.location instead of mraid.open. Three separate problems, one unhelpful email, another
round trip for each guess.
After.
$ npx github:marko-builds/adpreflight ./build.zip --networks unity,google
network verdict rule
unity fail unity/extracted-size-exceeded: Extracted build exceeds Unity Ads' 5 MB playable limit
unity fail unity/external-resource-reference: References an external URL, Unity requires all assets inlined
unity fail unity/redirect-window-location: Navigates via window.location, Unity expects the store link to go through mraid.open
Seconds, on your machine, with the rule names to search for.
The honest version. This tool claims only what the run performs.
| Check | Unity | |
|---|---|---|
| Extracted total size | yes | yes |
Submitted .zip size |
not applicable (Unity takes a single inlined file) | yes |
| File count cap (512) | no | yes |
index.html at archive root |
yes | no, see below |
| Double-zipped archive | no | yes |
| Single inlined bundle | yes | no |
| External resource references | yes (none permitted) | yes (Google's allowlist) |
Insecure http:// references |
yes | yes |
| Frame tags | no | yes |
| MRAID hook present | yes | no, see below |
window.location redirect |
yes | yes |
The ready handshake: the build is served from a local static server, opened in headless
Chromium, and its <script src="mraid.js"> request is intercepted with a recording shim. Every
container call is logged with a timestamp. If the creative touches the API before the container
signals ready, handshake-before-ready fires.
The recorded conversation is in the --json output, so you can read what your creative actually
said to the container:
getState ms=0 beforeReady=true
addEventListener ms=0 beforeReady=true
getPlacementType ms=1 beforeReady=false
These need a synthetic tap to ever fire, and this version has no interaction probe. Asserting them would mean reporting "pass" for checks that never ran:
- exit-call-per-CTA counts
- actual redirect behaviour at runtime (the
window.locationcheck above is a source check) sizeChangeandviewableChangehandling- rendering, visual correctness, performance, or anything about whether the ad is any good
| Code | Meaning |
|---|---|
| 0 | Every enabled rule passed |
| 1 | At least one rule failed |
| 2 | No verdict could be established |
Code 2 is not "worse than 1", it is "the run did not complete". It fires when Playwright's browser is missing, or when the creative never talked to the container at all, so the handshake could not be observed. A build that never loads a container is not passing the dynamic check, it is failing to take it. Code 2 outranks code 1 because an incomplete run's list of failures may itself be incomplete.
The static stages need nothing but Node and run in seconds.
The dynamic stage needs a browser, once:
npx playwright install chromium
Without it you still get every static result, and the dynamic stage reports
dynamic-stage-unavailable rather than quietly stepping aside.
Rule packs are data, not code, one YAML per network in rulepacks/. Every limit carries the exact
quote, the source URL and the retrieval date inline. Anything not confirmed from a primary source
is marked unverified rather than asserted.
Each rule also carries a basis field, and this distinction matters if you are deciding whether to
trust a verdict:
citedmeans a network's own documentation states it, and the quote is in the pack.derivedmeans it follows from something cited but no network states it in those words.
Rules currently marked derived: both insecure-http-reference rules, both
redirect-window-location rules, unity/mraid-hook-missing, google/double-zipped, and
google/extracted-size-exceeded (Google states its limit on the .zip, so measuring the extracted
tree is a conservative approximation that can fail a compressible build Google would accept).
Two things the packs record but this tool cannot enforce: Google's 5 MB and 5.2 MB figures conflict
across two of its own help pages (the conservative number is enforced), and ExitApi is
unsupported in Google Web Designer creatives while being required for hand-authored ones, which
cannot be told apart from the build alone.
fixtures/ is the calibration pair, and CI asserts it both directions on every push. A planted
rule that stops firing fails the build, and so does an unplanted rule that starts. See
fixtures/README.md.
Unity Ads and Google App campaigns only. Not AppLovin, Meta, ironSource or Vungle.
MIT. See LICENSE.