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
5 changes: 5 additions & 0 deletions .changeset/browser-writable-home.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@vivliostyle/cli': patch
---

Give the browser a writable `HOME` for UIDs missing from `/etc/passwd`.
27 changes: 26 additions & 1 deletion src/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
isRunningOnWSL,
registerCleanupHandler,
toError,
useTmpDirectory,
} from './util.js';

/* oxlint-disable typescript/no-unsafe-type-assertion */
Expand Down Expand Up @@ -204,7 +205,7 @@
}
args.push('--no-startup-window');
}
// TODO: Investigate appropriate settings on Firefox

Check warning on line 208 in src/browser.ts

View workflow job for this annotation

GitHub Actions / lint

eslint(no-warning-comments)

Unexpected 'todo' comment: TODO: Investigate appropriate settings...

const launchOptions = {
executablePath,
Expand All @@ -216,9 +217,18 @@
protocolTimeout,
} satisfies LaunchOptions;
Logger.debug('launchOptions %O', launchOptions);
// #416: set Chromium language to English to avoid locale-dependent issues
const env: NodeJS.ProcessEnv = { ...process.env, LANG: 'en.UTF-8' };
// Browsers store transient data that never affects rendering (crash dumps,
// profiles.ini) under HOME (chrome_paths_linux.cc, nsXREDirProvider.cpp). A
// container UID with no /etc/passwd entry (e.g. `docker run --user`)
// gets the unwritable HOME `/`, so the browser cannot launch (see #835).
if (process.platform === 'linux' && !isWritableDir(env.HOME)) {
Comment thread
u1f992 marked this conversation as resolved.
[env.HOME] = await useTmpDirectory();
}
Comment thread
u1f992 marked this conversation as resolved.
const browserLaunch = puppeteer.launch({
...launchOptions,
env: { ...process.env, LANG: 'en.UTF-8' },
env,
handleSIGINT: false,
handleSIGTERM: false,
handleSIGHUP: false,
Expand All @@ -232,6 +242,21 @@
return { browser, browserContext, closeBrowser };
}

function isWritableDir(dir: string | undefined): boolean {
if (!dir) {
return false;
}
try {
if (!fs.statSync(dir).isDirectory()) {
return false;
}
fs.accessSync(dir, fs.constants.W_OK | fs.constants.X_OK);
return true;
} catch {
return false;
}
}

function getPuppeteerCacheDir() {
if (isInContainer()) {
return '/opt/puppeteer';
Expand Down
93 changes: 92 additions & 1 deletion tests/browser.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import os from 'node:os';

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

const mockedLaunch = vi.hoisted(() =>
vi.fn<(options?: unknown) => Promise<unknown>>(),
);
const mockedRegisterCleanupHandler = vi.hoisted(() =>
vi.fn<(message: string, handler: () => void | Promise<void>) => void>(),
);
const mockedUseTmpDirectory = vi.hoisted(() =>
vi.fn<() => Promise<[string, () => void]>>(() =>
Promise.resolve(['/tmp/vivliostyle-home-test', () => {}]),
),
);

vi.mock('../src/node-modules.js', () => ({
importNodeModule: vi.fn<() => Promise<unknown>>(() =>
Expand All @@ -20,6 +27,7 @@ vi.mock('../src/util.js', async (importOriginal) => {
return {
...actual,
registerCleanupHandler: mockedRegisterCleanupHandler,
useTmpDirectory: mockedUseTmpDirectory,
};
});

Expand Down Expand Up @@ -175,3 +183,86 @@ describe('launchPreview', () => {
await expect(launching).rejects.toThrow(Error);
});
});

describe('writable HOME fallback', () => {
const originalPlatform = process.platform;
const setPlatform = (value: NodeJS.Platform) => {
Object.defineProperty(process, 'platform', {
value,
configurable: true,
});
};

beforeEach(() => {
vi.clearAllMocks();
mockedLaunch.mockResolvedValue({
browserContexts: () => [
{
pages: () => [],
newPage: () => ({
setViewport: vi.fn<() => void>(),
on: vi.fn<() => void>(),
authenticate: vi.fn<() => void>(),
goto: vi.fn<() => void>(),
}),
},
],
close: vi.fn<() => void>(),
});
});

afterEach(() => {
setPlatform(originalPlatform);
vi.unstubAllEnvs();
});

const launch = () =>
launchPreview({
mode: 'build',
url: 'https://example.com',
config: {
browser: {
type: 'chrome',
tag: 'stable',
executablePath: process.execPath,
},
proxy: undefined,
sandbox: false,
ignoreHttpsErrors: false,
timeout: 1000,
},
});

const launchedEnv = () =>
(mockedLaunch.mock.calls[0][0] as { env: NodeJS.ProcessEnv }).env;

it('replaces an unwritable HOME with a temp directory on Linux', async () => {
setPlatform('linux');
vi.stubEnv('HOME', '/__vivliostyle_nonexistent_home__');

await launch();

expect(mockedUseTmpDirectory).toHaveBeenCalledOnce();
expect(launchedEnv().HOME).toBe('/tmp/vivliostyle-home-test');
});

it('keeps a writable HOME untouched on Linux', async () => {
setPlatform('linux');
vi.stubEnv('HOME', os.tmpdir());

await launch();

expect(mockedUseTmpDirectory).not.toHaveBeenCalled();
expect(launchedEnv().HOME).toBe(os.tmpdir());
});

it('does not touch HOME on non-Linux platforms', async () => {
setPlatform('darwin');
vi.stubEnv('HOME', '/__vivliostyle_nonexistent_home__');

await launch();

expect(mockedUseTmpDirectory).not.toHaveBeenCalled();
expect(launchedEnv().HOME).toBe('/__vivliostyle_nonexistent_home__');
});
});
Loading