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
54 changes: 54 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ jobs:
VITE_BOT_TOKEN_ENDPOINT: ${{ secrets.VITE_BOT_TOKEN_ENDPOINT }}
VITE_CLARITY_ENABLED: ${{ vars.VITE_CLARITY_ENABLED || 'true' }}
VITE_CLARITY_PROJECT_ID: ${{ secrets.VITE_CLARITY_PROJECT_ID }}
VITE_BUILD_ID: ${{ github.sha }}

- name: Include CNAME
run: cp ../CNAME dist/
Expand Down Expand Up @@ -75,3 +76,56 @@ jobs:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4

- name: Smoke test deployed pages and assets
shell: bash
run: |
set -euo pipefail

base_url="${{ steps.deployment.outputs.page_url }}"
base_url="${base_url%/}"
routes=("/" "/m365" "/program-news")

fetch_route() {
local url="$1"
local html_file="$2"
curl -sS -L -o "${html_file}" -w "%{http_code}" "${url}" || printf "000"
}

fetch_asset_status() {
local url="$1"
curl -sS -L -o /dev/null -w "%{http_code}" "${url}" || printf "000"
}

for route in "${routes[@]}"; do
url="${base_url}${route}"
html_file="$(mktemp)"
status=""

for attempt in 1 2 3; do
status="$(fetch_route "${url}" "${html_file}")"
if [[ "${status}" == "200" ]]; then
break
fi
sleep 10
done

if [[ "${status}" != "200" ]]; then
echo "::error::${url} returned HTTP ${status}"
exit 1
fi

mapfile -t assets < <(grep -Eo '/assets/[^"]+\.(js|css)' "${html_file}" | sort -u)
if [[ "${#assets[@]}" -eq 0 ]]; then
echo "::error::${url} did not reference built JS/CSS assets"
exit 1
fi

for asset in "${assets[@]}"; do
asset_status="$(fetch_asset_status "${base_url}${asset}")"
if [[ "${asset_status}" != "200" ]]; then
echo "::error::${url} references ${asset}, which returned HTTP ${asset_status}"
exit 1
fi
done
done
2 changes: 1 addition & 1 deletion Elevate.Server/src/controllers/activityVideoController.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ function isPlainObject(value) {
}

function getActivityVideoId(req) {
return req.params.activityVideoId || req.params.id;
return req.params.activityVideoId || req.params.activityvideoid || req.params.id;
}

function normalizeOptionalString(value) {
Expand Down
64 changes: 64 additions & 0 deletions Elevate.Server/tests/activity-videos.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,33 @@ test('getAdminActivityVideoDetail returns activity video detail', async () => {
});
});

test('getAdminActivityVideoDetail accepts lowercased Azure route parameter name', async () => {
docs = [{
id: 'video-1',
type: 'activityVideo',
partitionKey: 'activityVideo',
videoId: 'SfK1hajr5qY',
title: 'Title',
category: '행사',
year: '2026',
channel: 'Microsoft Korea',
sortOrder: 1,
status: 'published',
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-02T00:00:00.000Z',
}];
const res = makeRes();

await ctrl.getAdminActivityVideoDetail({
params: { activityvideoid: 'video-1' },
query: {},
correlationId: 'x',
}, res);

assert.equal(res.getStatus(), 200);
assert.equal(res.getBody().id, 'video-1');
});

