diff --git a/src/sql_jsc/postgres/DataCell.rs b/src/sql_jsc/postgres/DataCell.rs index bb1281b6d3f8..6322d77e9311 100644 --- a/src/sql_jsc/postgres/DataCell.rs +++ b/src/sql_jsc/postgres/DataCell.rs @@ -18,6 +18,32 @@ type Result = core::result::Result; bun_core::declare_scope!(Postgres, visible); bun_core::declare_scope!(PostgresDataCell, visible); +/// Text-format `date` / `timestamp` / `timestamptz` (scalar or array element) to epoch ms. +fn parse_date_time_text( + tag: types::Tag, + bytes: &[u8], + global_object: &JSGlobalObject, +) -> Result { + use crate::postgres::types::date; + // The StartupMessage pins DateStyle=ISO, so the server only ever sends these shapes. + let ms = match tag { + types::Tag::timestamp | types::Tag::timestamp_array => { + date::timestamp_text_to_ms_utc(global_object, bytes) + } + types::Tag::timestamptz | types::Tag::timestamptz_array => { + date::timestamptz_text_to_ms_utc(global_object, bytes) + } + _ => None, + }; + if let Some(ms) = ms { + return Ok(ms); + } + // `date` (date-only ISO form), BC dates and 5+ digit years fall back to `Date.parse`. + let str = BunString::init(bytes); + crate::jsc::bun_string_jsc::parse_date(&str, global_object) + .map_err(crate::jsc::js_error_to_postgres) +} + fn parse_bytea(hex: &[u8]) -> Result { let len = hex.len() / 2; let mut buf: Vec = Vec::new(); @@ -214,11 +240,11 @@ fn parse_array( | types::Tag::timestamp_array | types::Tag::date_array => { let date_str = &slice[1..current_idx]; - let str = BunString::init(date_str); - array.push(SQLDataCell::date( - crate::jsc::bun_string_jsc::parse_date(&str, global_object) - .map_err(crate::jsc::js_error_to_postgres)?, - )); + array.push(SQLDataCell::date(parse_date_time_text( + array_type, + date_str, + global_object, + )?)); slice = try_slice(slice, current_idx + 1); continue; @@ -841,26 +867,11 @@ fn from_bytes( if let Some(inf) = crate::postgres::types::date::parse_infinity(bytes) { return Ok(SQLDataCell::date(inf)); } - // DateStyle is pinned to ISO in the startup packet, so the - // server always emits `YYYY-MM-DD[...]` here regardless of - // postgresql.conf / ALTER DATABASE / ALTER ROLE defaults. - // `timestamp` (no offset) is decoded as UTC components to - // agree with the binary path; `date` (UTC midnight) and - // `timestamptz` (explicit offset) go through Date.parse, - // which handles the ISO form unambiguously. - let date = match tag { - T::timestamp => crate::postgres::types::date::timestamp_text_to_ms_utc(global_object, bytes), - _ => None, - }; - let date = match date { - Some(d) => d, - None => { - let str = BunString::init(bytes); - crate::jsc::bun_string_jsc::parse_date(&str, global_object) - .map_err(crate::jsc::js_error_to_postgres)? - } - }; - Ok(SQLDataCell::date(date)) + Ok(SQLDataCell::date(parse_date_time_text( + tag, + bytes, + global_object, + )?)) } } tag @ (T::time | T::timetz) => { diff --git a/src/sql_jsc/postgres/types/date.rs b/src/sql_jsc/postgres/types/date.rs index c5d79c4cba18..2b3220fd0492 100644 --- a/src/sql_jsc/postgres/types/date.rs +++ b/src/sql_jsc/postgres/types/date.rs @@ -42,13 +42,29 @@ pub(crate) fn parse_infinity(bytes: &[u8]) -> Option { /// without this they'd go through JS `Date.parse` and be read as local time on /// non-UTC hosts. Returns `None` for anything that isn't this exact shape /// (e.g. `infinity`, BC dates, 5+ digit years), so the caller falls back to -/// `Date.parse`. `timestamptz` and `date` already decode correctly via -/// `Date.parse` and must NOT be routed here. +/// `Date.parse`. pub(crate) fn timestamp_text_to_ms_utc( global_object: &JSGlobalObject, bytes: &[u8], ) -> Option { let parsed = crate::shared::datetime_text::parse_postgres_timestamp(bytes)?; + components_to_ms_utc(global_object, &parsed) +} + +/// Same for `timestamptz` text, whose trailing `±HH[:MM[:SS]]` offset is applied here. +pub(crate) fn timestamptz_text_to_ms_utc( + global_object: &JSGlobalObject, + bytes: &[u8], +) -> Option { + let (parsed, offset_seconds) = crate::shared::datetime_text::parse_postgres_timestamptz(bytes)?; + let wall_clock_as_utc = components_to_ms_utc(global_object, &parsed)?; + Some(wall_clock_as_utc - f64::from(offset_seconds) * 1000.0) +} + +fn components_to_ms_utc( + global_object: &JSGlobalObject, + parsed: &crate::shared::datetime_text::DateTimeText, +) -> Option { global_object .gregorian_date_time_to_ms_utc( i32::from(parsed.year), diff --git a/src/sql_jsc/shared/datetime_text.rs b/src/sql_jsc/shared/datetime_text.rs index c1113c3ba7df..11286bafa884 100644 --- a/src/sql_jsc/shared/datetime_text.rs +++ b/src/sql_jsc/shared/datetime_text.rs @@ -42,7 +42,8 @@ enum Separator { /// MySQL DATE/DATETIME/TIMESTAMP text. Accepts the 10-byte date-only form /// (`YYYY-MM-DD`) and either `' '` or `'T'` as the date/time separator. pub(crate) fn parse_mysql(text: &[u8]) -> Option { - parse(text, TimePart::Optional, Separator::SpaceOrT) + let (dt, consumed) = parse(text, TimePart::Optional, Separator::SpaceOrT)?; + (consumed == text.len()).then_some(dt) } /// Postgres `timestamp` (WITHOUT TIME ZONE) text. Requires the full @@ -50,24 +51,50 @@ pub(crate) fn parse_mysql(text: &[u8]) -> Option { /// separator, `infinity`, BC dates, 5+ digit years) returns `None` so the /// caller can fall back to `Date.parse`. pub(crate) fn parse_postgres_timestamp(text: &[u8]) -> Option { - parse(text, TimePart::Required, Separator::Space) + let (dt, consumed) = parse(text, TimePart::Required, Separator::Space)?; + (consumed == text.len()).then_some(dt) } -fn parse(text: &[u8], time_part: TimePart, separator: Separator) -> Option { - fn parse_u(bytes: &[u8]) -> Option { - if bytes.is_empty() { - return None; +/// Postgres `timestamptz` text; the `±HH[:MM[:SS]]` offset is returned in seconds east of UTC. +pub(crate) fn parse_postgres_timestamptz(text: &[u8]) -> Option<(DateTimeText, i32)> { + let (dt, consumed) = parse(text, TimePart::Required, Separator::Space)?; + let (&sign, offset) = text.get(consumed..)?.split_first()?; + let sign: i32 = match sign { + b'+' => 1, + b'-' => -1, + _ => return None, + }; + let hours = parse_u(offset.get(0..2)?)?; + let (minutes, seconds) = match offset.len() { + 2 => (0, 0), + 5 if offset[2] == b':' => (parse_u(&offset[3..5])?, 0), + 8 if offset[2] == b':' && offset[5] == b':' => { + (parse_u(&offset[3..5])?, parse_u(&offset[6..8])?) } - let mut n: u32 = 0; - for &c in bytes { - if !c.is_ascii_digit() { - return None; - } - n = n.checked_mul(10)?.checked_add(u32::from(c - b'0'))?; + _ => return None, + }; + if minutes > 59 || seconds > 59 { + return None; + } + let offset_seconds = i32::try_from(hours * 3600 + minutes * 60 + seconds).ok()?; + Some((dt, sign * offset_seconds)) +} + +fn parse_u(bytes: &[u8]) -> Option { + if bytes.is_empty() { + return None; + } + let mut n: u32 = 0; + for &c in bytes { + if !c.is_ascii_digit() { + return None; } - Some(n) + n = n.checked_mul(10)?.checked_add(u32::from(c - b'0'))?; } + Some(n) +} +fn parse(text: &[u8], time_part: TimePart, separator: Separator) -> Option<(DateTimeText, usize)> { if text.len() < 10 || text[4] != b'-' || text[7] != b'-' { return None; } @@ -79,7 +106,7 @@ fn parse(text: &[u8], time_part: TimePart, separator: Separator) -> Option Some(result), + TimePart::Optional => Some((result, 10)), TimePart::Required => None, }; } @@ -95,21 +122,18 @@ fn parse(text: &[u8], time_part: TimePart, separator: Separator) -> Option 6 { + let frac_len = text[20..].iter().take_while(|c| c.is_ascii_digit()).count(); + if frac_len == 0 || frac_len > 6 { return None; } - let mut micro = parse_u(frac)?; - for _ in 0..(6 - frac.len()) { + let mut micro = parse_u(&text[20..20 + frac_len])?; + for _ in 0..(6 - frac_len) { micro *= 10; } result.microsecond = micro; - Some(result) + Some((result, 20 + frac_len)) } diff --git a/test/js/sql/postgres-timestamptz-text.test.ts b/test/js/sql/postgres-timestamptz-text.test.ts new file mode 100644 index 000000000000..58bd6cb8149e --- /dev/null +++ b/test/js/sql/postgres-timestamptz-text.test.ts @@ -0,0 +1,189 @@ +// Text-path decoding of `timestamptz` (`YYYY-MM-DD HH:MM:SS[.ffffff]±HH[:MM[:SS]]`) +// must yield the same instant as the binary path for every offset width +// Postgres emits, including the `±HH:MM:SS` form it prints for local-mean-time +// instants, and for years 0001..0099. JS `Date.parse` gets both wrong. +// +// Driven by a scripted v3 backend so the exact wire text is pinned and no +// Postgres server is needed. The `±HH:MM:SS` vectors are what PostgreSQL 17 +// prints for `'1883-11-18 12:00:00+00'::timestamptz` under the zones named +// next to them. + +import { SQL } from "bun"; +import { afterAll, beforeAll, expect, test } from "bun:test"; +import { + listeningServer, + pgAuthenticationOk, + pgCommandComplete, + pgDataRow, + pgReadyForQuery, + pgRowDescription, + type PgRowDescriptionColumn, +} from "./wire-frames"; + +const OID = { + date: 1082, + timestamp: 1114, + timestamptz: 1184, + timestamp_array: 1115, + timestamptz_array: 1185, +} as const; + +// The naive `timestamp` vectors below must decode as UTC whatever the host +// zone is, so run the file under a zone with a non-zero offset: a decoder that +// reads them as local time then fails instead of coinciding with UTC. +const originalTZ = process.env.TZ; +beforeAll(() => { + process.env.TZ = "America/New_York"; + expect(new Date(Date.UTC(2024, 5, 15, 12)).getTimezoneOffset()).toBe(240); +}); +afterAll(() => { + if (originalTZ === undefined) delete process.env.TZ; + else process.env.TZ = originalTZ; +}); + +// Simple-query backend: replies to every 'Q' with the latched RowDescription + +// DataRow. format: 0 (text) for every column. +let reply!: { cols: PgRowDescriptionColumn[]; row: Buffer[] }; +const mock = await listeningServer(socket => { + let startup = true; + socket.on("data", data => { + if (startup) { + startup = false; + socket.write(Buffer.concat([pgAuthenticationOk(), pgReadyForQuery()])); + return; + } + if (data[0] !== 0x51 /* 'Q' */) return; + socket.write( + Buffer.concat([ + pgRowDescription(reply.cols), + pgDataRow(reply.row), + pgCommandComplete("SELECT 1"), + pgReadyForQuery(), + ]), + ); + }); + socket.on("error", () => {}); +}); +afterAll(() => new Promise(r => mock.server.close(() => r()))); + +/** Serves one row whose cells are `texts`, every column of type `typeOid`; returns the decoded cells in order. */ +async function decode(typeOid: number, texts: string[]): Promise { + reply = { + cols: texts.map((_, i) => ({ name: `c${i}`, typeOid })), + row: texts.map(text => Buffer.from(text)), + }; + const sql = new SQL({ url: `postgres://u@127.0.0.1:${mock.port}/db`, max: 1, connectionTimeout: 2 }); + try { + const [row]: any = await sql`select 1`.simple(); + return texts.map((_, i) => row[`c${i}`]); + } finally { + await sql.close({ timeout: 0 }); + } +} + +// toISOString() throws on an Invalid Date; render it as a marker instead so a +// failing diff names the cell that broke. +const iso = (v: unknown) => (v instanceof Date ? (Number.isNaN(v.getTime()) ? "Invalid Date" : v.toISOString()) : v); + +// --- scalar timestamptz text ---------------------------------------------- + +test("timestamptz text: every offset width Postgres emits (±HH, ±HH:MM, ±HH:MM:SS)", async () => { + const cells = await decode(OID.timestamptz, [ + "1883-11-18 12:00:00+00", + "1883-11-18 07:00:00-05", + "2024-06-01 17:45:00+05:45", + "1883-11-18 07:03:58-04:56:02", // America/New_York + "1883-11-18 12:19:32+00:19:32", // Europe/Amsterdam + "1883-11-18 11:34:39-00:25:21", // Europe/Dublin: negative offset whose hour field is 00 + "1883-11-18 17:21:10+05:21:10", // Asia/Kolkata + ]); + expect(cells.map(iso)).toEqual([ + "1883-11-18T12:00:00.000Z", + "1883-11-18T12:00:00.000Z", + "2024-06-01T12:00:00.000Z", + "1883-11-18T12:00:00.000Z", + "1883-11-18T12:00:00.000Z", + "1883-11-18T12:00:00.000Z", + "1883-11-18T12:00:00.000Z", + ]); +}); + +test("timestamptz text: fractional seconds combine with every offset width", async () => { + const cells = await decode(OID.timestamptz, [ + "2024-06-01 12:00:00.5+00", + "2024-06-01 12:00:00.123456-05", + "2024-06-01 12:00:00.25+05:30", + "1883-11-18 07:03:58.25-04:56:02", + ]); + expect(cells.map(iso)).toEqual([ + "2024-06-01T12:00:00.500Z", + "2024-06-01T17:00:00.123Z", + "2024-06-01T06:30:00.250Z", + "1883-11-18T12:00:00.250Z", + ]); +}); + +test("timestamptz text: years 0001..0099 decode literally (text path == binary path)", async () => { + const cells = await decode(OID.timestamptz, [ + "0001-03-15 12:00:00+00", + "0044-03-15 12:00:00+00", + "0099-03-15 12:00:00+00", + "0100-03-15 12:00:00+00", + "2024-03-15 12:00:00+00", + ]); + expect(cells.map(iso)).toEqual([ + "0001-03-15T12:00:00.000Z", + "0044-03-15T12:00:00.000Z", + "0099-03-15T12:00:00.000Z", + "0100-03-15T12:00:00.000Z", + "2024-03-15T12:00:00.000Z", + ]); +}); + +test("timestamptz text outside the fixed-width shape still falls back to Date.parse", async () => { + const cells = await decode(OID.timestamptz, [ + // Five-digit years are the one such shape Date.parse handles; it must keep doing so. + "10000-01-01 00:00:00+00", + // Out-of-range offset fields are not treated as 99 minutes / 99 seconds. + "2024-06-01 12:00:00+01:99", + "2024-06-01 12:00:00+00:00:99", + ]); + expect(cells.map(iso)).toEqual(["+010000-01-01T00:00:00.000Z", "Invalid Date", "Invalid Date"]); +}); + +// --- array text path (arrays are text even on the extended protocol) -------- + +test("timestamptz[] text: elements get the same offset parsing as scalars", async () => { + const [arr] = await decode(OID.timestamptz_array, [ + '{"1883-11-18 07:03:58-04:56:02","2024-06-01 08:00:00.5-04","0044-03-15 12:00:00+00"}', + ]); + expect((arr as unknown[]).map(iso)).toEqual([ + "1883-11-18T12:00:00.000Z", + "2024-06-01T12:00:00.500Z", + "0044-03-15T12:00:00.000Z", + ]); +}); + +test("timestamp[] text: elements decode as UTC wall-clock, like the scalar decoder", async () => { + const [arr] = await decode(OID.timestamp_array, [ + '{"2024-06-15 12:00:00","2024-06-15 12:00:00.5","0044-03-15 12:00:00"}', + ]); + expect((arr as unknown[]).map(iso)).toEqual([ + "2024-06-15T12:00:00.000Z", + "2024-06-15T12:00:00.500Z", + "0044-03-15T12:00:00.000Z", + ]); +}); + +// --- unaffected neighbours -------------------------------------------------- + +test("date / naive timestamp scalars keep decoding as UTC", async () => { + const dates = await decode(OID.date, ["2024-06-15", "0044-03-15"]); + const timestamps = await decode(OID.timestamp, ["2024-06-15 12:00:00", "0044-03-15 12:00:00.25"]); + expect([...dates, ...timestamps].map(iso)).toEqual([ + "2024-06-15T00:00:00.000Z", + "0044-03-15T00:00:00.000Z", + "2024-06-15T12:00:00.000Z", + "0044-03-15T12:00:00.250Z", + ]); +}); diff --git a/test/js/sql/sql-postgres-datetime-roundtrip.test.ts b/test/js/sql/sql-postgres-datetime-roundtrip.test.ts index a018c040a068..ab6652a1eaa9 100644 --- a/test/js/sql/sql-postgres-datetime-roundtrip.test.ts +++ b/test/js/sql/sql-postgres-datetime-roundtrip.test.ts @@ -6,7 +6,8 @@ import path from "path"; // path decodes it as UTC (µs since 2000-01-01). The simple/text path must do // the same — otherwise it goes through JS Date.parse and is read as local time, // making the two protocols disagree on non-UTC hosts. `timestamptz` and `date` -// must keep decoding correctly. +// must keep decoding correctly, including the seconds-resolution offsets the +// server prints for historical instants and the always-text array types. // // The fixture runs against a real Postgres server (docker-compose in CI, or a // DATABASE_URL/local instance otherwise) and prints "OK TZ= offsetMin=" diff --git a/test/js/sql/sql-postgres-datetime-tz-fixture.ts b/test/js/sql/sql-postgres-datetime-tz-fixture.ts index 66fe2a0ca986..a4820eec42d3 100644 --- a/test/js/sql/sql-postgres-datetime-tz-fixture.ts +++ b/test/js/sql/sql-postgres-datetime-tz-fixture.ts @@ -3,7 +3,10 @@ // simple/text path must decode the same wall-clock as UTC too — otherwise it // goes through JS Date.parse and is read as local time, making the two // protocols disagree on non-UTC hosts. `timestamptz` (explicit offset) and -// `date` (UTC midnight) must keep decoding correctly. +// `date` (UTC midnight) must keep decoding correctly, including the +// `±HH:MM:SS` offsets the server prints for historical (local mean time) +// instants and the `timestamptz[]` / `timestamp[]` arrays that are sent as +// text on both protocols. // // The driving test spawns this fixture under several TZ values against a real // Postgres server and asserts binary and text decode to the same instant. @@ -63,16 +66,21 @@ const expected = [ const failures: string[] = []; -function checkRows(protocol: string, rows: Array<{ ts: Date; tstz: Date; d: Date }>) { - for (let i = 0; i < expected.length; i++) { - for (const col of ["ts", "tstz", "d"] as const) { - const got: Date = rows[i][col]; - if (!(got instanceof Date)) { - failures.push(`${protocol} id=${i} ${col}: expected Date, got ${Object.prototype.toString.call(got)}`); - continue; - } - if (got.toISOString() !== expected[i][col]) { - failures.push(`${protocol} id=${i} ${col}: want ${expected[i][col]} got ${got.toISOString()}`); +// Renders a decoded cell (Date, array of Dates, or text) for comparison with +// the expected strings; anything else is reported as its type tag. +function render(value: unknown): string { + if (typeof value === "string") return value; + if (Array.isArray(value)) return `[${value.map(render).join(",")}]`; + if (!(value instanceof Date)) return Object.prototype.toString.call(value); + return Number.isNaN(value.getTime()) ? "Invalid Date" : value.toISOString(); +} + +function checkRows(protocol: string, rows: Array>, want: Array>) { + for (let i = 0; i < want.length; i++) { + for (const col of Object.keys(want[i])) { + const got = render(rows[i]?.[col]); + if (got !== want[i][col]) { + failures.push(`${protocol} row=${i} ${col}: want ${want[i][col]} got ${got}`); } } } @@ -95,22 +103,22 @@ const binaryRows = await sql`SELECT ts, tstz, d, 0.1::real AS fmt FROM ${sql(t)} const textRows = await sql`SELECT ts, tstz, d, 0.1::real AS fmt FROM ${sql(t)} ORDER BY id`.simple(); checkFormat("binary", binaryRows, Math.fround(0.1)); checkFormat("text", textRows, 0.1); -checkRows("binary", binaryRows); -checkRows("text", textRows); +checkRows("binary", binaryRows, expected); +checkRows("text", textRows, expected); // Sub-millisecond sweep, checked against the server's own arithmetic: // floor(extract(epoch) * 1000) is numeric (exact) on PostgreSQL 14+, and, // unlike the text path, is also an oracle where the text decoders are known to -// differ and are not this decoder's business: BC dates, 5+ digit years and, -// for timestamptz, years 1-99 fall back to Date.parse, which yields Invalid -// Date for BC, reads a 5+ digit year `timestamp` as local time, and windows -// years 1-99 into 19xx/20xx. Those literals are checked on the binary path -// only. Values beyond JS Date's +/-8.64e15 ms decode to Invalid Date. +// differ and are not this decoder's business: BC dates and 5+ digit years fall +// back to Date.parse, which yields Invalid Date for BC and reads a 5+ digit +// year `timestamp` as local time. Those literals are checked on the binary +// path only. Values beyond JS Date's +/-8.64e15 ms decode to Invalid Date. const subMsLiterals: Array<[literal: string, textToo: boolean]> = [ ["4714-11-24 00:00:00.000001 BC", false], // the Postgres minimum ["0001-12-31 23:59:59.999999 BC", false], - ["0001-01-01 00:00:00.123456", false], - ["0099-12-31 23:59:59.999999", false], + // Years 1-99 used to be windowed into 19xx/20xx by the timestamptz text path. + ["0001-01-01 00:00:00.123456", true], + ["0099-12-31 23:59:59.999999", true], ["0100-01-01 00:00:00.000999", true], // More than 2^53 µs from 2000-01-01: the old f64 conversion was lossy here // on top of truncating toward zero, on both sides of 1970. @@ -170,6 +178,43 @@ for (const type of ["timestamp", "timestamptz"] as const) { } } +// Historical instants: for a date this old the session zone's rule is local +// mean time, so the server prints the offset with a seconds field +// (`1883-11-18 07:03:58-04:56:02` for America/New_York), which only the +// component decoder understands. Arrays are sent as text on both protocols, +// so they exercise the text decoder even in the "binary" query. +await sql.unsafe("SET TIME ZONE 'America/New_York'"); +const lmt = "1883-11-18 12:00:00+00"; +const historicalExpected = [ + { + // The server's own rendering, so this block is known to be exercising the + // seconds-resolution offset and not a plain `-05`. + tstz_text: "1883-11-18 07:03:58-04:56:02", + tstz: "1883-11-18T12:00:00.000Z", + tstz_arr: "[1883-11-18T12:00:00.000Z,2024-06-15T12:00:00.000Z]", + ts_arr: "[2024-06-15T12:00:00.000Z]", + }, +]; +// The same query on both protocols: a bound parameter makes it an extended +// query (scalar tstz arrives binary); unsafe() without parameters is a simple +// query (every cell arrives as text). +const historicalBinary = await sql` + SELECT ${lmt}::timestamptz::text AS tstz_text, + ${lmt}::timestamptz AS tstz, + ARRAY[${lmt}::timestamptz, '2024-06-15 12:00:00+00'::timestamptz] AS tstz_arr, + ARRAY['2024-06-15 12:00:00'::timestamp] AS ts_arr, + 0.1::real AS fmt`; +const historicalText = await sql.unsafe(` + SELECT '${lmt}'::timestamptz::text AS tstz_text, + '${lmt}'::timestamptz AS tstz, + ARRAY['${lmt}'::timestamptz, '2024-06-15 12:00:00+00'::timestamptz] AS tstz_arr, + ARRAY['2024-06-15 12:00:00'::timestamp] AS ts_arr, + 0.1::real AS fmt`); +checkFormat("binary historical", historicalBinary, Math.fround(0.1)); +checkFormat("text historical", historicalText, 0.1); +checkRows("binary historical", historicalBinary, historicalExpected); +checkRows("text historical", historicalText, historicalExpected); + if (failures.length) { console.error(`FAIL TZ=${process.env.TZ} offsetMin=${new Date().getTimezoneOffset()}`); for (const f of failures) console.error(" " + f);