Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 13 additions & 8 deletions .github/workflows/ar_button.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
run: yarn build:dev

- name: 🌐 Install browser
run: yarn playwright install --with-deps chromium

- name: 🚨 Smoke test
run: yarn test:smoke
2 changes: 1 addition & 1 deletion index.html → demo.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Yago AR Button</title>
<script type="module" src="./dist/ar-button.min.js"></script>
<script type="module" src="./src/ar-button.ts"></script>
</head>
<body>
<h1>Yago AR Button</h1>
Expand Down
20 changes: 20 additions & 0 deletions deploy/htaccess.dev
Original file line number Diff line number Diff line change
@@ -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.

<IfModule mod_headers.c>
# Dev must never be cached: a redeploy has to be visible on reload.
<FilesMatch "^(ar-button(\.min)?\.js|version\.json)$">
Header set Cache-Control "no-store, no-cache, must-revalidate"
</FilesMatch>

# Content-addressed builds never change, so they can be cached forever.
<FilesMatch "^ar-button-.+\.js$">
Header set Cache-Control "public, max-age=31536000, immutable"
</FilesMatch>
</IfModule>
19 changes: 19 additions & 0 deletions deploy/htaccess.prod
Original file line number Diff line number Diff line change
@@ -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.

<IfModule mod_headers.c>
# 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.
<FilesMatch "^(ar-button(\.min)?\.js|version\.json)$">
Header set Cache-Control "public, max-age=300, must-revalidate"
</FilesMatch>

# 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.
<FilesMatch "^ar-button-.+\.js$">
Header set Cache-Control "public, max-age=31536000, immutable"
</FilesMatch>
</IfModule>
12 changes: 7 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
Expand Down
76 changes: 76 additions & 0 deletions scripts/postbuild.mjs
Original file line number Diff line number Diff line change
@@ -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-<version>-<hash>.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 <dev|prod>');
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}`);
7 changes: 6 additions & 1 deletion src/ar-button.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
78 changes: 78 additions & 0 deletions test/smoke.mjs
Original file line number Diff line number Diff line change
@@ -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('<ar-button id="probe" qr-size="300"></ar-button>');
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();
}
19 changes: 19 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
Loading