test: lock semantic and no-script portfolio behavior
This commit is contained in:
206
tests/helpers/serve-dist.ts
Normal file
206
tests/helpers/serve-dist.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import { readFile, realpath, stat } from 'node:fs/promises';
|
||||
import { createServer, type ServerResponse } from 'node:http';
|
||||
import { extname, isAbsolute, relative, resolve, sep } from 'node:path';
|
||||
import { expect, test as base } from '@playwright/test';
|
||||
|
||||
const LOOPBACK_HOST = '127.0.0.1';
|
||||
|
||||
const contentTypes: Readonly<Record<string, string>> = {
|
||||
'.avif': 'image/avif',
|
||||
'.br': 'application/octet-stream',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.gif': 'image/gif',
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.ico': 'image/x-icon',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.map': 'application/json; charset=utf-8',
|
||||
'.png': 'image/png',
|
||||
'.svg': 'image/svg+xml; charset=utf-8',
|
||||
'.txt': 'text/plain; charset=utf-8',
|
||||
'.webp': 'image/webp',
|
||||
'.woff': 'font/woff',
|
||||
'.woff2': 'font/woff2',
|
||||
'.xml': 'application/xml; charset=utf-8',
|
||||
};
|
||||
|
||||
const sendText = (
|
||||
response: ServerResponse,
|
||||
status: number,
|
||||
body: string,
|
||||
): void => {
|
||||
response.writeHead(status, {
|
||||
'Cache-Control': 'no-store',
|
||||
'Content-Length': Buffer.byteLength(body),
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
});
|
||||
response.end(body);
|
||||
};
|
||||
|
||||
const isInside = (root: string, candidate: string): boolean => {
|
||||
const pathFromRoot = relative(root, candidate);
|
||||
return (
|
||||
pathFromRoot === '' ||
|
||||
(!isAbsolute(pathFromRoot) &&
|
||||
pathFromRoot !== '..' &&
|
||||
!pathFromRoot.startsWith(`..${sep}`))
|
||||
);
|
||||
};
|
||||
|
||||
const decodedRequestPath = (requestTarget: string): string | null => {
|
||||
const rawPath = requestTarget.split(/[?#]/u, 1)[0] ?? '/';
|
||||
let decoded: string;
|
||||
try {
|
||||
decoded = decodeURIComponent(rawPath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
!decoded.startsWith('/') ||
|
||||
decoded.includes('\0') ||
|
||||
decoded.includes('\\')
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const segments = decoded.split('/');
|
||||
if (segments.some((segment) => segment === '.' || segment === '..')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return decoded === '/' || decoded.endsWith('/')
|
||||
? `${decoded}index.html`
|
||||
: decoded;
|
||||
};
|
||||
|
||||
export interface DistPreviewServer {
|
||||
readonly origin: string;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export async function startDistServer(
|
||||
distRoot = resolve(process.cwd(), 'dist'),
|
||||
): Promise<DistPreviewServer> {
|
||||
const root = await realpath(distRoot);
|
||||
if (!(await stat(root)).isDirectory()) {
|
||||
throw new Error(`The preview output is not a directory: ${distRoot}`);
|
||||
}
|
||||
|
||||
const server = createServer((request, response) => {
|
||||
void (async () => {
|
||||
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
||||
response.setHeader('Allow', 'GET, HEAD');
|
||||
sendText(response, 405, 'Method not allowed.');
|
||||
return;
|
||||
}
|
||||
|
||||
const requestPath = decodedRequestPath(request.url ?? '/');
|
||||
if (requestPath === null) {
|
||||
sendText(response, 403, 'Forbidden.');
|
||||
return;
|
||||
}
|
||||
|
||||
const candidate = resolve(root, `.${requestPath}`);
|
||||
if (!isInside(root, candidate)) {
|
||||
sendText(response, 403, 'Forbidden.');
|
||||
return;
|
||||
}
|
||||
|
||||
let filePath: string;
|
||||
try {
|
||||
filePath = await realpath(candidate);
|
||||
} catch {
|
||||
sendText(response, 404, 'Not found.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isInside(root, filePath)) {
|
||||
sendText(response, 403, 'Forbidden.');
|
||||
return;
|
||||
}
|
||||
|
||||
const file = await stat(filePath);
|
||||
if (!file.isFile()) {
|
||||
sendText(response, 404, 'Not found.');
|
||||
return;
|
||||
}
|
||||
|
||||
const body = await readFile(filePath);
|
||||
response.writeHead(200, {
|
||||
'Cache-Control': 'no-store',
|
||||
'Content-Length': body.byteLength,
|
||||
'Content-Type':
|
||||
contentTypes[extname(filePath).toLowerCase()] ??
|
||||
'application/octet-stream',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
});
|
||||
response.end(request.method === 'HEAD' ? undefined : body);
|
||||
})().catch(() => {
|
||||
if (!response.headersSent) {
|
||||
sendText(response, 500, 'Internal server error.');
|
||||
} else {
|
||||
response.destroy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolveListening, reject) => {
|
||||
const onError = (error: Error): void => reject(error);
|
||||
server.once('error', onError);
|
||||
server.listen({ host: LOOPBACK_HOST, port: 0, exclusive: true }, () => {
|
||||
server.off('error', onError);
|
||||
resolveListening();
|
||||
});
|
||||
});
|
||||
|
||||
const address = server.address();
|
||||
if (
|
||||
address === null ||
|
||||
typeof address === 'string' ||
|
||||
address.address !== LOOPBACK_HOST
|
||||
) {
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>((resolveClosed) =>
|
||||
server.close(() => resolveClosed()),
|
||||
);
|
||||
throw new Error(
|
||||
'The preview server did not bind the owned loopback listener.',
|
||||
);
|
||||
}
|
||||
|
||||
let closed = false;
|
||||
return {
|
||||
origin: `http://${LOOPBACK_HOST}:${address.port}`,
|
||||
async close(): Promise<void> {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
await new Promise<void>((resolveClosed, reject) => {
|
||||
server.close((error) =>
|
||||
error === undefined ? resolveClosed() : reject(error),
|
||||
);
|
||||
server.closeIdleConnections();
|
||||
server.closeAllConnections();
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const test = base.extend<{}, { distUrl: string }>({
|
||||
distUrl: [
|
||||
async ({}, use) => {
|
||||
const preview = await startDistServer();
|
||||
try {
|
||||
await use(preview.origin);
|
||||
} finally {
|
||||
await preview.close();
|
||||
}
|
||||
},
|
||||
{ scope: 'worker' },
|
||||
],
|
||||
});
|
||||
|
||||
export { expect };
|
||||
Reference in New Issue
Block a user