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
25 changes: 25 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Every text file is stored and checked out with LF, on Windows too.
#
# Three tests read a source file as text and assert on its contents
# (tests/ipc/accountLoginFlow.test.ts, tests/security-boundaries.test.ts,
# tests/renderer-dom/moddbVisibilityPrompt.test.tsx). Under a CRLF checkout the
# first of those fails, which is how the Windows CI job found this; the other
# two pass only because neither happens to assert across a line ending.
* text=auto eol=lf

# Byte-exact fixtures. These are crafted archives and compressed streams, and
# the tests assert on declared sizes, offsets and digests inside them, so a
# single rewritten line ending changes the answer. tests/fixtures/not-a-zip.bin
# is the one that actually needs saying: it holds no NUL byte, so the rule above
# would classify it as text on its own.
*.zip binary
*.bin binary
*.lzma1 binary
*.lzma2 binary

# Assets. Git detects these as binary by itself; the markers keep that true if
# one is ever regenerated in a format that looks textual.
*.png binary
*.jpg binary
*.ico binary
*.icns binary
24 changes: 22 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,12 @@ jobs:
- run: npm run lint:ci
- run: npm run format:check

test:
runs-on: ubuntu-latest
test-matrix:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
Expand All @@ -43,6 +47,22 @@ jobs:
- run: npm ci
- run: npm run test:coverage

# `dev` branch protection requires a context literally named `test`, and a
# matrixed job can't produce one: it reports `test (ubuntu-latest)` and
# `test (windows-latest)` instead. This job keeps the required name alive.
# `always()` makes it run even when a leg fails (without it the gate would be
# skipped, and protection counts a skipped required job as satisfied), and
# the explicit comparison fails the gate unless every leg succeeded, so a
# failed, cancelled or skipped matrix turns it red.
test:
needs: [test-matrix]
if: always()
runs-on: ubuntu-latest
steps:
- run: |
echo "test-matrix result: ${{ needs.test-matrix.result }}"
[ "${{ needs.test-matrix.result }}" = "success" ]

# Informational quality scan: never blocks a merge, so a SonarCloud outage
# or configuration change can't turn the whole run red.
sonarcloud:
Expand Down
35 changes: 32 additions & 3 deletions src/ipc/handlers/gameHandlers.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { ipcMain } from "electron"
import { spawn } from "node:child_process"
import type { ChildProcessWithoutNullStreams } from "node:child_process"
import fse from "fs-extra"
import { join } from "node:path"
import os from "node:os"
Expand Down Expand Up @@ -123,7 +124,21 @@ function realGameProcess(): GameProcess {
resolve(outcome)
}

const externalApp = spawn(request.command, request.args, { env: { ...request.env }, cwd: request.cwd, shell: false, windowsHide: true })
let externalApp: ChildProcessWithoutNullStreams
try {
externalApp = spawn(request.command, request.args, { env: { ...request.env }, cwd: request.cwd, shell: false, windowsHide: true })
} catch (err) {
// spawn reports ENOENT, EACCES, EAGAIN, EMFILE and ENFILE through an "error"
// event and THROWS everything else, which is not a distinction the caller can
// do anything with. Windows answers a file that is not a real executable with
// UNKNOWN, so a truncated or quarantined Vintagestory.exe lands here rather
// than on the event below; without this the whole handler rejects and the
// launch stops being a reason the player is told.
logMessage("error", `[back] [ipc] [ipc/handlers/gameHandlers.ts] [EXECUTE_GAME] Error running Vintage Story.`)
logMessage("verbose", `[back] [ipc] [ipc/handlers/gameHandlers.ts] [EXECUTE_GAME] ${getErrorMessage(err)}`)
settle({ started: false, error: getErrorMessage(err) })
return
}

externalApp.stdout.resume()

