diff --git a/.github/workflows/ar_button.yml b/.github/workflows/ar_button.yml index f423f19..6d0b838 100644 --- a/.github/workflows/ar_button.yml +++ b/.github/workflows/ar_button.yml @@ -6,17 +6,22 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - - name: 📦 Set up node 12 - uses: actions/setup-node@v2 + - name: 📦 Set up node + uses: actions/setup-node@v4 with: - node-version: '12' + node-version: '20' + cache: yarn - name: 💿 Install dependencies - run: | - npm install yarn - yarn install --pure-lockfile + run: yarn install --frozen-lockfile - name: 🏗️ Build - run: yarn build:dev \ No newline at end of file + run: yarn build:dev + + - name: 🌐 Install browser + run: yarn playwright install --with-deps chromium + + - name: 🚨 Smoke test + run: yarn test:smoke diff --git a/index.html b/demo.html similarity index 80% rename from index.html rename to demo.html index f8dfb4b..e7f1606 100644 --- a/index.html +++ b/demo.html @@ -5,7 +5,7 @@ Yago AR Button - +

Yago AR Button

diff --git a/deploy/htaccess.dev b/deploy/htaccess.dev new file mode 100644 index 0000000..62fbed5 --- /dev/null +++ b/deploy/htaccess.dev @@ -0,0 +1,20 @@ +# Deployed into the ar-button/ directory by `yarn deploy:dev`. +# +# Apache merges this with the docroot .htaccess, which sets the CORS headers. +# Only Cache-Control is set here, so the two never collide. +# +# The gsutil deploys used to set "no-store, no-cache" on dev. That was lost in +# the move to rsync (Dec 2025), leaving responses with no Cache-Control at all, +# so browsers fell back to heuristic caching and held the bundle for weeks. + + + # Dev must never be cached: a redeploy has to be visible on reload. + + Header set Cache-Control "no-store, no-cache, must-revalidate" + + + # Content-addressed builds never change, so they can be cached forever. + + Header set Cache-Control "public, max-age=31536000, immutable" + + diff --git a/deploy/htaccess.prod b/deploy/htaccess.prod new file mode 100644 index 0000000..47b6ca2 --- /dev/null +++ b/deploy/htaccess.prod @@ -0,0 +1,19 @@ +# Deployed into the ar-button/ directory by `yarn deploy:prod`. +# +# Apache merges this with the docroot .htaccess, which sets the CORS headers. +# Only Cache-Control is set here, so the two never collide. + + + # Customers embed these filenames directly, so they can never be renamed or + # hashed. They must stay revalidating instead, or a redeploy takes weeks to + # reach browsers. Revalidation is cheap: ETag turns it into a 304. + + Header set Cache-Control "public, max-age=300, must-revalidate" + + + # Content-addressed builds never change, so they can be cached forever. + # Pin one of these if you need a build that cannot shift under you. + + Header set Cache-Control "public, max-age=31536000, immutable" + + diff --git a/package.json b/package.json index 6a46b1d..7d0da22 100644 --- a/package.json +++ b/package.json @@ -13,12 +13,13 @@ } }, "scripts": { - "serve": "parcel ./index.html --port 8080 --target default", + "serve": "parcel ./demo.html --port 8080 --target default", "watch": "parcel watch --target default", - "build:dev": "rm -rf dist/* && NODE_ENV=development parcel build --target default", - "build:prod": "rm -rf dist/* && NODE_ENV=production parcel build --target default && mv dist/ar-button.js dist/ar-button.min.js", - "deploy:dev": "rsync -avz --delete dist/ loxanimo@bitforge.ch:www/dist.dev.yago.cloud/", - "deploy:prod": "rsync -avz --delete dist/ loxanimo@bitforge.ch:www/dist.yago.cloud/" + "build:dev": "rm -rf dist && NODE_ENV=development parcel build --target default && node scripts/postbuild.mjs dev", + "test:smoke": "node test/smoke.mjs", + "build:prod": "rm -rf dist && NODE_ENV=production parcel build --target default && node scripts/postbuild.mjs prod", + "deploy:dev": "yarn build:dev && rsync -avz dist/ loxanimo@bitforge.ch:www/dist.dev.yago.cloud/ar-button/", + "deploy:prod": "yarn build:prod && rsync -avz --exclude='*.map' dist/ loxanimo@bitforge.ch:www/dist.yago.cloud/ar-button/" }, "author": "Bitforge AG", "license": "MIT", @@ -37,6 +38,7 @@ "@webcomponents/webcomponentsjs": "^2.8.0", "buffer": "^6.0.3", "parcel": "^2.16.4", + "playwright": "^1.58.2", "prettier": "^3.8.1" }, "customElements": "custom-elements.json" diff --git a/scripts/postbuild.mjs b/scripts/postbuild.mjs new file mode 100644 index 0000000..f2e0dcd --- /dev/null +++ b/scripts/postbuild.mjs @@ -0,0 +1,76 @@ +/** + * Post-build step: lay out dist/ so a deploy is both stable and identifiable. + * + * Produces, from the single bundle Parcel emits: + * + * ar-button.js stable entry, what new embeds should use + * ar-button.min.js identical copy under the legacy filename + * ar-button--.js immutable, content-addressed build + * version.json what is actually deployed here + * .htaccess per-environment Cache-Control + * + * Why a copy and not the old `mv`: production URLs have shipped as + * ar-button.min.js since the Vue CLI days and are embedded on customer sites, + * so that name can never disappear. Emitting both lets both environments move + * to ar-button.js without breaking anything already out there. + * + * The hashed copy exists because the stable names cannot be hashed - customers + * control those references. It gives something immutable to pin, and makes + * "which build is live?" answerable with one request instead of an + * investigation into CDN headers. + */ +import { readFileSync, writeFileSync, copyFileSync, existsSync, mkdirSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { execSync } from 'node:child_process'; +import { join } from 'node:path'; + +const env = process.argv[2]; +if (env !== 'dev' && env !== 'prod') { + console.error('usage: node scripts/postbuild.mjs '); + process.exit(1); +} + +const DIST = 'dist'; +const ENTRY = join(DIST, 'ar-button.js'); + +if (!existsSync(ENTRY)) { + console.error(`✗ ${ENTRY} not found - did the parcel build run?`); + process.exit(1); +} + +const bundle = readFileSync(ENTRY); +const hash = createHash('sha256').update(bundle).digest('hex').slice(0, 8); +const pkg = JSON.parse(readFileSync('package.json', 'utf8')); +const immutable = `ar-button-${pkg.version}-${hash}.js`; + +// Legacy filename, kept forever for embeds already in the wild. +copyFileSync(ENTRY, join(DIST, 'ar-button.min.js')); +copyFileSync(ENTRY, join(DIST, immutable)); + +const htaccess = join('deploy', `htaccess.${env}`); +if (!existsSync(htaccess)) { + console.error(`✗ ${htaccess} not found`); + process.exit(1); +} +copyFileSync(htaccess, join(DIST, '.htaccess')); + +let commit = 'unknown'; +try { + commit = execSync('git rev-parse --short HEAD', { stdio: ['ignore', 'pipe', 'ignore'] }) + .toString() + .trim(); +} catch { + // building outside a git checkout is fine, just less traceable +} + +const info = { + version: pkg.version, + hash, + commit, + env, + built: new Date().toISOString(), + files: { latest: 'ar-button.js', legacy: 'ar-button.min.js', immutable }, +}; +writeFileSync(join(DIST, 'version.json'), JSON.stringify(info, null, 2) + '\n'); + +console.log(`postbuild (${env}): ${immutable} commit ${commit}`); diff --git a/src/ar-button.ts b/src/ar-button.ts index 2c28f00..760b68b 100644 --- a/src/ar-button.ts +++ b/src/ar-button.ts @@ -88,7 +88,12 @@ export class ArButton extends LitElement { qrCodeAppended = false; - static get observedAttributes() {return [ 'model', 'lang', 'text', 'qr-size', 'qr-title', 'qr-text', 'project-color' ]} + static get observedAttributes() { + // Must include super's list: that getter is what triggers Lit's + // finalize(), which builds elementStyles. Without it the component + // renders with no styles at all. + return [...super.observedAttributes, 'model', 'lang', 'text', 'qr-size', 'qr-title', 'qr-text', 'project-color']; + } attributeChangedCallback(name: string, oldValue: any, newValue: any) { if (name == 'model') diff --git a/test/smoke.mjs b/test/smoke.mjs new file mode 100644 index 0000000..e284830 --- /dev/null +++ b/test/smoke.mjs @@ -0,0 +1,78 @@ +/** + * Smoke test: load the built bundle in a real browser and check the component + * actually renders with its styles applied. + * + * Guards the regression where ArButton overrode `static observedAttributes` + * without calling super. That getter is what triggers Lit's finalize(), which + * builds elementStyles from `static styles`. Without it the element got no + * stylesheet at all, so `.hidden { display: none }` never applied and the QR / + * browser-unsupported popups rendered permanently on top of the host page. + * + * The bundle still builds perfectly when this breaks, so building is not + * enough - it has to be rendered to be caught. + */ +import { chromium } from 'playwright'; +import assert from 'node:assert/strict'; +import { existsSync } from 'node:fs'; + +const BUNDLE = process.env.SMOKE_BUNDLE ?? 'dist/ar-button.js'; + +if (!existsSync(BUNDLE)) { + console.error(`✗ Missing ${BUNDLE} - run \`yarn build:dev\` first.`); + process.exit(1); +} + +const browser = await chromium.launch(); + +try { + const page = await browser.newPage(); + await page.setContent(''); + await page.addScriptTag({ path: BUNDLE }); + + await page.waitForFunction( + () => document.getElementById('probe')?.shadowRoot?.querySelector('ar-modal'), + null, + { timeout: 30000 } + ); + await page.evaluate(() => document.getElementById('probe').updateComplete); + + const result = await page.evaluate(() => { + const ctor = customElements.get('ar-button'); + const el = document.getElementById('probe'); + const modal = el.shadowRoot.querySelector('ar-modal'); + const link = el.shadowRoot.querySelector('a.ar-link'); + return { + elementStyles: ctor.elementStyles?.length ?? 0, + adoptedStyleSheets: el.shadowRoot.adoptedStyleSheets.length, + styleTags: el.shadowRoot.querySelectorAll('style').length, + qrModalDisplay: getComputedStyle(modal).display, + buttonDisplay: link ? getComputedStyle(link).display : '(no button)', + }; + }); + + console.log('ar-button smoke test:', result); + + assert.ok( + result.elementStyles >= 1, + '`static styles` were never finalized into elementStyles - does ArButton ' + + 'override observedAttributes without calling super.observedAttributes?' + ); + assert.ok( + result.adoptedStyleSheets + result.styleTags >= 1, + 'shadow root has no stylesheet attached, the component will render unstyled' + ); + assert.equal( + result.qrModalDisplay, + 'none', + 'QR popup should be hidden until the button is activated' + ); + assert.equal( + result.buttonDisplay, + 'inline-flex', + 'AR button is not picking up its own styles' + ); + + console.log('✅ ar-button renders with its styles applied'); +} finally { + await browser.close(); +} diff --git a/yarn.lock b/yarn.lock index 39b96d7..b5fdf0c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1092,6 +1092,11 @@ exec-sh@^0.2.0: dependencies: merge "^1.2.0" +fsevents@2.3.2: + version "2.3.2" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" + integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== + get-port@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/get-port/-/get-port-4.2.0.tgz#e37368b1e863b7629c43c5a323625f95cf24b119" @@ -1361,6 +1366,20 @@ picomatch@^4.0.3: resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.3.tgz#796c76136d1eead715db1e7bad785dedd695a042" integrity sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q== +playwright-core@1.61.1: + version "1.61.1" + resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.61.1.tgz#3c99841307efbbabc9d724c41a88c914705d15fc" + integrity sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg== + +playwright@^1.58.2: + version "1.61.1" + resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.61.1.tgz#d8c0c06eb93c28981afc747bace453bdbd5018bc" + integrity sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ== + dependencies: + playwright-core "1.61.1" + optionalDependencies: + fsevents "2.3.2" + postcss-value-parser@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514"