test('getAdminActivityVideoDetail returns 404 for missing activity video', async () => {
const res = makeRes();

Expand Down Expand Up @@ -246,6 +273,28 @@ test('createActivityVideo creates normalized draft video', async () => {
assert.equal(docs[0].partitionKey, 'activityVideo');
});

test('createActivityVideo preserves optional description and channel values', async () => {
const res = makeRes();

await ctrl.createActivityVideo({
body: {
videoId: 'SfK1hajr5qY',
title: 'Title',
description: ' Description ',
category: '행사',
year: '2026',
channel: ' Custom Channel ',
},
params: {},
query: {},
correlationId: 'x',
}, res);

assert.equal(res.getStatus(), 201);
assert.equal(res.getBody().description, 'Description');
assert.equal(res.getBody().channel, 'Custom Channel');
});

test('updateActivityVideo clears description and publishes video', async () => {
docs = [{
id: 'video-1',
Expand Down Expand Up @@ -388,3 +437,18 @@ test('deleteActivityVideo returns 204', async () => {
assert.equal(res.getStatus(), 204);
assert.deepEqual(deletedItem, { id: 'video-1', pk: 'activityVideo' });
});

test('deleteActivityVideo accepts lowercased Azure route parameter name', async () => {
docs = [{ id: 'video-1', type: 'activityVideo', partitionKey: 'activityVideo' }];
const res = makeRes();

await ctrl.deleteActivityVideo({
body: null,
params: { activityvideoid: 'video-1' },
query: {},
correlationId: 'x',
}, res);

assert.equal(res.getStatus(), 204);
assert.deepEqual(deletedItem, { id: 'video-1', pk: 'activityVideo' });
});
3 changes: 3 additions & 0 deletions Elevate.Web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@
</head>
<body>
<div id="root"></div>
<script>
window.__BUILD_ID__ = "__ELEVATE_BUILD_ID__";
</script>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
4 changes: 3 additions & 1 deletion Elevate.Web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@
"check:seo": "node scripts/check-seo.mjs",
"perf:lighthouse": "node scripts/perf-lighthouse.mjs",
"generate:seo-routes": "node scripts/generate-seo-routes.mjs",
"generate-posts": "node scripts/generate-posts.js"
"generate-posts": "node scripts/generate-posts.js",
"test:chunk-recovery": "node scripts/test-chunk-load-recovery-source.mjs",
"test:build-id": "node scripts/test-build-id-source.mjs"
},
"dependencies": {
"@microsoft/clarity": "^1.0.2",
Expand Down
23 changes: 23 additions & 0 deletions Elevate.Web/scripts/test-build-id-source.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';

const __dirname = dirname(fileURLToPath(import.meta.url));
const viteSource = readFileSync(join(__dirname, '../vite.config.js'), 'utf8');
const indexSource = readFileSync(join(__dirname, '../index.html'), 'utf8');
const mainSource = readFileSync(join(__dirname, '../src/main.jsx'), 'utf8');
const claritySource = readFileSync(join(__dirname, '../src/services/clarity.js'), 'utf8');
const boundarySource = readFileSync(join(__dirname, '../src/components/common/ErrorBoundary.jsx'), 'utf8');

assert.match(viteSource, /VITE_BUILD_ID/);
assert.match(viteSource, /GITHUB_SHA/);
assert.match(viteSource, /__ELEVATE_BUILD_ID__/);
assert.match(viteSource, /transformIndexHtml/);
assert.match(viteSource, /replace\(\/\[\^a-zA-Z0-9\._-\]\/g, ''\)/);
assert.match(viteSource, /\|\| 'dev'/);
assert.match(indexSource, /window\.__BUILD_ID__ = "__ELEVATE_BUILD_ID__"/);
assert.match(mainSource, /setClarityTag\('build_id'/);
assert.match(claritySource, /trackClientDiagnostic/);
assert.match(boundarySource, /window\.__BUILD_ID__/);
assert.match(boundarySource, /trackClientDiagnostic\('render_error'/);
32 changes: 32 additions & 0 deletions Elevate.Web/scripts/test-chunk-load-recovery-source.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';

const __dirname = dirname(fileURLToPath(import.meta.url));
const source = readFileSync(join(__dirname, '../src/services/chunkLoadRecovery.js'), 'utf8');
const mainSource = readFileSync(join(__dirname, '../src/main.jsx'), 'utf8');

assert.match(source, /Failed to fetch dynamically imported module/);
assert.match(source, /Importing a module script failed/);
assert.match(source, /normalizeErrorMessage\(value\)\.toLowerCase\(\)/);
assert.match(source, /pattern\.toLowerCase\(\)/);
assert.match(source, /chunk-recovery-attempted/);
assert.match(source, /chunk-recovery-diagnostic/);
assert.match(source, /__elevateChunkLoadRecoveryStarted/);
assert.match(source, /window\[RECOVERY_STARTED_WINDOW_KEY\]/);
assert.match(source, /sessionStorage\.getItem/);
assert.match(source, /sessionStorage\.setItem/);
assert.match(source, /sessionStorage\.removeItem/);
assert.match(source, /return true/);
assert.match(source, /return false/);
assert.match(source, /const recoveryMarked = safeSessionStorageSet/);
assert.match(source, /if \(!recoveryMarked\)/);
assert.match(source, /queueRecoveryDiagnostic\(message\)/);
assert.match(source, /flushPendingRecoveryDiagnostic\(\)/);
assert.match(source, /JSON\.parse\(pendingDiagnostic\)/);
assert.match(source, /window\.location\.reload\(\)/);
assert.match(source, /window\.addEventListener\('error'/);
assert.match(source, /window\.addEventListener\('unhandledrejection'/);
assert.match(source, /trackClientDiagnostic\('chunk_load_failed'/);
assert.match(mainSource, /startChunkLoadRecovery\(\)/);
7 changes: 7 additions & 0 deletions Elevate.Web/src/components/common/ErrorBoundary.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
* </ErrorBoundary>
*/
import { Component } from 'react';
import { trackClientDiagnostic } from '../../services/clarity';

class ErrorBoundary extends Component {
constructor(props) {
Expand All @@ -24,6 +25,11 @@ class ErrorBoundary extends Component {

componentDidCatch(error, info) {
console.error('[ErrorBoundary]', error, info.componentStack);
trackClientDiagnostic('render_error', {
route: `${window.location.pathname}${window.location.search}`,
build_id: window.__BUILD_ID__ || 'unknown',
message: error?.message || 'unknown',
});
}

render() {
Expand All @@ -33,6 +39,7 @@ class ErrorBoundary extends Component {
<span className="text-5xl">💥</span>
<h2 className="text-xl font-semibold text-slate-700">페이지를 표시할 수 없습니다</h2>
<p className="text-sm text-slate-500 max-w-sm">{this.state.errorMessage}</p>
<p className="text-xs text-slate-400">Build {window.__BUILD_ID__ || 'unknown'}</p>
<button
type="button"
onClick={() => window.location.reload()}
Expand Down
5 changes: 4 additions & 1 deletion Elevate.Web/src/main.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ import { HelmetProvider } from 'react-helmet-async'
import './index.css'
import App from './App.jsx'
import { API_BASE } from './api/client'
import { startClarityWhenIdle } from './services/clarity'
import { setClarityTag, startClarityWhenIdle } from './services/clarity'
import { startChunkLoadRecovery } from './services/chunkLoadRecovery'
import { startInpMeasurement } from './services/webVitals'
import ErrorBoundary from './components/common/ErrorBoundary'

Expand All @@ -31,11 +32,13 @@ function preconnectToApiOrigin() {
}
}

startChunkLoadRecovery()
preconnectToApiOrigin()

// LCP 경로의 네트워크 경쟁을 줄이기 위해 Clarity는 유휴 시점에 시작한다.
// VITE_CLARITY_ENABLED=true 이고 VITE_CLARITY_PROJECT_ID가 설정된 경우에만 실제로 초기화된다.
startClarityWhenIdle()
setClarityTag('build_id', window.__BUILD_ID__ || 'unknown')
startInpMeasurement()

createRoot(document.getElementById('root')).render(
Expand Down
Loading