refactor: remove bundled personal portfolio
@@ -25,3 +25,6 @@ npm-debug.log*
|
||||
Dockerfile*
|
||||
compose*.yaml
|
||||
compose*.yml
|
||||
|
||||
portfolio-private-bundle
|
||||
private-bundle
|
||||
|
||||
4
.gitignore
vendored
@@ -50,3 +50,7 @@ Desktop.ini
|
||||
/releases/
|
||||
/data/
|
||||
/backups/
|
||||
|
||||
# Private import bundles belong outside this checkout.
|
||||
/portfolio-private-bundle/
|
||||
/private-bundle/
|
||||
|
||||
@@ -229,6 +229,5 @@ npm run test:e2e
|
||||
```
|
||||
|
||||
Browser tests provision a temporary owner in a temporary data directory. Their
|
||||
credentials are test fixtures, not production defaults. The container smoke test
|
||||
checks generic startup, packaged legacy import, localized pages, and image
|
||||
optimization. Run `scripts/smoke-container.sh IMAGE_TAG` against the built image.
|
||||
credentials are test fixtures, not production defaults. The optional container smoke test
|
||||
checks generic startup, owner commands, uploads, and persistence. Run `scripts/smoke-container.sh IMAGE_TAG` against the built image.
|
||||
|
||||
|
Before Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 217 KiB |
|
Before Width: | Height: | Size: 516 KiB |
|
Before Width: | Height: | Size: 234 KiB |
|
Before Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 89 KiB |
|
Before Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 978 KiB |
|
Before Width: | Height: | Size: 905 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 258 KiB |
|
Before Width: | Height: | Size: 249 KiB |
|
Before Width: | Height: | Size: 2.3 MiB |
|
Before Width: | Height: | Size: 303 KiB |
|
Before Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 700 KiB |
|
Before Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 161 KiB |
|
Before Width: | Height: | Size: 218 KiB |
|
Before Width: | Height: | Size: 132 KiB |
|
Before Width: | Height: | Size: 905 KiB |
|
Before Width: | Height: | Size: 978 KiB |
|
Before Width: | Height: | Size: 983 KiB |
|
Before Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 559 KiB |
|
Before Width: | Height: | Size: 601 KiB |
|
Before Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 8.0 KiB |
|
Before Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 4.0 KiB |
@@ -7,10 +7,24 @@ const cwd = path.resolve('.next/standalone');
|
||||
const directory = await mkdtemp(path.join(tmpdir(), 'portfolio-e2e-'));
|
||||
const port = process.env.PLAYWRIGHT_PORT || '43119';
|
||||
const env = { ...process.env, DATA_DIR: directory, SITE_URL: `http://127.0.0.1:${port}`, HOSTNAME: '127.0.0.1', PORT: port, OWNER_EMAIL: 'e2e@example.com', OWNER_PASSWORD: 'Only-for-isolated-e2e-tests-2026' };
|
||||
for (const command of ['import-legacy', 'create-owner']) {
|
||||
for (const command of ['init', 'create-owner']) {
|
||||
const result = spawnSync(process.execPath, ['maintenance.mjs', command], { cwd, env, stdio: 'inherit' });
|
||||
if (result.status !== 0) { await rm(directory, { recursive: true, force: true }); process.exit(result.status || 1); }
|
||||
}
|
||||
// Generic sample content keeps browser fixtures independent of a private portfolio.
|
||||
const seeded = spawnSync(process.execPath, ['--import', 'tsx', '-e', `
|
||||
const { initialize } = require('./src/server/initialize.ts');
|
||||
const { readContent, saveContent } = require('./src/server/content.ts');
|
||||
const db = initialize();
|
||||
saveContent(db, 'languages', { code: 'fa', name: 'فارسی', direction: 'rtl', enabled: true, messages: {} });
|
||||
const project = readContent(db).entries.find(item => item.kind === 'projects');
|
||||
project.shared.liveUrl = 'https://example.com';
|
||||
project.shared.caseStudy.gallery = [1, 2].map(index => ({ image: project.shared.image, alt: 'Sample image ' + index, caption: 'Sample caption ' + index }));
|
||||
saveContent(db, 'entries', project);
|
||||
saveContent(db, 'entries', { ...project, id: 'another-project', position: 1, shared: { ...project.shared, title: 'Another project' } });
|
||||
db.sqlite.close();
|
||||
`], { cwd: path.resolve('.'), env, stdio: 'inherit' });
|
||||
if (seeded.status !== 0) { await rm(directory, { recursive: true, force: true }); process.exit(seeded.status || 1); }
|
||||
const server = spawn(process.execPath, ['server.js'], { cwd, env, stdio: 'inherit' });
|
||||
process.on('SIGTERM', () => server.kill()); process.on('SIGINT', () => server.kill());
|
||||
server.on('exit', async code => { await rm(directory, { recursive: true, force: true }); process.exit(code || 0); });
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
// Run only when refreshing the explicit legacy-import fixture, before editing legacy data.
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { messagesByLocale } from '../src/i18n/content';
|
||||
import { getProjects } from '../src/data/projects';
|
||||
import { getExperiences } from '../src/data/experience';
|
||||
import { getEducation } from '../src/data/education';
|
||||
import { getMilestones } from '../src/data/milestones';
|
||||
import { filterTabs, skillsByDiscipline } from '../src/data/skills';
|
||||
|
||||
const placeholders: Record<string, string[]> = {
|
||||
ariaLabel: ['progress'], progressLabel: ['progress'], liveProjectLabel: ['title'],
|
||||
openGalleryImage: ['caption'], selectGalleryImage: ['index', 'caption'],
|
||||
imageCounter: ['current', 'total'], logoAlt: ['company'], viewCertificateLabel: ['title'],
|
||||
};
|
||||
const fixture = { messages: messagesByLocale, entries: Object.fromEntries(['en', 'fa'].map(locale => [locale, {
|
||||
projects: getProjects(locale as 'en' | 'fa'), experience: getExperiences(locale as 'en' | 'fa'),
|
||||
education: [{ id: 'education', ...getEducation(locale as 'en' | 'fa') }],
|
||||
milestones: getMilestones(locale as 'en' | 'fa'),
|
||||
skills: filterTabs.map(tab => ({ id: tab.id, label: messagesByLocale[locale as 'en' | 'fa'].skills.filterLabels[tab.id], ...skillsByDiscipline[tab.id] })),
|
||||
}])) };
|
||||
mkdirSync('src/seed', { recursive: true });
|
||||
writeFileSync('src/seed/legacy.json', JSON.stringify(fixture, (key, value) => {
|
||||
if (typeof value !== 'function') return value;
|
||||
const names = placeholders[key];
|
||||
let result = value(...names.map((name, index) => ['progress', 'index', 'current', 'total'].includes(name) ? 98760 + index : `{${name}}`));
|
||||
names.forEach((name, index) => {
|
||||
result = result.replaceAll(String(98760 + index), `{${name}}`).replaceAll(new Intl.NumberFormat('fa-IR', { useGrouping: false }).format(98760 + index), `{${name}}`);
|
||||
});
|
||||
return result;
|
||||
}, 2) + '\n');
|
||||
@@ -40,7 +40,7 @@ fi
|
||||
|
||||
docker exec "$container_name" node -e "fetch('http://127.0.0.1:3000/').then(async r=>{if(!r.ok||!(await r.text()).includes('Your Name'))process.exit(1)})"
|
||||
# Exercise the packaged maintenance command and native SQLite driver in the image.
|
||||
docker exec "$container_name" node maintenance.mjs import-legacy
|
||||
docker exec "$container_name" node maintenance.mjs init
|
||||
docker exec --env OWNER_EMAIL=smoke@example.com --env OWNER_PASSWORD=Isolated-container-smoke-password-2026 "$container_name" node maintenance.mjs create-owner
|
||||
|
||||
docker exec -i "$container_name" node <<'NODE'
|
||||
@@ -53,26 +53,16 @@ async function expectOk(path, options) {
|
||||
}
|
||||
|
||||
const english = await expectOk('/');
|
||||
await expectOk('/fa');
|
||||
await expectOk('/projects/interanet-solutions');
|
||||
await expectOk('/fa/projects/interanet-solutions');
|
||||
await expectOk('/projects/sample-projects');
|
||||
|
||||
const html = await english.text();
|
||||
const staticAsset = html.match(/(?:src|href)="(\/_next\/static\/[^"?#]+)/)?.[1];
|
||||
if (!staticAsset) throw new Error('No built static asset was found in the home page.');
|
||||
await expectOk(staticAsset);
|
||||
|
||||
const optimized = await expectOk(
|
||||
'/_next/image?url=%2Fimages%2Favatar.png&w=256&q=75',
|
||||
{ headers: { Accept: 'image/webp' } },
|
||||
);
|
||||
const contentType = optimized.headers.get('content-type') ?? '';
|
||||
if (!/^image\/(?:webp|png)$/.test(contentType)) {
|
||||
throw new Error(`Unexpected optimized image type: ${contentType}`);
|
||||
}
|
||||
if ((await optimized.arrayBuffer()).byteLength === 0) {
|
||||
throw new Error('Optimized image response was empty.');
|
||||
}
|
||||
|
||||
await expectOk('/images/portfolio-placeholder.svg');
|
||||
|
||||
NODE
|
||||
|
||||
# Upload through the real authenticated endpoint, then replace the container.
|
||||
@@ -103,7 +93,7 @@ for (let attempt = 0; attempt < 30; attempt++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
}
|
||||
if (!ready) throw new Error('Replacement container did not start');
|
||||
const page = await fetch(`${origin}/projects/interanet-solutions`);
|
||||
const page = await fetch(`${origin}/projects/sample-projects`);
|
||||
if (!page.ok) throw new Error('Database did not survive container replacement');
|
||||
const key = readFileSync('/data/smoke-media-key', 'utf8');
|
||||
const media = await fetch(`${origin}/media/${key}`);
|
||||
|
||||