feat: expose verifiable deployment release
This commit is contained in:
@@ -1,3 +1,5 @@
|
|||||||
|
ARG SOURCE_COMMIT=unknown
|
||||||
|
|
||||||
# Stage 1: Vite SPA build
|
# Stage 1: Vite SPA build
|
||||||
FROM node:22-slim AS build
|
FROM node:22-slim AS build
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -11,6 +13,8 @@ RUN npm run build
|
|||||||
# indirme için gerekli. Sunucu darlığında DT_ENABLED=0 ile kapatılabilir;
|
# indirme için gerekli. Sunucu darlığında DT_ENABLED=0 ile kapatılabilir;
|
||||||
# uygulama ve diğer tüm veri kaynakları Chromium olmadan da çalışır.
|
# uygulama ve diğer tüm veri kaynakları Chromium olmadan da çalışır.
|
||||||
FROM node:22-slim
|
FROM node:22-slim
|
||||||
|
ARG SOURCE_COMMIT
|
||||||
|
ENV APP_RELEASE=${SOURCE_COMMIT}
|
||||||
ENV DEBIAN_FRONTEND=noninteractive
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends nginx ca-certificates curl \
|
&& apt-get install -y --no-install-recommends nginx ca-certificates curl \
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ Bu çalışma notu üretim dağıtımında kullanılacak kontrol sırasını tan
|
|||||||
- `CORS_ORIGINS`: frontend originlerinin virgülle ayrılmış tam HTTPS adresleri.
|
- `CORS_ORIGINS`: frontend originlerinin virgülle ayrılmış tam HTTPS adresleri.
|
||||||
- Sağlayıcı kullanılacaksa ilgili `GOOGLE_CLIENT_ID`, `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, `RESEND_API_KEY` ve `TELEGRAM_BOT_TOKEN` değerleri.
|
- Sağlayıcı kullanılacaksa ilgili `GOOGLE_CLIENT_ID`, `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, `RESEND_API_KEY` ve `TELEGRAM_BOT_TOKEN` değerleri.
|
||||||
- `IHALEIST_USER_DATA_DIR` ve `IHALEIST_SHARED_DATA_DIR`: kalıcı volume altında, uygulama dışından erişilemeyen dizinler.
|
- `IHALEIST_USER_DATA_DIR` ve `IHALEIST_SHARED_DATA_DIR`: kalıcı volume altında, uygulama dışından erişilemeyen dizinler.
|
||||||
|
- Coolify Advanced ayarında **Include Source Commit in Build** açık olmalıdır. Docker imajı `SOURCE_COMMIT` değerini `/api/version` yanıtındaki `release` alanına taşır; bu değer gizli değildir.
|
||||||
|
|
||||||
## Compose ile staging smoke testi
|
## Compose ile staging smoke testi
|
||||||
|
|
||||||
@@ -34,6 +35,16 @@ Coolify uygulaması `ihaleist:main` için sağlık kontrolü `GET http://localho
|
|||||||
3. Yeni imaj staging’de başlatılır; `/api/health`, giriş, dosya yükleme/indirme ve beş sabit regresyon ihalesi doğrulanır.
|
3. Yeni imaj staging’de başlatılır; `/api/health`, giriş, dosya yükleme/indirme ve beş sabit regresyon ihalesi doğrulanır.
|
||||||
4. CORS allowlistesi, auth secret ve sağlayıcı test anahtarları staging yanıtlarında kontrol edilir.
|
4. CORS allowlistesi, auth secret ve sağlayıcı test anahtarları staging yanıtlarında kontrol edilir.
|
||||||
|
|
||||||
|
Hedef commit ve yerel frontend asset hash’i verilerek salt okunur canlı kabul çalıştırılır:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
IHALEIST_EXPECTED_RELEASE="$(git rev-parse HEAD)" \
|
||||||
|
IHALEIST_EXPECTED_ASSET_SHA256="$(sha256sum dist/assets/index-*.js | awk '{print $1}')" \
|
||||||
|
npm run qa:live -- https://ihaleist.com
|
||||||
|
```
|
||||||
|
|
||||||
|
macOS üzerinde `sha256sum` yerine `shasum -a 256` kullanılabilir. Probe `/api/version` release değerini, ana sayfadaki gerçek asset’i ve container içinden bildirilen asset hash’ini birlikte karşılaştırır.
|
||||||
|
|
||||||
## Kademeli yayın
|
## Kademeli yayın
|
||||||
|
|
||||||
Önce tek test firmasıyla trafik açılır. Kayıt, belge, analiz ve bildirim metrikleri izlenir. Hata oranı veya veri tutarsızlığı artarsa yeni trafik durdurulur; dış sağlayıcılara gerçek ödeme, e-posta veya resmi teklif gönderimi yapılmaz.
|
Önce tek test firmasıyla trafik açılır. Kayıt, belge, analiz ve bildirim metrikleri izlenir. Hata oranı veya veri tutarsızlığı artarsa yeni trafik durdurulur; dış sağlayıcılara gerçek ödeme, e-posta veya resmi teklif gönderimi yapılmaz.
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ import { subscribe, list as listSubs, remove as removeSub, deliveryQueue, resolv
|
|||||||
import { bildirimleriIsle } from './notifier.mjs';
|
import { bildirimleriIsle } from './notifier.mjs';
|
||||||
import { scanSite } from './site-scan.mjs';
|
import { scanSite } from './site-scan.mjs';
|
||||||
import { createDetailCache } from './detail-cache.mjs';
|
import { createDetailCache } from './detail-cache.mjs';
|
||||||
|
import { releaseInfo } from './release-info.mjs';
|
||||||
|
|
||||||
// ilan.gov.tr sunucusu ara sertifikayı (GeoTrust TLS RSA CA G1) göndermiyor;
|
// ilan.gov.tr sunucusu ara sertifikayı (GeoTrust TLS RSA CA G1) göndermiyor;
|
||||||
// zinciri tamamlamak için PEM'i yerelden ekliyoruz.
|
// zinciri tamamlamak için PEM'i yerelden ekliyoruz.
|
||||||
@@ -75,6 +76,7 @@ const CA_BUNDLE = [...tls.rootCertificates, EXTRA_CA];
|
|||||||
const PORT = process.env.PORT || 3001;
|
const PORT = process.env.PORT || 3001;
|
||||||
const ILAN_BASE = 'https://www.ilan.gov.tr';
|
const ILAN_BASE = 'https://www.ilan.gov.tr';
|
||||||
const CORS_ORIGINS = String(process.env.CORS_ORIGINS || '').split(',').map(value => value.trim()).filter(Boolean);
|
const CORS_ORIGINS = String(process.env.CORS_ORIGINS || '').split(',').map(value => value.trim()).filter(Boolean);
|
||||||
|
const RUNTIME_RELEASE = releaseInfo();
|
||||||
|
|
||||||
function readiness() {
|
function readiness() {
|
||||||
const directories = {
|
const directories = {
|
||||||
@@ -1732,6 +1734,7 @@ const server = http.createServer(async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (url.pathname === '/api/health') return json(res, 200, { ok: true });
|
if (url.pathname === '/api/health') return json(res, 200, { ok: true });
|
||||||
|
if (url.pathname === '/api/version') return json(res, 200, { ok: true, ...RUNTIME_RELEASE });
|
||||||
if (url.pathname === '/api/ready') {
|
if (url.pathname === '/api/ready') {
|
||||||
const status = readiness();
|
const status = readiness();
|
||||||
return json(res, status.ready ? 200 : 503, { ok: status.ready, ...status });
|
return json(res, status.ready ? 200 : 503, { ok: status.ready, ...status });
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import crypto from 'node:crypto';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const DEFAULT_ASSET_ROOTS = [
|
||||||
|
'/usr/share/nginx/html',
|
||||||
|
path.resolve(process.cwd(), 'dist'),
|
||||||
|
];
|
||||||
|
|
||||||
|
function cleanRelease(value) {
|
||||||
|
const release = String(value || '').trim();
|
||||||
|
if (!release || release === 'unknown' || release.length > 128) return null;
|
||||||
|
return release;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deployedRelease(env = process.env) {
|
||||||
|
return cleanRelease(env.APP_RELEASE) || cleanRelease(env.SOURCE_COMMIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function frontendAssetInfo(roots = DEFAULT_ASSET_ROOTS) {
|
||||||
|
for (const root of roots) {
|
||||||
|
try {
|
||||||
|
const indexPath = path.join(root, 'index.html');
|
||||||
|
const html = fs.readFileSync(indexPath, 'utf8');
|
||||||
|
const match = html.match(/<script[^>]+src=["']([^"']*\/assets\/[^"']+\.js(?:\?[^"']*)?)["']/i);
|
||||||
|
if (!match) continue;
|
||||||
|
const publicPath = new URL(match[1], 'https://ihaleist.invalid/').pathname;
|
||||||
|
const assetPath = path.resolve(root, `.${publicPath}`);
|
||||||
|
const rootPath = path.resolve(root) + path.sep;
|
||||||
|
if (!assetPath.startsWith(rootPath)) continue;
|
||||||
|
const bytes = fs.readFileSync(assetPath);
|
||||||
|
return {
|
||||||
|
path: publicPath,
|
||||||
|
sha256: crypto.createHash('sha256').update(bytes).digest('hex'),
|
||||||
|
bytes: bytes.length,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
// Bu kökte derlenmiş arayüz yoksa sıradaki olası köke bakılır.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function releaseInfo({ env = process.env, roots = DEFAULT_ASSET_ROOTS } = {}) {
|
||||||
|
return {
|
||||||
|
release: deployedRelease(env),
|
||||||
|
version: '1.0.0',
|
||||||
|
frontendAsset: frontendAssetInfo(roots),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@ const request=async(path,method='GET',body,auth=token)=>{
|
|||||||
before(async()=>{
|
before(async()=>{
|
||||||
const socket=createServer();await new Promise(r=>socket.listen(0,'127.0.0.1',r));const port=socket.address().port;await new Promise(r=>socket.close(r));
|
const socket=createServer();await new Promise(r=>socket.listen(0,'127.0.0.1',r));const port=socket.address().port;await new Promise(r=>socket.close(r));
|
||||||
base='http://127.0.0.1:'+port;
|
base='http://127.0.0.1:'+port;
|
||||||
child=spawn(process.execPath,['server/index.js'],{cwd:new URL('..',import.meta.url),env:{...process.env,NODE_ENV:'test',CORS_ORIGINS:'https://client.example,https://admin.example',TELEGRAM_BOT_TOKEN:'',PORT:String(port),IHALEIST_USER_DATA_DIR:dir,IHALEIST_SHARED_DATA_DIR:join(dir,'shared'),UYUM_GOMME:'0'},stdio:'ignore'});
|
child=spawn(process.execPath,['server/index.js'],{cwd:new URL('..',import.meta.url),env:{...process.env,NODE_ENV:'test',APP_RELEASE:'0123456789abcdef',CORS_ORIGINS:'https://client.example,https://admin.example',TELEGRAM_BOT_TOKEN:'',PORT:String(port),IHALEIST_USER_DATA_DIR:dir,IHALEIST_SHARED_DATA_DIR:join(dir,'shared'),UYUM_GOMME:'0'},stdio:'ignore'});
|
||||||
for(let i=0;i<100;i++){try{await fetch(base+'/api/auth/google/config');return;}catch{await new Promise(r=>setTimeout(r,50));}}
|
for(let i=0;i<100;i++){try{await fetch(base+'/api/auth/google/config');return;}catch{await new Promise(r=>setTimeout(r,50));}}
|
||||||
throw new Error('QA API did not start');
|
throw new Error('QA API did not start');
|
||||||
});
|
});
|
||||||
@@ -55,6 +55,18 @@ test('readiness endpoint checks persistent data directories',async()=>{
|
|||||||
assert.equal(response.data.checks.userData.ok,true);
|
assert.equal(response.data.checks.userData.ok,true);
|
||||||
assert.equal(response.data.checks.sharedData.ok,true);
|
assert.equal(response.data.checks.sharedData.ok,true);
|
||||||
});
|
});
|
||||||
|
test('version endpoint identifies the running release and frontend asset',async()=>{
|
||||||
|
const response=await request('/api/version','GET',null,null);
|
||||||
|
assert.equal(response.status,200);
|
||||||
|
assert.equal(response.data.ok,true);
|
||||||
|
assert.equal(response.data.release,'0123456789abcdef');
|
||||||
|
assert.equal(response.data.version,'1.0.0');
|
||||||
|
if(response.data.frontendAsset){
|
||||||
|
assert.match(response.data.frontendAsset.path,/^\/assets\/.+\.js$/);
|
||||||
|
assert.match(response.data.frontendAsset.sha256,/^[a-f0-9]{64}$/);
|
||||||
|
assert.ok(response.data.frontendAsset.bytes>0);
|
||||||
|
}
|
||||||
|
});
|
||||||
test('HTTP documents and API keys expose empty successful state, not endless load errors',async()=>{
|
test('HTTP documents and API keys expose empty successful state, not endless load errors',async()=>{
|
||||||
const docs=await request('/api/me/documents');assert.equal(docs.status,200);assert.deepEqual(docs.data.belgeler,[]);
|
const docs=await request('/api/me/documents');assert.equal(docs.status,200);assert.deepEqual(docs.data.belgeler,[]);
|
||||||
const keys=await request('/api/me/apikeys');assert.equal(keys.status,200);assert.deepEqual(keys.data.anahtarlar,[]);
|
const keys=await request('/api/me/apikeys');assert.equal(keys.status,200);assert.deepEqual(keys.data.anahtarlar,[]);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
// provider calls are performed. It intentionally fails when a required
|
// provider calls are performed. It intentionally fails when a required
|
||||||
// deployment contract is missing.
|
// deployment contract is missing.
|
||||||
import { mkdir, writeFile } from 'node:fs/promises';
|
import { mkdir, writeFile } from 'node:fs/promises';
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
|
||||||
const base = String(process.argv[2] || process.env.IHALEIST_LIVE_URL || 'https://ihaleist.com').replace(/\/$/, '');
|
const base = String(process.argv[2] || process.env.IHALEIST_LIVE_URL || 'https://ihaleist.com').replace(/\/$/, '');
|
||||||
const evidencePath = process.env.IHALEIST_LIVE_EVIDENCE_PATH || 'docs/quality/evidence/qa-live-runtime.json';
|
const evidencePath = process.env.IHALEIST_LIVE_EVIDENCE_PATH || 'docs/quality/evidence/qa-live-runtime.json';
|
||||||
@@ -13,8 +14,10 @@ const requiredHeaders = [
|
|||||||
];
|
];
|
||||||
const observedHeaders = [...new Set([...requiredHeaders, 'strict-transport-security'])];
|
const observedHeaders = [...new Set([...requiredHeaders, 'strict-transport-security'])];
|
||||||
const checks = {};
|
const checks = {};
|
||||||
|
const expectedRelease = String(process.env.IHALEIST_EXPECTED_RELEASE || '').trim();
|
||||||
|
const expectedAssetSha256 = String(process.env.IHALEIST_EXPECTED_ASSET_SHA256 || '').trim();
|
||||||
|
|
||||||
async function probe(path, successField = null) {
|
async function probe(path, successField = null, captureText = false) {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
try {
|
try {
|
||||||
@@ -24,11 +27,14 @@ async function probe(path, successField = null) {
|
|||||||
contentType: response.headers.get('content-type'),
|
contentType: response.headers.get('content-type'),
|
||||||
headers: Object.fromEntries(observedHeaders.map(name => [name, response.headers.get(name)])),
|
headers: Object.fromEntries(observedHeaders.map(name => [name, response.headers.get(name)])),
|
||||||
};
|
};
|
||||||
if (successField) {
|
if (successField || captureText) {
|
||||||
const raw = await response.text();
|
const raw = await response.text();
|
||||||
|
if (captureText) result.text = raw;
|
||||||
|
if (successField) {
|
||||||
try { result.json = JSON.parse(raw); } catch { result.jsonError = 'JSON yanıtı ayrıştırılamadı.'; }
|
try { result.json = JSON.parse(raw); } catch { result.jsonError = 'JSON yanıtı ayrıştırılamadı.'; }
|
||||||
result.apiSuccess = result.json?.[successField] === true;
|
result.apiSuccess = result.json?.[successField] === true;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { error: error.name === 'AbortError' ? `timeout after ${timeoutMs}ms` : error.message };
|
return { error: error.name === 'AbortError' ? `timeout after ${timeoutMs}ms` : error.message };
|
||||||
@@ -37,22 +43,63 @@ async function probe(path, successField = null) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
checks.home = await probe('/');
|
async function probeFrontendAsset(publicPath) {
|
||||||
|
if (!publicPath) return { error: 'Ana sayfada Vite JavaScript asset yolu bulunamadı.' };
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
try {
|
||||||
|
const response = await fetch(new URL(publicPath, base), { signal: controller.signal, redirect: 'manual' });
|
||||||
|
const bytes = Buffer.from(await response.arrayBuffer());
|
||||||
|
return {
|
||||||
|
status: response.status,
|
||||||
|
contentType: response.headers.get('content-type'),
|
||||||
|
headers: Object.fromEntries(observedHeaders.map(name => [name, response.headers.get(name)])),
|
||||||
|
path: new URL(publicPath, base).pathname,
|
||||||
|
bytes: bytes.length,
|
||||||
|
sha256: crypto.createHash('sha256').update(bytes).digest('hex'),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return { error: error.name === 'AbortError' ? `timeout after ${timeoutMs}ms` : error.message };
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
checks.home = await probe('/', null, true);
|
||||||
|
const assetMatch = checks.home.text?.match(/<script[^>]+src=["']([^"']*\/assets\/[^"']+\.js(?:\?[^"']*)?)["']/i);
|
||||||
|
delete checks.home.text;
|
||||||
checks.health = await probe('/api/health', 'ok');
|
checks.health = await probe('/api/health', 'ok');
|
||||||
// /api/health and /api/ready use the same { ok: true } probe contract. The
|
// /api/health and /api/ready use the same { ok: true } probe contract. The
|
||||||
// readiness payload also includes a separate `ready` diagnostic field.
|
// readiness payload also includes a separate `ready` diagnostic field.
|
||||||
checks.ready = await probe('/api/ready', 'ok');
|
checks.ready = await probe('/api/ready', 'ok');
|
||||||
|
checks.version = await probe('/api/version', 'ok');
|
||||||
// The EKAP document fallback depends on the bundled browser being available;
|
// The EKAP document fallback depends on the bundled browser being available;
|
||||||
// check its public capability endpoint without opening a document or mutating
|
// check its public capability endpoint without opening a document or mutating
|
||||||
// any account data.
|
// any account data.
|
||||||
checks.documents = await probe('/api/documents/config', 'tarayiciVar');
|
checks.documents = await probe('/api/documents/config', 'tarayiciVar');
|
||||||
const missingHeaders = requiredHeaders.filter(name => [checks.home, checks.health, checks.ready, checks.documents].some(check => check?.headers?.[name] == null));
|
checks.frontendAsset = await probeFrontendAsset(assetMatch?.[1]);
|
||||||
|
const release = checks.version?.json?.release;
|
||||||
|
const releaseValid = typeof release === 'string' && /^[a-f0-9]{7,64}$/i.test(release);
|
||||||
|
const releaseMatches = !expectedRelease || release === expectedRelease;
|
||||||
|
const reportedAsset = checks.version?.json?.frontendAsset;
|
||||||
|
const assetMatchesRuntime = Boolean(reportedAsset?.sha256)
|
||||||
|
&& reportedAsset.sha256 === checks.frontendAsset?.sha256
|
||||||
|
&& reportedAsset.path === checks.frontendAsset?.path;
|
||||||
|
const assetMatchesExpected = !expectedAssetSha256 || checks.frontendAsset?.sha256 === expectedAssetSha256;
|
||||||
|
const publicChecks = [checks.home, checks.health, checks.ready, checks.version, checks.documents, checks.frontendAsset];
|
||||||
|
const missingHeaders = requiredHeaders.filter(name => publicChecks.some(check => check?.headers?.[name] == null));
|
||||||
const report = {
|
const report = {
|
||||||
base,
|
base,
|
||||||
readOnly: true,
|
readOnly: true,
|
||||||
checkedAt: new Date().toISOString(),
|
checkedAt: new Date().toISOString(),
|
||||||
|
expectedRelease: expectedRelease || null,
|
||||||
|
expectedAssetSha256: expectedAssetSha256 || null,
|
||||||
checks,
|
checks,
|
||||||
pass: checks.home?.status === 200 && checks.health?.status === 200 && checks.health.apiSuccess === true && checks.ready?.status === 200 && checks.ready.apiSuccess === true && checks.documents?.status === 200 && checks.documents.apiSuccess === true && missingHeaders.length === 0,
|
releaseValid,
|
||||||
|
releaseMatches,
|
||||||
|
assetMatchesRuntime,
|
||||||
|
assetMatchesExpected,
|
||||||
|
pass: checks.home?.status === 200 && checks.health?.status === 200 && checks.health.apiSuccess === true && checks.ready?.status === 200 && checks.ready.apiSuccess === true && checks.version?.status === 200 && checks.version.apiSuccess === true && checks.documents?.status === 200 && checks.documents.apiSuccess === true && checks.frontendAsset?.status === 200 && releaseValid && releaseMatches && assetMatchesRuntime && assetMatchesExpected && missingHeaders.length === 0,
|
||||||
missingHeaders,
|
missingHeaders,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { deployedRelease, frontendAssetInfo } from '../server/release-info.mjs';
|
||||||
|
|
||||||
|
test('release metadata rejects missing build markers', () => {
|
||||||
|
assert.equal(deployedRelease({}), null);
|
||||||
|
assert.equal(deployedRelease({ APP_RELEASE: 'unknown' }), null);
|
||||||
|
assert.equal(deployedRelease({ SOURCE_COMMIT: 'abc1234' }), 'abc1234');
|
||||||
|
assert.equal(deployedRelease({ APP_RELEASE: 'release-a', SOURCE_COMMIT: 'release-b' }), 'release-a');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('frontend asset metadata hashes the script referenced by index.html', () => {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), 'ihaleist-release-'));
|
||||||
|
try {
|
||||||
|
mkdirSync(join(root, 'assets'));
|
||||||
|
writeFileSync(join(root, 'index.html'), '<script type="module" src="/assets/index-test.js"></script>');
|
||||||
|
writeFileSync(join(root, 'assets/index-test.js'), 'console.log("release");\n');
|
||||||
|
assert.deepEqual(frontendAssetInfo([root]), {
|
||||||
|
path: '/assets/index-test.js',
|
||||||
|
sha256: 'ed81365d3ce25c2e499a43c7d91c72cbc05c6475a19423f18fc3917c96857e80',
|
||||||
|
bytes: 24,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
rmSync(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user