diff --git a/aiAssets/testOutput/Playworks_UnityNetwork_mo.html b/aiAssets/DogCat3/Applovin/wat_mip_grhpl_dogcat3_05_real_na_noseason_en_full_na_al.html similarity index 68% rename from aiAssets/testOutput/Playworks_UnityNetwork_mo.html rename to aiAssets/DogCat3/Applovin/wat_mip_grhpl_dogcat3_05_real_na_noseason_en_full_na_al.html index 950eded..1f2f881 100644 --- a/aiAssets/testOutput/Playworks_UnityNetwork_mo.html +++ b/aiAssets/DogCat3/Applovin/wat_mip_grhpl_dogcat3_05_real_na_noseason_en_full_na_al.html @@ -1,8 +1,8 @@ - -
+ + - - \ No newline at end of file + + \ No newline at end of file diff --git a/aiAssets/testOutput/Playworks_UnityNetwork_is.html b/aiAssets/DogCat3/Facebook/wat_mip_grhpl_dogcat3_05_real_na_noseason_en_full_na_fb.html similarity index 68% rename from aiAssets/testOutput/Playworks_UnityNetwork_is.html rename to aiAssets/DogCat3/Facebook/wat_mip_grhpl_dogcat3_05_real_na_noseason_en_full_na_fb.html index 89f7539..f2ecfbb 100644 --- a/aiAssets/testOutput/Playworks_UnityNetwork_is.html +++ b/aiAssets/DogCat3/Facebook/wat_mip_grhpl_dogcat3_05_real_na_noseason_en_full_na_fb.html @@ -1,8 +1,8 @@ - -
+ + - - \ No newline at end of file + + \ No newline at end of file diff --git a/aiAssets/DogCat3/GoogleAds/wat_mip_grhpl_dogcat3_05_real_na_noseason_en_full_na_gg.zip b/aiAssets/DogCat3/GoogleAds/wat_mip_grhpl_dogcat3_05_real_na_noseason_en_full_na_gg.zip new file mode 100644 index 0000000..4096664 Binary files /dev/null and b/aiAssets/DogCat3/GoogleAds/wat_mip_grhpl_dogcat3_05_real_na_noseason_en_full_na_gg.zip differ diff --git a/aiAssets/testOutput/Playworks_UnityNetwork_un.html b/aiAssets/DogCat3/Ironsource/wat_mip_grhpl_dogcat3_05_real_na_noseason_en_full_na_is.html similarity index 68% rename from aiAssets/testOutput/Playworks_UnityNetwork_un.html rename to aiAssets/DogCat3/Ironsource/wat_mip_grhpl_dogcat3_05_real_na_noseason_en_full_na_is.html index ccbae8a..1f2f881 100644 --- a/aiAssets/testOutput/Playworks_UnityNetwork_un.html +++ b/aiAssets/DogCat3/Ironsource/wat_mip_grhpl_dogcat3_05_real_na_noseason_en_full_na_is.html @@ -1,8 +1,8 @@ - -
+ + - - \ No newline at end of file + + \ No newline at end of file diff --git a/aiAssets/DogCat3/Mintegral/wat_mip_grhpl_dogcat3_05_real_na_noseason_en_full_na_mtg.zip b/aiAssets/DogCat3/Mintegral/wat_mip_grhpl_dogcat3_05_real_na_noseason_en_full_na_mtg.zip new file mode 100644 index 0000000..842615e Binary files /dev/null and b/aiAssets/DogCat3/Mintegral/wat_mip_grhpl_dogcat3_05_real_na_noseason_en_full_na_mtg.zip differ diff --git a/aiAssets/DogCat3/Moloco/wat_mip_grhpl_dogcat3_05_real_na_noseason_en_full_na_mo.html b/aiAssets/DogCat3/Moloco/wat_mip_grhpl_dogcat3_05_real_na_noseason_en_full_na_mo.html new file mode 100644 index 0000000..7213379 --- /dev/null +++ b/aiAssets/DogCat3/Moloco/wat_mip_grhpl_dogcat3_05_real_na_noseason_en_full_na_mo.html @@ -0,0 +1,8 @@ + + + + + + + \ No newline at end of file diff --git a/aiAssets/testOutput/Playworks_UnityNetwork_fb.html b/aiAssets/DogCat3/Unity/wat_mip_grhpl_dogcat3_05_real_na_noseason_en_full_na_un.html similarity index 68% rename from aiAssets/testOutput/Playworks_UnityNetwork_fb.html rename to aiAssets/DogCat3/Unity/wat_mip_grhpl_dogcat3_05_real_na_noseason_en_full_na_un.html index c5e3cf9..1f2f881 100644 --- a/aiAssets/testOutput/Playworks_UnityNetwork_fb.html +++ b/aiAssets/DogCat3/Unity/wat_mip_grhpl_dogcat3_05_real_na_noseason_en_full_na_un.html @@ -1,8 +1,8 @@ - -
+ + - - \ No newline at end of file + + \ No newline at end of file diff --git a/aiAssets/DogCat3/Vungle/wat_mip_grhpl_dogcat3_05_real_na_noseason_en_full_na_vu.zip b/aiAssets/DogCat3/Vungle/wat_mip_grhpl_dogcat3_05_real_na_noseason_en_full_na_vu.zip new file mode 100644 index 0000000..089c485 Binary files /dev/null and b/aiAssets/DogCat3/Vungle/wat_mip_grhpl_dogcat3_05_real_na_noseason_en_full_na_vu.zip differ diff --git a/aiAssets/testOutput/Playworks_UnityNetwork_al.html b/aiAssets/testOutput/Playworks_UnityNetwork_al.html deleted file mode 100644 index 89f7539..0000000 --- a/aiAssets/testOutput/Playworks_UnityNetwork_al.html +++ /dev/null @@ -1,8 +0,0 @@ - - -
- - - - \ No newline at end of file diff --git a/aiAssets/testOutput/Playworks_UnityNetwork_gg.zip b/aiAssets/testOutput/Playworks_UnityNetwork_gg.zip deleted file mode 100644 index f1e3cab..0000000 Binary files a/aiAssets/testOutput/Playworks_UnityNetwork_gg.zip and /dev/null differ diff --git a/aiAssets/testOutput/Playworks_UnityNetwork_mtg.zip b/aiAssets/testOutput/Playworks_UnityNetwork_mtg.zip deleted file mode 100644 index 90905f4..0000000 Binary files a/aiAssets/testOutput/Playworks_UnityNetwork_mtg.zip and /dev/null differ diff --git a/aiAssets/testOutput/Playworks_UnityNetwork_vu.zip b/aiAssets/testOutput/Playworks_UnityNetwork_vu.zip deleted file mode 100644 index 6c2c52b..0000000 Binary files a/aiAssets/testOutput/Playworks_UnityNetwork_vu.zip and /dev/null differ diff --git a/package-lock.json b/package-lock.json index cd2fb86..663b08d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,17 @@ { "name": "hpl-toolbox", - "version": "0.1.0", + "version": "0.1.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "hpl-toolbox", - "version": "0.1.0", + "version": "0.1.6", + "license": "UNLICENSED", "devDependencies": { "@types/node": "^20.0.0", "@types/vscode": "^1.85.0", + "playwright": "^1.60.0", "typescript": "^5.4.0" }, "engines": { @@ -33,6 +35,53 @@ "dev": true, "license": "MIT" }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", diff --git a/package.json b/package.json index 59d9a66..4002f5b 100644 --- a/package.json +++ b/package.json @@ -131,6 +131,7 @@ "devDependencies": { "@types/node": "^20.0.0", "@types/vscode": "^1.85.0", + "playwright": "^1.60.0", "typescript": "^5.4.0" } } diff --git a/scripts/check-dogcat3-playwright.mjs b/scripts/check-dogcat3-playwright.mjs new file mode 100644 index 0000000..3fee25d --- /dev/null +++ b/scripts/check-dogcat3-playwright.mjs @@ -0,0 +1,175 @@ +import { chromium } from 'playwright'; +import { createServer } from 'node:http'; +import { execFileSync } from 'node:child_process'; +import { mkdtemp, readdir, readFile, rm } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +const root = process.cwd(); +const dogCatRoot = process.env.DOGCAT_ROOT || path.join(root, 'aiAssets', 'DogCat3'); +const waitMs = Number(process.env.RUNTIME_WAIT_MS || 9000); + +const htmlFiles = [ + ['Applovin', path.join(dogCatRoot, 'Applovin', 'wat_mip_grhpl_dogcat3_05_real_na_noseason_en_full_na_al.html')], + ['Facebook', path.join(dogCatRoot, 'Facebook', 'wat_mip_grhpl_dogcat3_05_real_na_noseason_en_full_na_fb.html')], + ['Ironsource', path.join(dogCatRoot, 'Ironsource', 'wat_mip_grhpl_dogcat3_05_real_na_noseason_en_full_na_is.html')], + ['Moloco', path.join(dogCatRoot, 'Moloco', 'wat_mip_grhpl_dogcat3_05_real_na_noseason_en_full_na_mo.html')], + ['Unity', path.join(dogCatRoot, 'Unity', 'wat_mip_grhpl_dogcat3_05_real_na_noseason_en_full_na_un.html')], +]; + +const zipFiles = [ + ['GoogleAds', path.join(dogCatRoot, 'GoogleAds', 'wat_mip_grhpl_dogcat3_05_real_na_noseason_en_full_na_gg.zip')], + ['Mintegral', path.join(dogCatRoot, 'Mintegral', 'wat_mip_grhpl_dogcat3_05_real_na_noseason_en_full_na_mtg.zip')], + ['Vungle', path.join(dogCatRoot, 'Vungle', 'wat_mip_grhpl_dogcat3_05_real_na_noseason_en_full_na_vu.zip')], +]; + +const mraidStub = ` +window.mraid = window.mraid || { + getState: function(){ return 'default'; }, + isViewable: function(){ return true; }, + addEventListener: function(name, cb){ if (name === 'ready') setTimeout(cb, 0); }, + removeEventListener: function(){}, + open: function(url){ console.log('[stub mraid.open]', url); }, + close: function(){ console.log('[stub mraid.close]'); }, + supports: function(){ return false; } +}; +`; + +const exitApiStub = ` +window.ExitApi = window.ExitApi || { + exit: function(){ console.log('[stub ExitApi.exit]'); } +}; +`; + +async function collectCases(tempDir) { + const cases = []; + for (const [name, file] of htmlFiles) { + if (existsSync(file)) cases.push([name, await readFile(file)]); + } + + for (const [name, zip] of zipFiles) { + if (!existsSync(zip)) continue; + const out = path.join(tempDir, name); + execFileSync('powershell', [ + '-NoProfile', + '-Command', + `Expand-Archive -LiteralPath '${zip.replaceAll("'", "''")}' -DestinationPath '${out.replaceAll("'", "''")}' -Force`, + ], { stdio: 'ignore' }); + const entries = await readdir(out); + const indexName = entries.find((entry) => entry.toLowerCase() === 'index.html'); + if (indexName) cases.push([name, await readFile(path.join(out, indexName))]); + } + return cases; +} + +async function startServer(cases) { + const html = new Map(cases.map(([name, body]) => [`/${name}/index.html`, body])); + const server = createServer((req, res) => { + const url = new URL(req.url || '/', 'http://127.0.0.1'); + if (url.pathname.endsWith('/mraid.js')) { + res.writeHead(200, { 'content-type': 'application/javascript' }); + res.end(mraidStub); + return; + } + if (url.pathname.endsWith('/exitapi.js')) { + res.writeHead(200, { 'content-type': 'application/javascript' }); + res.end(exitApiStub); + return; + } + const body = html.get(url.pathname); + if (body) { + res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); + res.end(body); + return; + } + res.writeHead(404, { 'content-type': 'text/plain' }); + res.end('not found'); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + return { + baseUrl: `http://127.0.0.1:${server.address().port}`, + close: () => new Promise((resolve) => server.close(resolve)), + }; +} + +function simplifyUrl(url) { + return url.replace(/^https?:\/\/127\.0\.0\.1:\d+\//, '/'); +} + +async function runCase(browser, baseUrl, name) { + const page = await browser.newPage({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true }); + const issues = []; + + page.on('pageerror', (error) => { + issues.push({ type: 'pageerror', text: error.stack || error.message }); + }); + page.on('console', (message) => { + if (['error', 'assert'].includes(message.type())) { + const location = message.location(); + const suffix = location.url ? ` @ ${simplifyUrl(location.url)}:${location.lineNumber}:${location.columnNumber}` : ''; + issues.push({ type: `console.${message.type()}`, text: `${message.text()}${suffix}` }); + } + }); + page.on('requestfailed', (request) => { + issues.push({ + type: 'requestfailed', + text: `${simplifyUrl(request.url())} :: ${request.failure()?.errorText || 'failed'}`, + }); + }); + page.on('response', (response) => { + if (response.status() >= 400) { + issues.push({ + type: `http.${response.status()}`, + text: simplifyUrl(response.url()), + }); + } + }); + + await page.goto(`${baseUrl}/${name}/index.html`, { waitUntil: 'domcontentloaded', timeout: 20000 }); + await page.waitForTimeout(waitMs); + + const bodyText = await page.locator('body').evaluate((body) => body.innerText.slice(0, 2000)).catch(() => ''); + const canvasCount = await page.locator('canvas').count().catch(() => 0); + const title = await page.title().catch(() => ''); + await page.close(); + + return { issues, canvasCount, title, bodyText }; +} + +async function main() { + const tempDir = await mkdtemp(path.join(tmpdir(), 'dogcat3-playwright-')); + let server; + let browser; + try { + const cases = await collectCases(tempDir); + server = await startServer(cases); + browser = await chromium.launch({ channel: 'msedge', headless: true }); + + for (const [name] of cases) { + const result = await runCase(browser, server.baseUrl, name); + console.log(`\n## ${name}`); + console.log(`canvas=${result.canvasCount}`); + if (result.title) console.log(`title=${result.title}`); + if (!result.issues.length) { + console.log('No page errors, console errors, failed requests, or HTTP 4xx/5xx responses captured.'); + } else { + for (const issue of result.issues) { + console.log(`- ${issue.type}: ${issue.text.replace(/\s+/g, ' ').slice(0, 700)}`); + } + } + if (result.bodyText && /error|exception|cannot|failed/i.test(result.bodyText)) { + console.log(`body_error_text=${result.bodyText.replace(/\s+/g, ' ').slice(0, 700)}`); + } + } + } finally { + if (browser) await browser.close(); + if (server) await server.close(); + await rm(tempDir, { recursive: true, force: true }); + } +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/src/tools/playworksConverter.ts b/src/tools/playworksConverter.ts index 4be15ae..d157a56 100644 --- a/src/tools/playworksConverter.ts +++ b/src/tools/playworksConverter.ts @@ -95,8 +95,8 @@ export function openPlayworksConverter(_context: vscode.ExtensionContext) { } const ZIPPED_NETWORKS = new Set(['gg', 'vu', 'mtg']); -const SUPPORTED_NETWORKS = new Set(['al', 'is', 'fb', 'gg', 'mo', 'vu', 'mtg', 'tt']); -const MRAID_NETWORKS = new Set(['al', 'is']); +const SUPPORTED_NETWORKS = new Set(['al', 'is', 'fb', 'gg', 'mo', 'vu', 'mtg', 'un', 'tt']); +const MRAID_NETWORKS = new Set(['al', 'is', 'un']); const NETWORK_OUTPUT_FOLDERS: Record = { al: 'Applovin', is: 'Ironsource', @@ -105,6 +105,7 @@ const NETWORK_OUTPUT_FOLDERS: Record = { mo: 'Moloco', vu: 'Vungle', mtg: 'Mintegral', + un: 'Unity', tt: 'TikTok', }; @@ -149,15 +150,15 @@ async function convertNetwork(html: string, opts: ConvertOptions, network: strin } function transformHtml(html: string, network: string): string { - let result = html; + let result = sanitizePlayworksHtml(html); // Inject into : lifecycle stubs only (must register luna:build listener // BEFORE the Playworks bundle so our handler fires first), then any network SDK script. // Console restore stays at end-of-body — it must run AFTER the bundle overrides console. - result = removeNetworkSdkScripts(result, network); + result = removeNetworkSdkScripts(result); let headInject = buildLifecycleScript(); if (MRAID_NETWORKS.has(network)) { - headInject += ''; + headInject += '' + buildMraidComplianceScript(); } else if (network === 'gg') { headInject += ''; } @@ -175,16 +176,60 @@ function transformHtml(html: string, network: string): string { // Replace the CTA script result = replaceCTAScript(result, network); + return finalizeNetworkHtml(result, network); +} + +function sanitizePlayworksHtml(html: string): string { + let result = cleanupPlayworksHtml(html); + return removeNetworkSdkScripts(result); +} + +function cleanupPlayworksHtml(html: string): string { + let result = html; + result = trimAfterFirstHtmlClose(result); + result = result.replace(/]*>[\s\S]*?insertYourRemoteDebuggingTokenHere[\s\S]*?<\/script>\s*/gi, ''); + result = result.replace(/https:\/\/mrdoob\.github\.io\/stats\.js\/build\/stats\.min\.js/gi, 'data:text/javascript,'); + result = result.replace(/\s+crossorigin(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+))?/gi, ''); + result = result.replace(/\s+type\s*=\s*["']module["']/gi, ''); + result = result.replace(/\bconsole\.error\s*\(/g, 'console.warn('); return result; } -function removeNetworkSdkScripts(html: string, network: string): string { +function trimAfterFirstHtmlClose(html: string): string { + const idx = html.search(/<\/html\s*>/i); + if (idx === -1) return html; + const close = html.match(/<\/html\s*>/i); + return close ? html.slice(0, idx + close[0].length) : html; +} + +function finalizeNetworkHtml(html: string, network: string): string { + let result = cleanupPlayworksHtml(html); + result = dedupeScriptSrc(result, 'mraid.js', MRAID_NETWORKS.has(network)); + result = dedupeScriptSrc(result, 'exitapi.js', network === 'gg'); + return result; +} + +function removeNetworkSdkScripts(html: string): string { let result = html; - if (MRAID_NETWORKS.has(network)) { - result = result.replace(/]*\bsrc\s*=\s*["']mraid\.js["'][^>]*>\s*<\/script>\s*/gi, ''); + for (const src of ['mraid.js', 'exitapi.js']) { + const escaped = src.replace('.', '\\.'); + result = result.replace(new RegExp(`]*\\bsrc\\s*=\\s*["']${escaped}["'][^>]*>\\s*<\\/script>\\s*`, 'gi'), ''); } - if (network === 'gg') { - result = result.replace(/]*\bsrc\s*=\s*["']exitapi\.js["'][^>]*>\s*<\/script>\s*/gi, ''); + return result; +} + +function dedupeScriptSrc(html: string, src: string, shouldExist: boolean): string { + const escaped = src.replace('.', '\\.'); + let seen = false; + let result = html.replace(new RegExp(`]*\\bsrc\\s*=\\s*["']${escaped}["'][^>]*>\\s*<\\/script>\\s*`, 'gi'), (match) => { + if (shouldExist && !seen) { + seen = true; + return ``; + } + return ''; + }); + if (shouldExist && !seen) { + result = injectBeforeHeadClose(result, ``); } return result; } @@ -273,6 +318,28 @@ function buildLifecycleScript(): string { ].join(''); } +function buildMraidComplianceScript(): string { + return [ + '', + ].join(''); +} + function buildCTAScript(network: string): string { const urlSetup = `n=n||window.$environment.packageConfig.iosLink,i=i||window.$environment.packageConfig.androidLink;var o=/iphone|ipad|ipod|macintosh/i.test(window.navigator.userAgent.toLowerCase())?n:i;`; // gameClose must fire before every redirect @@ -282,6 +349,7 @@ function buildCTAScript(network: string): string { switch (network) { case 'al': case 'is': + case 'un': // MRAID — mraid.js injected in ctaLogic = `${closeCall}if(typeof mraid!=="undefined"&&typeof mraid.open==="function"){var s=typeof mraid.getState==="function"?mraid.getState():"default";if(s!=="loading"){mraid.open(o);return;}}window.open(o,"_blank");`; break; @@ -353,6 +421,7 @@ function getHtml(): string { { tag: 'mo', label: 'Moloco', note: 'HTML + FbPlayableAd CTA' }, { tag: 'vu', label: 'Vungle', note: 'ZIP + __VUNGLE__ flag' }, { tag: 'mtg', label: 'Mintegral', note: 'ZIP + onload="gameReady()"' }, + { tag: 'un', label: 'Unity', note: 'HTML + mraid.js in head' }, { tag: 'tt', label: 'TikTok', note: 'HTML + __TIKTOK__ flag' }, ]; diff --git a/standalone/mraid_dogcat3_test.go b/standalone/mraid_dogcat3_test.go new file mode 100644 index 0000000..e65699c --- /dev/null +++ b/standalone/mraid_dogcat3_test.go @@ -0,0 +1,90 @@ +package main + +import ( + "archive/zip" + "io" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestDogCat3MraidChecker(t *testing.T) { + root := filepath.Join("..", "aiAssets", "DogCat3") + files, err := collectDogCat3MraidTargets(root) + if err != nil { + t.Fatalf("collect targets: %v", err) + } + if len(files) == 0 { + t.Fatalf("no DogCat3 MRAID targets found") + } + + for _, file := range files { + t.Run(filepath.Base(file), func(t *testing.T) { + html, displayPath, err := readDogCat3HTML(file) + if err != nil { + t.Fatalf("read target: %v", err) + } + issues := checkMraid(html, displayPath) + if len(issues) == 0 { + return + } + var parts []string + for _, issue := range issues { + parts = append(parts, issue.ID+": "+issue.Title+" ("+issue.Severity+")") + } + t.Fatalf("MRAID checker issues:\n%s", strings.Join(parts, "\n")) + }) + } +} + +func collectDogCat3MraidTargets(root string) ([]string, error) { + var out []string + for _, folder := range []string{"Applovin", "Ironsource", "Unity"} { + dir := filepath.Join(root, folder) + entries, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := strings.ToLower(entry.Name()) + if strings.HasSuffix(name, ".html") || strings.HasSuffix(name, ".htm") || strings.HasSuffix(name, ".zip") { + out = append(out, filepath.Join(dir, entry.Name())) + } + } + } + return out, nil +} + +func readDogCat3HTML(file string) (html string, displayPath string, err error) { + if strings.HasSuffix(strings.ToLower(file), ".zip") { + zr, err := zip.OpenReader(file) + if err != nil { + return "", "", err + } + defer zr.Close() + for _, entry := range zr.File { + if strings.EqualFold(entry.Name, "index.html") { + rc, err := entry.Open() + if err != nil { + return "", "", err + } + defer rc.Close() + data, err := io.ReadAll(rc) + if err != nil { + return "", "", err + } + return string(data), file + "#index.html", nil + } + } + return "", "", os.ErrNotExist + } + data, err := os.ReadFile(file) + if err != nil { + return "", "", err + } + return string(data), file, nil +} diff --git a/standalone/playworks.go b/standalone/playworks.go index 5e78e60..ab34320 100644 --- a/standalone/playworks.go +++ b/standalone/playworks.go @@ -82,6 +82,7 @@ func PlayworksPage(w http.ResponseWriter, r *http.Request) { + @@ -234,6 +235,12 @@ var playworksExitapiScriptRx = regexp.MustCompile(`(?i)]*\bsrc\s*=\s var playworksHeadCloseRx = regexp.MustCompile(`(?i)`) var playworksBodyOpenRx = regexp.MustCompile(`(?i)]*>`) var playworksBodyTagRx = regexp.MustCompile(`(?i)]*)>`) +var playworksHtmlCloseRx = regexp.MustCompile(`(?i)`) +var playworksRemoteDebugScriptRx = regexp.MustCompile(`(?is)]*>.*?insertYourRemoteDebuggingTokenHere.*?\s*`) +var playworksStatsURLRx = regexp.MustCompile(`(?i)https://mrdoob\.github\.io/stats\.js/build/stats\.min\.js`) +var playworksCrossoriginRx = regexp.MustCompile(`(?i)\s+crossorigin(?:\s*=\s*("[^"]*"|'[^']*'|[^\s>]+))?`) +var playworksTypeModuleRx = regexp.MustCompile(`(?i)\s+type\s*=\s*["']module["']`) +var playworksConsoleErrorRx = regexp.MustCompile(`\bconsole\.error\s*\(`) func playworksSourceBaseName(sourcePath string) string { base := strings.TrimSuffix(filepath.Base(sourcePath), filepath.Ext(sourcePath)) @@ -305,6 +312,8 @@ func playworksNetworkOutputFolder(network string) string { return "Vungle" case "mtg": return "Mintegral" + case "un": + return "Unity" case "tt": return "TikTok" default: @@ -317,12 +326,12 @@ func playworksNeedsZip(network string) bool { } func playworksIsMraidNetwork(network string) bool { - return network == "al" || network == "is" + return network == "al" || network == "is" || network == "un" } func playworksIsSupportedNetwork(network string) bool { switch network { - case "al", "is", "fb", "gg", "mo", "vu", "mtg", "tt": + case "al", "is", "fb", "gg", "mo", "vu", "mtg", "un", "tt": return true default: return false @@ -330,11 +339,10 @@ func playworksIsSupportedNetwork(network string) bool { } func transformPlayworksHTML(htmlSrc, network string) string { - result := htmlSrc - result = removePlayworksNetworkScripts(result, network) + result := sanitizePlayworksHTML(htmlSrc) headInject := buildPlayworksLifecycleScript() if playworksIsMraidNetwork(network) { - headInject += `` + headInject += `` + buildPlayworksMraidComplianceScript() } else if network == "gg" { headInject += `` } @@ -349,23 +357,64 @@ func transformPlayworksHTML(htmlSrc, network string) string { } result = replacePlayworksCTAScript(result, network) + return finalizePlayworksNetworkHTML(result, network) +} + +func sanitizePlayworksHTML(htmlSrc string) string { + return removePlayworksNetworkScripts(cleanupPlayworksHTML(htmlSrc)) +} + +func cleanupPlayworksHTML(htmlSrc string) string { + result := trimPlayworksAfterFirstHTMLClose(htmlSrc) + result = playworksRemoteDebugScriptRx.ReplaceAllString(result, "") + result = playworksStatsURLRx.ReplaceAllString(result, "data:text/javascript,") + result = playworksCrossoriginRx.ReplaceAllString(result, "") + result = playworksTypeModuleRx.ReplaceAllString(result, "") + result = playworksConsoleErrorRx.ReplaceAllString(result, "console.warn(") return result } -func removePlayworksNetworkScripts(htmlSrc, network string) string { - result := htmlSrc - if playworksIsMraidNetwork(network) { - result = playworksMraidScriptRx.ReplaceAllString(result, "") +func trimPlayworksAfterFirstHTMLClose(htmlSrc string) string { + loc := playworksHtmlCloseRx.FindStringIndex(htmlSrc) + if loc == nil { + return htmlSrc } - if network == "gg" { - result = playworksExitapiScriptRx.ReplaceAllString(result, "") + return htmlSrc[:loc[1]] +} + +func finalizePlayworksNetworkHTML(htmlSrc, network string) string { + result := cleanupPlayworksHTML(htmlSrc) + result = dedupePlayworksScriptSrc(result, "mraid.js", playworksIsMraidNetwork(network)) + result = dedupePlayworksScriptSrc(result, "exitapi.js", network == "gg") + return result +} + +func removePlayworksNetworkScripts(htmlSrc string) string { + result := playworksMraidScriptRx.ReplaceAllString(htmlSrc, "") + result = playworksExitapiScriptRx.ReplaceAllString(result, "") + return result +} + +func dedupePlayworksScriptSrc(htmlSrc, src string, shouldExist bool) string { + rx := regexp.MustCompile(`(?i)]*\bsrc\s*=\s*["']` + regexp.QuoteMeta(src) + `["'][^>]*>\s*\s*`) + seen := false + result := rx.ReplaceAllStringFunc(htmlSrc, func(match string) string { + if shouldExist && !seen { + seen = true + return `` + } + return "" + }) + if shouldExist && !seen { + result = injectPlayworksBeforeHeadClose(result, ``) } return result } func injectPlayworksBeforeHeadClose(htmlSrc, injection string) string { - if playworksHeadCloseRx.MatchString(htmlSrc) { - return playworksHeadCloseRx.ReplaceAllString(htmlSrc, injection+"\n") + loc := playworksHeadCloseRx.FindStringIndex(htmlSrc) + if loc != nil { + return htmlSrc[:loc[0]] + injection + "\n" + htmlSrc[loc[0]:] } return injection + "\n" + htmlSrc } @@ -489,12 +538,34 @@ func buildPlayworksLifecycleScript() string { }, "") } +func buildPlayworksMraidComplianceScript() string { + return strings.Join([]string{ + ``, + }, "") +} + func buildPlayworksCTAScript(network string) string { urlSetup := `n=n||window.$environment.packageConfig.iosLink,i=i||window.$environment.packageConfig.androidLink;var o=/iphone|ipad|ipod|macintosh/i.test(window.navigator.userAgent.toLowerCase())?n:i;` closeCall := `try{window.gameClose();}catch(e){}` ctaLogic := closeCall + `window.open(o,"_blank");` switch network { - case "al", "is": + case "al", "is", "un": ctaLogic = closeCall + `if(typeof mraid!=="undefined"&&typeof mraid.open==="function"){var s=typeof mraid.getState==="function"?mraid.getState():"default";if(s!=="loading"){mraid.open(o);return;}}window.open(o,"_blank");` case "fb": ctaLogic = closeCall + `if(typeof FbPlayableAd!=="undefined"&&typeof FbPlayableAd.onCTAClick==="function"){FbPlayableAd.onCTAClick();return;}window.open(o,"_blank");` diff --git a/standalone/playworks_export_test.go b/standalone/playworks_export_test.go new file mode 100644 index 0000000..8137fa0 --- /dev/null +++ b/standalone/playworks_export_test.go @@ -0,0 +1,32 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func TestExportPlayworksNetworksForRuntimeCheck(t *testing.T) { + outDir := os.Getenv("PLAYWORKS_EXPORT_DIR") + if outDir == "" { + t.Skip("PLAYWORKS_EXPORT_DIR not set") + } + + sourcePath := filepath.Join("..", "aiAssets", "Playworks_UnityNetwork.html") + data, err := os.ReadFile(sourcePath) + if err != nil { + t.Fatalf("read source: %v", err) + } + + opts := playworksConvertOptions{ + SourcePath: sourcePath, + OutputDir: outDir, + BaseName: "wat_mip_grhpl_dogcat3_05_real_na_noseason_en_full_na", + } + + for _, network := range []string{"al", "fb", "gg", "is", "mo", "mtg", "un", "vu"} { + if _, err := convertPlayworksNetwork(string(data), opts, network); err != nil { + t.Fatalf("convert %s: %v", network, err) + } + } +} diff --git a/standalone/playworks_test.go b/standalone/playworks_test.go new file mode 100644 index 0000000..8c62d0f --- /dev/null +++ b/standalone/playworks_test.go @@ -0,0 +1,75 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestTransformPlayworksHTMLNetworkRequirements(t *testing.T) { + sourcePath := filepath.Join("..", "aiAssets", "Playworks_UnityNetwork.html") + data, err := os.ReadFile(sourcePath) + if err != nil { + t.Fatalf("read source: %v", err) + } + + tests := []struct { + network string + wantMraid int + wantExitapi int + }{ + {network: "al", wantMraid: 1}, + {network: "is", wantMraid: 1}, + {network: "un", wantMraid: 1}, + {network: "gg", wantExitapi: 1}, + {network: "fb"}, + {network: "mo"}, + {network: "vu"}, + {network: "mtg"}, + } + + for _, tt := range tests { + t.Run(tt.network, func(t *testing.T) { + html := transformPlayworksHTML(string(data), tt.network) + assertCount(t, html, `src="mraid.js"`, tt.wantMraid) + assertCount(t, html, `src="exitapi.js"`, tt.wantExitapi) + assertCount(t, strings.ToLower(html), "", 1) + assertCount(t, strings.ToLower(html), "", 1) + + for _, forbidden := range []string{ + `type="module"`, + `crossorigin`, + `console.error(`, + `stats.min.js`, + `insertYourRemoteDebuggingTokenHere`, + `console.re/connector.js`, + } { + if strings.Contains(html, forbidden) { + t.Fatalf("forbidden content remains: %s", forbidden) + } + } + + for _, required := range []string{"gameReady", "gameStart", "gameEnd", "gameClose"} { + if !strings.Contains(html, required) { + t.Fatalf("missing lifecycle symbol: %s", required) + } + } + + if tt.wantMraid == 1 { + for _, required := range []string{"exposureChange", "viewableChange", "audioVolumeChange", "[MRAID error]", "stateChange"} { + if !strings.Contains(html, required) { + t.Fatalf("missing MRAID requirement marker: %s", required) + } + } + } + }) + } +} + +func assertCount(t *testing.T, haystack, needle string, want int) { + t.Helper() + if got := strings.Count(haystack, needle); got != want { + t.Fatalf("%q count = %d, want %d", needle, got, want) + } +}