Fence displayed identity and native prompt generations, classify secret-free outcomes, recover atomic addition receipts without restoring old credentials, and expose truthful own-key verification metadata. Preserve required verifier and enrollment authority boundaries. Include real-store, production browser, native CTAP2 and visible responsive regressions. Validation: root check/fmt; API 115; passkey security 25; Web unit 712/component 605; auth/entry/activation/hosting browsers; product auth E2E 8 and two explicit browser suites. Server suite: 1262 lib+12 CLI, 3 ignored and 2 environment-specific exclusions. State-name checker retains 15 byte-identical baseline findings; no new exceptions. Physical keys/mobile and hosted CI remain unverified.
612 lines
23 KiB
TypeScript
612 lines
23 KiB
TypeScript
/// <reference lib="dom" />
|
|
// 実Backend + 本番static shell + Chromium native WebAuthn/CTAP2の旧鍵移行matrix。
|
|
// navigator.credentials、HTTP応答、暗号検証を置換しない。CDPは合成認証器の
|
|
// 能力/本人確認/タッチだけを制御し、credentialのexport/import/deleteはしない。
|
|
// 旧鍵fixtureだけは実optionsのuser.idとresident指定を変更してnative createする。
|
|
// 登録attestationはuser handleを署名しないため、本番verifier/storeで保存できる。
|
|
// 現行製品の登録動作の再現ではなく、旧random handle保存状態の明示的な合成である。
|
|
// 実スマホ/OSのprovider選択/物理鍵は対象外。mobileはChromiumのviewportのみ。
|
|
import { join } from "@std/path";
|
|
import { type Browser, type CDPSession, chromium, type Page } from "playwright";
|
|
import { serveProductionShell } from "./static_shell.ts";
|
|
|
|
const [backend, origin, build, handlePrefix, artifacts] = Deno.args;
|
|
if (!backend || !origin || !build || !handlePrefix || !artifacts) {
|
|
throw new Error(
|
|
"Expected owned Backend, origin, production build, synthetic handle prefix, artifacts",
|
|
);
|
|
}
|
|
const started = performance.now();
|
|
const server = await serveProductionShell(backend, origin, build);
|
|
let browser: Browser | undefined;
|
|
let phase = "launch";
|
|
const rows: {
|
|
resident: boolean;
|
|
authenticator: string;
|
|
outcome: string;
|
|
elapsed_ms: number;
|
|
}[] = [];
|
|
class NativeCheckError extends Error {}
|
|
function check(condition: unknown, diagnostic: string): asserts condition {
|
|
// 値をdumpするassertEqualsは使わない。例外/HTTP本文/credential/cookieをログに残さない。
|
|
if (!condition) throw new NativeCheckError(diagnostic);
|
|
}
|
|
type User = { user_id: string; account_id: string; handle: string };
|
|
type Key = {
|
|
key_reference: string;
|
|
normal_login_verified_at: string | null;
|
|
last_used_at: string | null;
|
|
};
|
|
async function whoami(page: Page) {
|
|
const response = await page.request.get(`${origin}/api/auth/whoami`);
|
|
check(response.status() === 200, "whoami status");
|
|
return await response.json() as {
|
|
actor: User | null;
|
|
authentication_reference: string | null;
|
|
session: { kind: string };
|
|
};
|
|
}
|
|
async function owner(page: Page, user: User, reference?: string) {
|
|
const current = await whoami(page);
|
|
check(
|
|
current.session.kind === "authenticated" &&
|
|
current.actor?.user_id === user.user_id &&
|
|
current.actor.account_id === user.account_id &&
|
|
current.actor.handle === user.handle,
|
|
"authenticated owner mismatch",
|
|
);
|
|
check(
|
|
typeof current.authentication_reference === "string",
|
|
"missing authentication reference",
|
|
);
|
|
if (reference) {
|
|
check(
|
|
current.authentication_reference === reference,
|
|
"issued authentication mismatch",
|
|
);
|
|
}
|
|
return current;
|
|
}
|
|
async function keys(page: Page, user: User): Promise<Key[]> {
|
|
const response = await page.request.get(`${origin}/api/auth/passkeys`);
|
|
check(response.status() === 200, "key list status");
|
|
const result = await response.json();
|
|
check(
|
|
result.user.user_id === user.user_id &&
|
|
result.user.account_id === user.account_id,
|
|
"key list owner",
|
|
);
|
|
return result.keys;
|
|
}
|
|
async function presence(
|
|
cdp: CDPSession,
|
|
authenticatorId: string,
|
|
enabled: boolean,
|
|
) {
|
|
await cdp.send("WebAuthn.setAutomaticPresenceSimulation", {
|
|
authenticatorId,
|
|
enabled,
|
|
});
|
|
}
|
|
async function authenticator(
|
|
cdp: CDPSession,
|
|
resident: boolean,
|
|
automaticPresenceSimulation = true,
|
|
) {
|
|
return (await cdp.send("WebAuthn.addVirtualAuthenticator", {
|
|
options: {
|
|
protocol: "ctap2",
|
|
transport: "usb",
|
|
hasResidentKey: resident,
|
|
hasUserVerification: true,
|
|
isUserVerified: true,
|
|
automaticPresenceSimulation,
|
|
},
|
|
})).authenticatorId;
|
|
}
|
|
async function account(page: Page) {
|
|
await page.goto(origin + "/account");
|
|
await page.getByRole("heading", { name: "Account", exact: true }).waitFor();
|
|
await page.getByRole("button", { name: "Log out", exact: true }).waitFor();
|
|
}
|
|
async function logout(page: Page) {
|
|
await account(page);
|
|
await page.getByRole("button", { name: "Log out", exact: true }).click();
|
|
await page.waitForURL((url) => url.origin === origin && url.pathname === "/");
|
|
check(
|
|
(await whoami(page)).session.kind === "anonymous",
|
|
"logout must clear authentication",
|
|
);
|
|
}
|
|
async function login(page: Page, user: User, legacy: boolean) {
|
|
await page.goto(origin + "/login?returnTo=%2Faccount");
|
|
const button = page.getByRole("button", {
|
|
name: legacy ? "Log in with older key" : "Log in with passkey",
|
|
exact: true,
|
|
});
|
|
if (legacy) {
|
|
await page.getByText("Use an older key with account ID", { exact: true })
|
|
.click();
|
|
await page.getByLabel("Account ID (handle)", { exact: true }).fill(
|
|
user.handle,
|
|
);
|
|
} else {
|
|
// 互換入口は閉じたまま。通常loginのrequestにhandle/user/accountが入らないことも検査する。
|
|
check(
|
|
!await page.getByLabel("Account ID (handle)", { exact: true })
|
|
.isVisible(),
|
|
"normal login asks for ID",
|
|
);
|
|
}
|
|
const options = page.waitForResponse((r) =>
|
|
new URL(r.url()).pathname === "/api/auth/passkeys/login/options"
|
|
);
|
|
const complete = page.waitForResponse((r) =>
|
|
new URL(r.url()).pathname === "/api/auth/passkeys/login/complete"
|
|
);
|
|
await button.click();
|
|
const optionResponse = await options;
|
|
check(optionResponse.status() === 200, "login options status");
|
|
const input = optionResponse.request().postDataJSON();
|
|
if (!legacy) {
|
|
check(
|
|
Object.keys(input).every((key) => key === "browser_origin"),
|
|
"normal login selected an account",
|
|
);
|
|
const wire = await optionResponse.json();
|
|
check(
|
|
!wire.public_key.publicKey.allowCredentials?.length,
|
|
"normal login sends an allow-list",
|
|
);
|
|
}
|
|
const response = await complete;
|
|
check(response.status() === 200, "native login complete status");
|
|
const issued = await response.json();
|
|
check(
|
|
issued.user.user_id === user.user_id &&
|
|
issued.user.account_id === user.account_id,
|
|
"login returned wrong owner",
|
|
);
|
|
await page.waitForURL((url) => url.pathname === "/account");
|
|
await owner(page, user, issued.authentication_reference);
|
|
}
|
|
|
|
const terminate = () => {
|
|
void (async () => {
|
|
await browser?.close();
|
|
await server.shutdown();
|
|
Deno.exit(143);
|
|
})();
|
|
};
|
|
Deno.addSignalListener("SIGTERM", terminate);
|
|
try {
|
|
browser = await chromium.launch({ headless: true, timeout: 15_000 });
|
|
for (const resident of [false, true]) {
|
|
const context = await browser.newContext({
|
|
viewport: { width: 1280, height: 900 },
|
|
serviceWorkers: "block",
|
|
});
|
|
context.setDefaultTimeout(12_000);
|
|
// 製品もfixtureも外部通信しない。loopback proxy以外へのbrowser requestは拒否する。
|
|
await context.route(
|
|
"**/*",
|
|
(route) =>
|
|
new URL(route.request().url()).origin === origin
|
|
? route.continue()
|
|
: route.abort(),
|
|
);
|
|
let page = await context.newPage();
|
|
let pageErrors = 0;
|
|
page.on("pageerror", () => ++pageErrors);
|
|
let cdp = await context.newCDPSession(page);
|
|
await cdp.send("WebAuthn.enable", { enableUI: false });
|
|
// 両行とも認証器はresident key対応にする。residentは旧credentialの作成指定だけを変え、
|
|
// resident必須の追加が能力不足で拒否されるケースとduplicate exclusionを混同しない。
|
|
const old = await authenticator(cdp, true);
|
|
let completionPosts = 0;
|
|
const completions = () => completionPosts;
|
|
page.on("request", (r) => {
|
|
if (
|
|
new URL(r.url()).pathname === "/api/auth/passkeys/addition/complete"
|
|
) ++completionPosts;
|
|
});
|
|
try {
|
|
phase = `legacy-fixture-resident-${resident}`;
|
|
await page.goto(origin + "/register");
|
|
// 生options/credentialはpage内にとどめる。user.id/residency以外はServerの要求を維持する。
|
|
const fixture = await page.evaluate(async ({ handle, resident }) => {
|
|
const post = async (path: string, body: unknown) => {
|
|
const response = await fetch(path, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error("synthetic legacy fixture HTTP rejected");
|
|
}
|
|
return await response.json();
|
|
};
|
|
const wire = await post("/api/auth/passkeys/registration/options", {
|
|
handle,
|
|
display_name: "Synthetic native migration",
|
|
browser_origin: location.origin,
|
|
});
|
|
const bytes = (value: string) =>
|
|
Uint8Array.from(
|
|
atob(value.replaceAll("-", "+").replaceAll("_", "/")),
|
|
(c) => c.charCodeAt(0),
|
|
);
|
|
const options = wire.public_key.publicKey;
|
|
const random = crypto.getRandomValues(new Uint8Array(32));
|
|
const stable = bytes(options.user.id);
|
|
const differs = stable.length !== random.length ||
|
|
stable.some((byte: number, index: number) => byte !== random[index]);
|
|
options.challenge = bytes(options.challenge);
|
|
options.user.id = random;
|
|
options.excludeCredentials = options.excludeCredentials?.map((
|
|
item: { id: string },
|
|
) => ({ ...item, id: bytes(item.id) }));
|
|
options.authenticatorSelection = {
|
|
...options.authenticatorSelection,
|
|
residentKey: resident ? "required" : "discouraged",
|
|
requireResidentKey: resident,
|
|
};
|
|
const credential = await navigator.credentials.create({
|
|
publicKey: options,
|
|
}) as PublicKeyCredential | null;
|
|
if (
|
|
!credential ||
|
|
!(credential.response instanceof AuthenticatorAttestationResponse)
|
|
) throw new Error("native attestation missing");
|
|
const encode = (value: ArrayBuffer) =>
|
|
btoa(String.fromCharCode(...new Uint8Array(value))).replaceAll(
|
|
"+",
|
|
"-",
|
|
).replaceAll("/", "_").replaceAll("=", "");
|
|
const issued = await post("/api/auth/passkeys/registration/complete", {
|
|
challenge_id: wire.challenge_id,
|
|
credential: {
|
|
id: credential.id,
|
|
rawId: encode(credential.rawId),
|
|
type: credential.type,
|
|
response: {
|
|
attestationObject: encode(credential.response.attestationObject),
|
|
clientDataJSON: encode(credential.response.clientDataJSON),
|
|
transports: credential.response.getTransports(),
|
|
},
|
|
extensions: credential.getClientExtensionResults(),
|
|
},
|
|
});
|
|
return {
|
|
user: issued.user,
|
|
authentication_reference: issued.authentication_reference,
|
|
randomDiffersFromStable: differs,
|
|
};
|
|
}, {
|
|
handle: `${handlePrefix}-${resident ? "resident" : "nonresident"}`,
|
|
resident,
|
|
});
|
|
check(
|
|
fixture.randomDiffersFromStable,
|
|
"fixture must use a legacy random handle",
|
|
);
|
|
const user: User = fixture.user;
|
|
await owner(page, user, fixture.authentication_reference);
|
|
const initial = await keys(page, user);
|
|
check(
|
|
initial.length === 1 && initial[0].normal_login_verified_at === null,
|
|
"legacy key initially unverified",
|
|
);
|
|
const oldReference = initial[0].key_reference;
|
|
await logout(page);
|
|
phase = `older-key-login-before-resident-${resident}`;
|
|
await login(page, user, true);
|
|
|
|
phase = `same-authenticator-excluded-resident-${resident}`;
|
|
const sameStarted = performance.now();
|
|
const add = () =>
|
|
page.getByRole("button", {
|
|
name: `Add passkey to ${user.handle}`,
|
|
exact: true,
|
|
});
|
|
const optionsResponse = page.waitForResponse((r) =>
|
|
new URL(r.url()).pathname === "/api/auth/passkeys/addition/options"
|
|
);
|
|
await add().click();
|
|
check(
|
|
(await optionsResponse).status() === 200,
|
|
"addition options status",
|
|
);
|
|
const excluded = page.getByRole("alert").filter({
|
|
hasText: "[authenticator/excluded]",
|
|
});
|
|
await excluded.waitFor({ state: "visible" });
|
|
check(
|
|
(await excluded.innerText()).includes(
|
|
"different device, security key, or passkey provider",
|
|
),
|
|
"exclusion needs a concrete alternative",
|
|
);
|
|
check(
|
|
(await excluded.innerText()).includes("do not delete"),
|
|
"exclusion must preserve working access",
|
|
);
|
|
check(
|
|
completions() === 0,
|
|
"native exclusion must not submit completion",
|
|
);
|
|
const afterExcluded = await keys(page, user);
|
|
check(
|
|
afterExcluded.length === 1 &&
|
|
afterExcluded[0].key_reference === oldReference,
|
|
"exclusion changed old key",
|
|
);
|
|
await owner(page, user);
|
|
rows.push({
|
|
resident,
|
|
authenticator: "same",
|
|
outcome: "native-exclusion-no-completion-old-key-preserved",
|
|
elapsed_ms: Math.round(performance.now() - sameStarted),
|
|
});
|
|
|
|
phase = `other-authenticator-pending-resident-${resident}`;
|
|
const otherStarted = performance.now();
|
|
// CDPの仮想認証器選択は物理providerの選択UIを再現しない。別targetに別認証器を置き、
|
|
// 旧targetと旧鍵は生存させる。共有contextの既存sessionを使い、cookie/鍵の移送はしない。
|
|
const legacyPage = page;
|
|
const legacyCdp = cdp;
|
|
page = await context.newPage();
|
|
page.on("pageerror", () => ++pageErrors);
|
|
page.on("request", (r) => {
|
|
if (
|
|
new URL(r.url()).pathname === "/api/auth/passkeys/addition/complete"
|
|
) ++completionPosts;
|
|
});
|
|
cdp = await context.newCDPSession(page);
|
|
await cdp.send("WebAuthn.enable", { enableUI: false });
|
|
const newer = await authenticator(cdp, true, false);
|
|
await account(page);
|
|
// options応答後、native操作はCTAPのpresence=falseで止める。timeout/sleepでpromptを推測しない。
|
|
const beforeAddition = await owner(page, user);
|
|
const additionOptions = page.waitForResponse((r) =>
|
|
new URL(r.url()).pathname === "/api/auth/passkeys/addition/options"
|
|
);
|
|
const additionComplete = page.waitForResponse((r) =>
|
|
new URL(r.url()).pathname === "/api/auth/passkeys/addition/complete"
|
|
);
|
|
await add().click();
|
|
const optionResponse = await additionOptions;
|
|
check(
|
|
optionResponse.status() === 200,
|
|
"other authenticator options status",
|
|
);
|
|
let optionsInput = optionResponse.request().postDataJSON();
|
|
check(
|
|
optionsInput.expected_user_id === user.user_id &&
|
|
optionsInput.expected_account_id === user.account_id &&
|
|
optionsInput.expected_authentication_reference ===
|
|
beforeAddition.authentication_reference,
|
|
"addition must fence displayed owner and session",
|
|
);
|
|
let wire = await optionResponse.json();
|
|
check(
|
|
wire.public_key.publicKey.excludeCredentials.length === 1,
|
|
"old credential must remain excluded",
|
|
);
|
|
check(
|
|
wire.public_key.publicKey.authenticatorSelection.userVerification ===
|
|
"required",
|
|
"addition must require UV",
|
|
);
|
|
check(
|
|
wire.public_key.publicKey.authenticatorSelection.residentKey ===
|
|
"required",
|
|
"product addition must require residency",
|
|
);
|
|
const pending = page.getByRole("status").filter({
|
|
hasText: "Waiting for passkey addition",
|
|
});
|
|
await pending.waitFor({ state: "visible" });
|
|
check(
|
|
await page.getByRole("heading", { name: "Account", exact: true })
|
|
.isVisible(),
|
|
"account hidden while native pending",
|
|
);
|
|
check(
|
|
await add().isDisabled() && completions() === 0,
|
|
"pending must prevent duplicate/early completion",
|
|
);
|
|
for (
|
|
const viewport of [{ width: 1280, height: 900 }, {
|
|
width: 390,
|
|
height: 844,
|
|
}]
|
|
) {
|
|
await page.setViewportSize(viewport);
|
|
await pending.scrollIntoViewIfNeeded();
|
|
check(
|
|
await pending.isVisible(),
|
|
"pending must remain visible at viewport",
|
|
);
|
|
await page.screenshot({
|
|
path: join(
|
|
artifacts,
|
|
`native-addition-pending-${resident}-${viewport.width}.png`,
|
|
),
|
|
fullPage: false,
|
|
});
|
|
}
|
|
await page.setViewportSize({ width: 1280, height: 900 });
|
|
// presence=falseで既に待機中のCTAP要求を後からtrueにして解放できるとは仮定しない。
|
|
// pending/取消と成功を分ける。送信前の停止を確認し、別ceremonyを明示開始する。
|
|
await page.getByRole("button", { name: "Stop waiting", exact: true })
|
|
.click();
|
|
await page.getByRole("alert").filter({
|
|
hasText: "Completion was not sent.",
|
|
}).waitFor();
|
|
check(
|
|
completions() === 0 && (await keys(page, user)).length === 1,
|
|
"cancel before touch must not save or complete",
|
|
);
|
|
await presence(cdp, newer, true);
|
|
const freshOptions = page.waitForResponse((r) =>
|
|
new URL(r.url()).pathname === "/api/auth/passkeys/addition/options"
|
|
);
|
|
await add().click();
|
|
const freshResponse = await freshOptions;
|
|
check(freshResponse.status() === 200, "fresh addition options status");
|
|
optionsInput = freshResponse.request().postDataJSON();
|
|
wire = await freshResponse.json();
|
|
phase = `other-authenticator-completion-resident-${resident}`;
|
|
const complete = await additionComplete;
|
|
check(complete.status() === 200, "native addition complete status");
|
|
const issued = await complete.json();
|
|
check(
|
|
issued.user.user_id === user.user_id &&
|
|
issued.user.account_id === user.account_id &&
|
|
typeof issued.authentication_reference === "string",
|
|
"addition complete owner/reference",
|
|
);
|
|
await page.getByRole("status").filter({
|
|
hasText: "Passkey added to your account",
|
|
}).waitFor({ state: "visible" });
|
|
await owner(page, user, issued.authentication_reference);
|
|
check(completions() === 1, "addition must submit exactly once");
|
|
// 元のbinding referenceはreceipt照合用。Cookie発行後も同じ現在ownerから確認する。
|
|
const receipt = await page.request.post(
|
|
`${origin}/api/auth/passkeys/addition/result`,
|
|
{
|
|
headers: { Origin: origin },
|
|
data: {
|
|
challenge_id: wire.challenge_id,
|
|
expected_user_id: user.user_id,
|
|
expected_account_id: user.account_id,
|
|
expected_authentication_reference:
|
|
optionsInput.expected_authentication_reference,
|
|
},
|
|
},
|
|
);
|
|
check(
|
|
receipt.status() === 200,
|
|
"saved addition receipt after Cookie issuance",
|
|
);
|
|
const saved = await receipt.json();
|
|
check(
|
|
saved.status === "saved" && saved.user.user_id === user.user_id &&
|
|
saved.user.account_id === user.account_id,
|
|
"receipt must identify saved owner",
|
|
);
|
|
const added = await keys(page, user);
|
|
check(
|
|
added.length === 2 &&
|
|
added.some((key) => key.key_reference === oldReference),
|
|
"addition must retain older key",
|
|
);
|
|
const newKey = added.find((key) => key.key_reference !== oldReference);
|
|
check(
|
|
newKey && saved.key.key_reference === newKey.key_reference &&
|
|
newKey.normal_login_verified_at === null,
|
|
"storage must not assert normal login",
|
|
);
|
|
await logout(page);
|
|
phase = `handle-free-login-resident-${resident}`;
|
|
await login(page, user, false);
|
|
const verified = await keys(page, user);
|
|
check(
|
|
verified.length === 2 &&
|
|
verified.find((key) => key.key_reference === newKey.key_reference)
|
|
?.normal_login_verified_at != null,
|
|
"actual handle-free verifier success must mark the new key",
|
|
);
|
|
check(
|
|
verified.find((key) => key.key_reference === oldReference)
|
|
?.normal_login_verified_at === null,
|
|
"normal login must not mark the unused legacy key",
|
|
);
|
|
await logout(page);
|
|
phase = `older-key-login-after-resident-${resident}`;
|
|
await presence(cdp, newer, false);
|
|
page = legacyPage;
|
|
cdp = legacyCdp;
|
|
await presence(cdp, old, true);
|
|
await login(page, user, true);
|
|
const retained = await keys(page, user);
|
|
check(
|
|
retained.length === 2 &&
|
|
retained.find((key) => key.key_reference === oldReference)
|
|
?.last_used_at != null,
|
|
"older key must remain usable after migration",
|
|
);
|
|
check(
|
|
retained.find((key) => key.key_reference === newKey.key_reference)
|
|
?.normal_login_verified_at != null,
|
|
"older login must preserve new key verification",
|
|
);
|
|
check(pageErrors === 0, "product page emitted an uncaught error");
|
|
rows.push({
|
|
resident,
|
|
authenticator: "other",
|
|
outcome:
|
|
"saved-correct-owner-handle-free-verified-older-login-preserved",
|
|
elapsed_ms: Math.round(performance.now() - otherStarted),
|
|
});
|
|
} finally {
|
|
await context.close();
|
|
}
|
|
}
|
|
await Deno.writeTextFile(
|
|
join(artifacts, "native-migration-result.json"),
|
|
JSON.stringify(
|
|
{
|
|
status: "passed",
|
|
boundary:
|
|
"real Backend/static shell/native Chromium CTAP2; synthetic legacy creation override only",
|
|
chromium: browser.version(),
|
|
rows,
|
|
elapsed_ms: Math.round(performance.now() - started),
|
|
viewports: ["1280x900", "390x844"],
|
|
physical_smartphone: "unconfirmed",
|
|
physical_authenticator: "unconfirmed",
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
);
|
|
console.error(
|
|
`native-migration: PASS ${rows.length} matrix rows; ${
|
|
Math.round(performance.now() - started)
|
|
}ms`,
|
|
);
|
|
} catch (error) {
|
|
const diagnostic = error instanceof NativeCheckError
|
|
? error.message
|
|
: "browser-or-tool-operation-failed";
|
|
await Deno.writeTextFile(
|
|
join(artifacts, "native-migration-result.json"),
|
|
JSON.stringify(
|
|
{
|
|
status: "failed",
|
|
phase,
|
|
diagnostic,
|
|
rows,
|
|
elapsed_ms: Math.round(performance.now() - started),
|
|
physical_smartphone: "unconfirmed",
|
|
physical_authenticator: "unconfirmed",
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
);
|
|
// Playwrightの例外にはrequest/引数を含み得るため本文やstackを保存しない。
|
|
console.error(
|
|
`native-migration: FAIL phase=${phase}; completed_rows=${rows.length}; elapsed_ms=${
|
|
Math.round(performance.now() - started)
|
|
}`,
|
|
);
|
|
Deno.exitCode = 1;
|
|
} finally {
|
|
Deno.removeSignalListener("SIGTERM", terminate);
|
|
await browser?.close();
|
|
await server.shutdown();
|
|
}
|