Expand Down Expand Up @@ -330,6 +345,10 @@ function realProcessProbe(): ProcessProbe {

let stdout = ""
let settled = false
// Declared before settle so that every exit from this executor, the spawn
// throw included, goes through settle. clearTimeout ignores undefined, so
// settling before the timer exists is safe.
let timer: ReturnType<typeof setTimeout> | undefined = undefined

const settle = (outcome: ProcessProbeOutcome): void => {
if (settled) return
Expand All @@ -338,9 +357,19 @@ function realProcessProbe(): ProcessProbe {
resolve(outcome)
}

const externalApp = spawn(request.command, request.args, { shell: false, windowsHide: true })
let externalApp: ChildProcessWithoutNullStreams
try {
externalApp = spawn(request.command, request.args, { shell: false, windowsHide: true })
} catch (err) {
// Same throw-instead-of-emit split as EXECUTE_GAME's spawn above, settled
// the way the "error" event below settles it.
logMessage("error", `[back] [ipc] [gameHandlers.ts] [LOOK_FOR_A_GAME_VERSION] Error looking for the Vintage Story version.`)
logMessage("verbose", `[back] [ipc] [gameHandlers.ts] [LOOK_FOR_A_GAME_VERSION] ${getErrorMessage(err)}`)
settle({ ok: false, stdout, error: getErrorMessage(err) })
return
}

const timer = setTimeout(() => {
timer = setTimeout(() => {
logMessage("error", `[back] [ipc] [gameHandlers.ts] [LOOK_FOR_A_GAME_VERSION] Timed out waiting for Vintage Story to report its version.`)
externalApp.kill()
settle({ ok: false, stdout, error: "Timed out waiting for a response." })
Expand Down
5 changes: 4 additions & 1 deletion tests/ipc/accountLoginFlow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ import { describe, it } from "vitest"
* union with no member asking for another request, so the domain cannot lead the
* handler into a third pass either.
*/
const HANDLER_SOURCE = readFileSync(resolve(__dirname, "../../src/ipc/handlers/accountHandlers.ts"), "utf8")
// Normalized to LF: on Windows, git checks this file out with CRLF line
// endings, and every "\n"-based search below (and in the case-slicing test)
// assumes LF.
const HANDLER_SOURCE = readFileSync(resolve(__dirname, "../../src/ipc/handlers/accountHandlers.ts"), "utf8").replace(/\r\n/g, "\n")

function countOccurrences(haystack: string, needle: string): number {
return haystack.split(needle).length - 1
Expand Down
4 changes: 3 additions & 1 deletion tests/ipc/accountStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,9 @@ describe("saveAccountSecrets", () => {
assert.equal(JSON.parse(raw).version, 2)
})

it("keeps the file readable only by its owner", async () => {
// chmod on Windows only toggles the read-only attribute; it cannot produce
// the POSIX 0o600 this reads back off disk.
it.skipIf(process.platform === "win32")("keeps the file readable only by its owner", async () => {
const store = await loadStore()

await store.saveAccountSecrets("uid-a", ACCOUNT_A)
Expand Down
4 changes: 3 additions & 1 deletion tests/ipc/configHandlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,9 @@ describe("SAVE_CONFIG", () => {
assert.equal(reread.defaultInstallationsFolder, join(appDataFolder, "RiftLauncherInstallations"))
})

it("reports write-failed when the config file cannot be written", async () => {
// chmod 0o500 does not stop a write on Windows: NTFS enforces read-only
// through the file attribute, not POSIX write bits on the containing folder.
it.skipIf(process.platform === "win32")("reports write-failed when the config file cannot be written", async () => {
const event = await createTrustedEvent()

// Make the userData folder itself read-only so the temp-file write inside
Expand Down
13 changes: 11 additions & 2 deletions tests/ipc/extraction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,9 +124,13 @@ describe("runExtraction on a gzipped tar", () => {

await runExtraction({ filePath: archivePath, outputPath: workspacePath("target"), deleteArchive: false, sevenZipBin })

// The listing is what says the wrapping "vintagestory" folder was stepped
// into rather than copied along: it names every entry, so an extra folder
// could not hide in it. Asking the filesystem whether "vintagestory"
// exists cannot say that on Windows, where it resolves to the
// "Vintagestory" file next to it.
assert.deepEqual(readdirSync(workspacePath("target")).sort(), ["Vintagestory", "assets"])
assert.equal(readFileSync(workspacePath("target", "Vintagestory"), "utf8"), "elf")
assert.equal(existsSync(workspacePath("target", "vintagestory")), false)
})

it("keeps a zero byte marker as a zero byte file", async () => {
Expand Down Expand Up @@ -221,7 +225,12 @@ describe("runExtraction on a zip", () => {
assert.equal(statSync(workspacePath("target", "vintagestory", "assets", "version-1.22.6.txt")).size, 0)
})

it("coalesces 7-Zip progress and emits one terminal 100", async () => {
// Two 7-Zip processes over a 2000 file archive run past the 5 s default on a
// Windows runner, where spawning and scanning cost far more than on Linux.
// The coalescing itself has nothing platform-specific in it, so the ubuntu
// run covers it; and PR #274 replaces this test outright, dropping 7-Zip for
// a yauzl reader that needs no process at all.
it.skipIf(process.platform === "win32")("coalesces 7-Zip progress and emits one terminal 100", async () => {
const source = workspacePath("many-files")
mkdirSync(source, { recursive: true })
for (let index = 0; index < 2_000; index++) writeFileSync(join(source, `file-${index}.bin`), Buffer.alloc(2_048, index % 251))
Expand Down
Loading
Loading