This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
package dev.fileworkbench.testing
|
||||
|
||||
import com.intellij.openapi.util.Disposer
|
||||
import com.intellij.psi.PsiErrorElement
|
||||
import com.intellij.psi.util.PsiTreeUtil
|
||||
import org.jetbrains.kotlin.cli.jvm.compiler.EnvironmentConfigFiles
|
||||
import org.jetbrains.kotlin.cli.jvm.compiler.KotlinCoreEnvironment
|
||||
import org.jetbrains.kotlin.config.CompilerConfiguration
|
||||
import org.jetbrains.kotlin.psi.KtPsiFactory
|
||||
import java.io.File
|
||||
|
||||
/** Syntax only. This deliberately does not claim Android/Compose symbol or Gradle verification. */
|
||||
fun main(args: Array<String>) {
|
||||
val root = File(args.firstOrNull() ?: ".").canonicalFile
|
||||
val disposable = Disposer.newDisposable()
|
||||
try {
|
||||
val environment = KotlinCoreEnvironment.createForProduction(disposable, CompilerConfiguration(), EnvironmentConfigFiles.JVM_CONFIG_FILES)
|
||||
val factory = KtPsiFactory(environment.project, false)
|
||||
var files = 0
|
||||
var errors = 0
|
||||
root.walkTopDown().onEnter { it.name !in setOf("build", "out", ".gradle", ".git") }
|
||||
.filter { it.isFile && (it.extension == "kt" || it.extension == "kts") }.forEach { source ->
|
||||
files++
|
||||
val text = source.readText()
|
||||
val parsed = factory.createFile(source.name, text)
|
||||
PsiTreeUtil.findChildrenOfType(parsed, PsiErrorElement::class.java).forEach {
|
||||
errors++
|
||||
val line = text.take(it.textOffset).count { c -> c == '\n' } + 1
|
||||
println("FAIL ${source.relativeTo(root)}:$line ${it.errorDescription}")
|
||||
}
|
||||
}
|
||||
println("Kotlin PSI syntax: $files files, $errors parse errors. Android/Compose type resolution is NOT tested.")
|
||||
check(errors == 0)
|
||||
} finally { Disposer.dispose(disposable) }
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$Root = Split-Path $PSScriptRoot -Parent
|
||||
$Jar = Join-Path $Root 'gradle/wrapper/gradle-wrapper.jar'
|
||||
$Work = Join-Path (Split-Path $Jar) "bootstrap.$([guid]::NewGuid().ToString('N'))"
|
||||
$Temp = Join-Path $Work 'wrapper.jar'
|
||||
$Expected = '7d3a4ac4de1c32b59bc6a4eb8ecb8e612ccd0cf1ae1e99f66902da64df296172'
|
||||
$DistExpected = '6f74b601422d6d6fc4e1f9a1ab6522f642c2fdcbc15ae33ebd30ba3d7198e854'
|
||||
New-Item -ItemType Directory -Force -Path $Work | Out-Null
|
||||
try {
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||
Invoke-WebRequest -UseBasicParsing -Uri 'https://raw.githubusercontent.com/gradle/gradle/v8.14.5/gradle/wrapper/gradle-wrapper.jar' -OutFile $Temp
|
||||
$Actual = (Get-FileHash -Algorithm SHA256 $Temp).Hash.ToLowerInvariant()
|
||||
if ($Actual -ne $Expected) {
|
||||
Write-Host 'Tag wrapper differs from release; retrieving the verified release distribution.'
|
||||
$Dist = Join-Path $Work 'gradle.zip'
|
||||
Invoke-WebRequest -UseBasicParsing -Uri 'https://services.gradle.org/distributions/gradle-8.14.5-bin.zip' -OutFile $Dist
|
||||
if ((Get-FileHash -Algorithm SHA256 $Dist).Hash.ToLowerInvariant() -ne $DistExpected) {
|
||||
throw 'Distribution checksum mismatch; refusing to execute.'
|
||||
}
|
||||
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||
$Outer = [IO.Compression.ZipFile]::OpenRead($Dist)
|
||||
try {
|
||||
$Entries = @($Outer.Entries | Where-Object { $_.FullName -match '/gradle-wrapper-main-8\.14\.5\.jar$' })
|
||||
if ($Entries.Count -ne 1) { throw 'Expected exactly one wrapper-main JAR.' }
|
||||
$Main = Join-Path $Work 'wrapper-main.jar'
|
||||
[IO.Compression.ZipFileExtensions]::ExtractToFile($Entries[0], $Main, $true)
|
||||
} finally { $Outer.Dispose() }
|
||||
$Inner = [IO.Compression.ZipFile]::OpenRead($Main)
|
||||
try {
|
||||
$Entry = $Inner.GetEntry('gradle-wrapper.jar')
|
||||
if ($null -eq $Entry) { throw 'Embedded wrapper missing.' }
|
||||
[IO.Compression.ZipFileExtensions]::ExtractToFile($Entry, $Temp, $true)
|
||||
} finally { $Inner.Dispose() }
|
||||
if ((Get-FileHash -Algorithm SHA256 $Temp).Hash.ToLowerInvariant() -ne $Expected) {
|
||||
throw 'Embedded wrapper checksum mismatch; refusing to execute.'
|
||||
}
|
||||
}
|
||||
Move-Item -Force $Temp $Jar
|
||||
Write-Host 'Official Gradle 8.14.5 wrapper verified.'
|
||||
} finally { if (Test-Path $Work) { Remove-Item -Recurse -Force $Work } }
|
||||
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env sh
|
||||
# Build tooling only. All downloaded executable bytes must match official release hashes.
|
||||
set -eu
|
||||
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
JAR="$ROOT/gradle/wrapper/gradle-wrapper.jar"
|
||||
EXPECTED=7d3a4ac4de1c32b59bc6a4eb8ecb8e612ccd0cf1ae1e99f66902da64df296172
|
||||
DIST_EXPECTED=6f74b601422d6d6fc4e1f9a1ab6522f642c2fdcbc15ae33ebd30ba3d7198e854
|
||||
mkdir -p "$(dirname "$JAR")"
|
||||
WORK=$(mktemp -d "$ROOT/gradle/wrapper/bootstrap.XXXXXX")
|
||||
trap 'rm -rf "$WORK"' EXIT HUP INT TERM
|
||||
fetch() {
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
curl --fail --location --proto '=https' --tlsv1.2 --connect-timeout 15 --max-time 300 "$1" -o "$2"
|
||||
elif command -v wget >/dev/null 2>&1; then
|
||||
wget --https-only --timeout=30 "$1" -O "$2"
|
||||
else
|
||||
echo 'Install curl/wget, or generate the wrapper with an installed Gradle 8.14.5.' >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
checksum() {
|
||||
if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | cut -d ' ' -f 1
|
||||
elif command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | cut -d ' ' -f 1
|
||||
else echo 'No SHA-256 utility found; refusing unverified tooling.' >&2; return 1; fi
|
||||
}
|
||||
printf '%s\n' 'Fetching the official Gradle 8.14.5 wrapper; a pinned SHA-256 is required.'
|
||||
fetch https://raw.githubusercontent.com/gradle/gradle/v8.14.5/gradle/wrapper/gradle-wrapper.jar "$WORK/wrapper.jar"
|
||||
ACTUAL=$(checksum "$WORK/wrapper.jar")
|
||||
if [ "$ACTUAL" != "$EXPECTED" ]; then
|
||||
# A source tag can contain an older build's wrapper. Never execute that mismatch.
|
||||
# The released distribution contains the exact wrapper as an embedded resource.
|
||||
printf '%s\n' 'Tag wrapper does not match the release checksum; retrieving the verified release distribution.'
|
||||
command -v unzip >/dev/null 2>&1 || {
|
||||
echo 'Install unzip for this fallback, or generate the wrapper with installed Gradle 8.14.5.' >&2
|
||||
exit 1
|
||||
}
|
||||
fetch https://services.gradle.org/distributions/gradle-8.14.5-bin.zip "$WORK/gradle.zip"
|
||||
[ "$(checksum "$WORK/gradle.zip")" = "$DIST_EXPECTED" ] || {
|
||||
echo 'Gradle distribution checksum mismatch; refusing to execute.' >&2; exit 1
|
||||
}
|
||||
ENTRY=$(unzip -Z1 "$WORK/gradle.zip" | grep '/gradle-wrapper-main-8\.14\.5\.jar$')
|
||||
[ "$(printf '%s\n' "$ENTRY" | wc -l | tr -d ' ')" = 1 ] || {
|
||||
echo 'Expected exactly one wrapper-main JAR in the verified release.' >&2; exit 1
|
||||
}
|
||||
unzip -p "$WORK/gradle.zip" "$ENTRY" > "$WORK/wrapper-main.jar"
|
||||
unzip -p "$WORK/wrapper-main.jar" gradle-wrapper.jar > "$WORK/wrapper.jar"
|
||||
[ "$(checksum "$WORK/wrapper.jar")" = "$EXPECTED" ] || {
|
||||
echo 'Embedded wrapper checksum mismatch; refusing to execute.' >&2; exit 1
|
||||
}
|
||||
fi
|
||||
mv "$WORK/wrapper.jar" "$JAR"
|
||||
printf '%s\n' 'Official wrapper verified. The Gradle distribution is separately checksum-pinned.'
|
||||
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check this release's build pins and plugin IDs; NOT Gradle/Kotlin type checking."""
|
||||
from pathlib import Path
|
||||
import hashlib
|
||||
import re
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
checks = 0
|
||||
|
||||
def verify(condition, message):
|
||||
global checks
|
||||
if not condition:
|
||||
raise SystemExit("FAIL build configuration: " + message)
|
||||
checks += 1
|
||||
print("PASS " + message)
|
||||
|
||||
def text(name):
|
||||
return (ROOT / name).read_text(encoding="utf-8")
|
||||
|
||||
def properties(name):
|
||||
return dict(line.split("=", 1) for line in text(name).splitlines()
|
||||
if "=" in line and not line.lstrip().startswith("#"))
|
||||
|
||||
DIST = "6f74b601422d6d6fc4e1f9a1ab6522f642c2fdcbc15ae33ebd30ba3d7198e854"
|
||||
JAR = "7d3a4ac4de1c32b59bc6a4eb8ecb8e612ccd0cf1ae1e99f66902da64df296172"
|
||||
wrapper = properties("gradle/wrapper/gradle-wrapper.properties")
|
||||
verify(wrapper["distributionUrl"].replace(chr(92), "") ==
|
||||
"https://services.gradle.org/distributions/gradle-8.14.5-bin.zip", "Gradle distribution is exactly 8.14.5")
|
||||
verify(wrapper["distributionSha256Sum"] == DIST, "official 8.14.5 distribution SHA-256 is pinned")
|
||||
for script in ["scripts/bootstrap-wrapper.sh", "scripts/bootstrap-wrapper.ps1"]:
|
||||
content = text(script)
|
||||
verify(JAR in content and DIST in content and "v8.14.5/" in content and
|
||||
"gradle-8.14.5-bin.zip" in content and "8.13" not in content,
|
||||
script + " uses matching 8.14.5 downloads and hashes")
|
||||
verify(properties("gradle/gradle-daemon-jvm.properties").get("toolchainVersion") == "17", "Gradle daemon requires JDK 17")
|
||||
verify(text(".java-version").strip() == "17", "Java-version hint is 17")
|
||||
verify("JavaVersion.VERSION_17" in text("settings.gradle.kts") and
|
||||
'gradle.gradleVersion == "8.14.5"' in text("settings.gradle.kts"), "settings reject conflicting Gradle/JVM overrides")
|
||||
for module in ["androidApp", "core", "platform-jvm"]:
|
||||
verify("jvmToolchain(17)" in text(module + "/build.gradle.kts"), module + " uses toolchain 17")
|
||||
android = text("androidApp/build.gradle.kts")
|
||||
verify("sourceCompatibility = JavaVersion.VERSION_17" in android and
|
||||
"targetCompatibility = JavaVersion.VERSION_17" in android, "Android Java bytecode targets 17")
|
||||
verify('versionName = "0.2.3"' in android and "versionCode = 5" in android, "application version is 0.2.3/code 5")
|
||||
|
||||
# Deliberately limited to the project's simple version-catalog/alias structure.
|
||||
root_build = text("build.gradle.kts")
|
||||
catalog = text("gradle/libs.versions.toml")
|
||||
plugin_section = catalog.split("[plugins]", 1)[1]
|
||||
entries = re.findall(r'^([\w-]+)\s*=\s*\{\s*id\s*=\s*"([^"]+)"', plugin_section, re.M)
|
||||
ids = dict(entries)
|
||||
aliases = [a.replace(".", "-") for a in re.findall(r'alias\(libs\.plugins\.([\w.]+)\)', root_build)]
|
||||
expected = {"com.android.application", "org.jetbrains.kotlin.android", "org.jetbrains.kotlin.multiplatform",
|
||||
"org.jetbrains.kotlin.jvm", "org.jetbrains.kotlin.plugin.compose"}
|
||||
resolved = [ids.get(a) for a in aliases]
|
||||
verify(len(entries) == len(ids) == 5 and len(resolved) == 5 and set(resolved) == expected,
|
||||
"five root plugin aliases resolve to five distinct expected IDs")
|
||||
verify(not re.search(r'\b(?:id|kotlin)\s*\(', root_build), "root has no extra direct plugin requests")
|
||||
verify('kotlin = "2.2.21"' in catalog and 'agp = "8.11.1"' in catalog, "Kotlin/AGP versions are unchanged")
|
||||
verify("java-version: '17'" in text(".github/workflows/verify.yml"), "existing CI uses Java 17")
|
||||
verify("[SAF-D1/NULL_CURSOR]" in text("androidApp/src/main/kotlin/dev/fileworkbench/android/browser/SafBrowserStore.kt") and
|
||||
"Fileworkbench 0.2.3 · Browser diagnostics SAF-D1" in text("androidApp/src/main/kotlin/dev/fileworkbench/android/ui/BrowserScreen.kt"),
|
||||
"source contains the diagnostic error and visible release marker")
|
||||
verify("org.gradle.java.home" not in properties("gradle.properties"), "tracked Gradle properties contain no machine-specific JDK home")
|
||||
jar = ROOT / "gradle/wrapper/gradle-wrapper.jar"
|
||||
if jar.exists():
|
||||
verify(hashlib.sha256(jar.read_bytes()).hexdigest() == JAR, "bundled official wrapper JAR matches its checksum")
|
||||
else:
|
||||
print("INFO wrapper JAR is not bundled; the first wrapper launch bootstraps the verified binary.")
|
||||
print(f"Build-configuration checks: {checks} passed. Source checks only, not an Android build.")
|
||||
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
KOTLIN_DIR="$(cd "$(dirname "$(command -v kotlinc)")/.." && pwd)"
|
||||
mkdir -p out/syntax
|
||||
kotlinc scripts/KotlinSyntaxCheck.kt -jvm-target 17 -cp "$KOTLIN_DIR/lib/kotlin-compiler.jar" -d out/syntax/check.jar
|
||||
java -cp "out/syntax/check.jar:$KOTLIN_DIR/lib/*" dev.fileworkbench.testing.KotlinSyntaxCheckKt .
|
||||
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Source-level invariants, not a substitute for Android lint, Gradle or device testing."""
|
||||
from pathlib import Path
|
||||
import xml.etree.ElementTree as ET
|
||||
root = Path(__file__).resolve().parent.parent
|
||||
android = '{http://schemas.android.com/apk/res/android}'
|
||||
for xml in (root / 'androidApp/src').rglob('*.xml'):
|
||||
ET.parse(xml)
|
||||
manifest = ET.parse(root / 'androidApp/src/main/AndroidManifest.xml').getroot()
|
||||
assert not manifest.findall('uses-permission'), 'Unexpected permission in app manifest'
|
||||
application = manifest.find('application')
|
||||
assert application.get(android+'allowBackup') == 'false'
|
||||
service = application.find('service')
|
||||
assert service.get(android+'isolatedProcess') == 'true'
|
||||
assert service.get(android+'exported') == 'false'
|
||||
provider = application.find('provider')
|
||||
assert provider.get(android+'exported') == 'false'
|
||||
for source in (root / 'core/src/commonMain').rglob('*.kt'):
|
||||
content = source.read_text()
|
||||
assert 'import android.' not in content and 'import java.' not in content, source
|
||||
all_android = '\n'.join(p.read_text() for p in (root / 'androidApp/src/main/kotlin').rglob('*.kt'))
|
||||
assert 'WebView(' not in all_android
|
||||
assert 'Log.' not in all_android
|
||||
print('PASS project invariants: XML, permission-free manifest, isolated PDF service, narrow FileProvider, portable core, no WebView/log calls.')
|
||||
|
||||
# Browser/picker entry points must not impersonate the system document chooser.
|
||||
activities = {item.get(android+'name'): item for item in application.findall('activity')}
|
||||
main = activities['.MainActivity']
|
||||
picker = activities['.FilePickerActivity']
|
||||
assert picker.get(android+'exported') == 'true'
|
||||
assert picker.get(android+'launchMode', 'standard') == 'standard'
|
||||
all_actions = {item.get(android+'name') for item in application.findall('.//action')}
|
||||
assert 'android.intent.action.GET_CONTENT' in all_actions
|
||||
assert 'android.intent.action.OPEN_DOCUMENT' not in all_actions
|
||||
assert 'android.intent.action.OPEN_DOCUMENT_TREE' not in all_actions
|
||||
assert any(item.get(android+'name') == 'android.intent.category.APP_FILES' for item in main.findall('.//category'))
|
||||
assert any(item.get(android+'name') == 'android.intent.category.OPENABLE' for item in picker.findall('.//category'))
|
||||
paths = ET.parse(root / 'androidApp/src/main/res/xml/file_paths.xml').getroot()
|
||||
assert {(item.tag, item.get('path')) for item in paths} == {('cache-path', 'shared/'), ('cache-path', 'picked/')}
|
||||
assert all('FixtureDocumentsProvider' not in item.get(android+'name', '') for item in application.findall('provider'))
|
||||
for file in ['MainActivity.kt', 'FilePickerActivity.kt']:
|
||||
content = (root / 'androidApp/src/main/kotlin/dev/fileworkbench/android' / file).read_text()
|
||||
assert 'sanitized' in content
|
||||
assert content.index('sanitized') < content.index('super.onCreate(savedInstanceState)')
|
||||
copy_code = (root / 'androidApp/src/main/kotlin/dev/fileworkbench/android/browser/PickedFileStore.kt').read_text()
|
||||
assert 'SelectionCopyBudget()' in copy_code
|
||||
assert 'flags = Intent.FLAG_GRANT_READ_URI_PERMISSION' in copy_code
|
||||
assert 'FLAG_GRANT_PREFIX_URI_PERMISSION' not in copy_code
|
||||
assert 'FLAG_GRANT_WRITE_URI_PERMISSION' not in copy_code
|
||||
assert 'FLAG_GRANT_PERSISTABLE_URI_PERMISSION' not in copy_code
|
||||
print('PASS browser source invariants: APP_FILES/GET_CONTENT, standard picker activity, no system-picker impersonation, narrow copied paths, read-only results, sanitized activity defaults, debug fixture excluded from main manifest.')
|
||||
|
||||
# Regression guard for the reported instrumentation compile failure. This deliberately
|
||||
# checks only the known constructor/use pattern; it is NOT Kotlin type checking.
|
||||
import re
|
||||
for source in (root / 'androidApp/src').rglob('*.kt'):
|
||||
assert not re.search(r"\bPdfDocument\s*\(\s*\)\s*\.\s*use\b", source.read_text()), (
|
||||
f'{source}: PdfDocument does not implement Closeable/AutoCloseable; use try/finally'
|
||||
)
|
||||
print('PASS PDF source regression guard: no PdfDocument().use call (pattern check only).')
|
||||
|
||||
# The release is intentionally pinned; prevent mixed old/new wrapper or plugin files.
|
||||
import subprocess
|
||||
import sys
|
||||
subprocess.run([sys.executable, str(root / 'scripts/check-build-config.py')], check=True)
|
||||
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run the same @Test method bodies offline, omitting only JUnit/Kotlin runner annotations."""
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
root = Path(__file__).resolve().parent.parent
|
||||
out = root / "out/offline-tests/generated"
|
||||
shutil.rmtree(out, ignore_errors=True)
|
||||
out.mkdir(parents=True)
|
||||
cases = []
|
||||
for source in sorted(list((root / "core/src/commonTest").rglob("*Tests.kt")) + list((root / "platform-jvm/src/test").rglob("*Tests.kt"))):
|
||||
text = source.read_text()
|
||||
package = re.search(r"^package ([\w.]+)", text, re.M).group(1)
|
||||
cls = re.search(r"^class (\w+Tests)", text, re.M).group(1)
|
||||
for method in re.findall(r"@Test\s+fun (\w+)\(", text):
|
||||
cases.append((f"{cls}.{method}", f"{package}.{cls}().{method}()"))
|
||||
text = re.sub(r"^import (?:kotlin\.test|org\.junit)\.Test\s*$", "", text, flags=re.M).replace("@Test ", "")
|
||||
(out / source.name).write_text(text)
|
||||
runner = """package dev.fileworkbench.testing
|
||||
fun main() {
|
||||
val cases: List<Pair<String, () -> Unit>> = listOf(
|
||||
"""
|
||||
runner += ",\n".join(f' "{name}" to {{ {call} }}' for name, call in cases)
|
||||
runner += """
|
||||
)
|
||||
var failed = 0
|
||||
for ((name, run) in cases) {
|
||||
try { run(); println("PASS $name") }
|
||||
catch (e: Throwable) { failed++; println("FAIL $name: ${e.message}"); e.printStackTrace() }
|
||||
}
|
||||
println("RESULT: ${cases.size - failed}/${cases.size} checks passed")
|
||||
check(failed == 0) { "$failed checks failed" }
|
||||
}
|
||||
"""
|
||||
(out / "OfflineRunner.kt").write_text(runner)
|
||||
print(f"Prepared {len(cases)} production-core/JVM checks.")
|
||||
@@ -0,0 +1,172 @@
|
||||
package dev.fileworkbench.testing
|
||||
|
||||
import dev.fileworkbench.core.PreviewException
|
||||
import dev.fileworkbench.core.browser.*
|
||||
import java.io.Closeable
|
||||
import java.io.IOException
|
||||
|
||||
// Deliberately small doubles, not Android stubs for compiling the application.
|
||||
// The production listing block below is inserted verbatim by test-folder-listing.py.
|
||||
private object Document { const val COLUMN_DOCUMENT_ID = "document_id" }
|
||||
private object DocumentsContract {
|
||||
const val EXTRA_LOADING = "loading"
|
||||
const val EXTRA_INFO = "info"
|
||||
const val EXTRA_ERROR = "error"
|
||||
fun buildDocumentUriUsingTree(tree: String, id: String) = "$tree/document/$id"
|
||||
}
|
||||
private class Extras(private val values: Map<String, Any?> = emptyMap()) {
|
||||
fun getBoolean(key: String, fallback: Boolean) = values[key] as? Boolean ?: fallback
|
||||
fun getString(key: String) = values[key] as? String
|
||||
}
|
||||
// Test-only representations for the diagnostic expression. They do not simulate
|
||||
// Android URI grants, provider availability or OS behaviour.
|
||||
private data class TestUri(val value: String, val authority: String? = "fixture.provider")
|
||||
private object Build { object VERSION { const val SDK_INT = 36 } }
|
||||
private object UriReadGrants {
|
||||
var retainedRead = false
|
||||
var failLookup = false
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
fun hasPersistentRead(resolver: TestResolver, tree: String): Boolean {
|
||||
if (failLookup) throw SecurityException("fixture grant lookup failure")
|
||||
return retainedRead
|
||||
}
|
||||
}
|
||||
private data class Row(val id: String?, val name: String = "sample.txt")
|
||||
private class TestCursor(
|
||||
val rows: List<Row>,
|
||||
val extras: Extras = Extras(),
|
||||
private val failOnMove: Boolean = false,
|
||||
) : Closeable {
|
||||
var index = -1
|
||||
var closeCount = 0
|
||||
fun moveToNext(): Boolean {
|
||||
if (failOnMove) throw IOException("fixture read failure")
|
||||
return ++index < rows.size
|
||||
}
|
||||
fun string(column: String): String? {
|
||||
check(column == Document.COLUMN_DOCUMENT_ID)
|
||||
return rows[index].id
|
||||
}
|
||||
override fun close() { closeCount++ }
|
||||
}
|
||||
private class TestSignal(private val cancelled: Boolean = false) {
|
||||
fun throwIfCanceled() { if (cancelled) throw java.util.concurrent.CancellationException("fixture cancellation") }
|
||||
}
|
||||
private class TestResolver(private val cursor: TestCursor?) {
|
||||
var queries = 0
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
fun query(uri: TestUri, projection: Array<String>, selection: String?, arguments: Array<String>?, order: String?, signal: TestSignal): TestCursor? {
|
||||
queries++
|
||||
return cursor
|
||||
}
|
||||
}
|
||||
private fun entry(cursor: TestCursor, uri: String) = BrowserEntry(uri, cursor.rows[cursor.index].name, "text/plain")
|
||||
private fun readListing(resolver: TestResolver, signal: TestSignal = TestSignal()): BrowserListing = run {
|
||||
val tree = "fixture:root"
|
||||
val children = TestUri("fixture:root/children")
|
||||
val PROJECTION = arrayOf(Document.COLUMN_DOCUMENT_ID)
|
||||
/* PRODUCTION_LISTING_BLOCK */
|
||||
}
|
||||
private inline fun <reified T : Throwable> expectThrows(block: () -> Unit): T {
|
||||
try { block() } catch (e: Throwable) {
|
||||
check(e is T) { "Expected ${T::class.java.simpleName}, got $e" }
|
||||
return e
|
||||
}
|
||||
error("Expected ${T::class.java.simpleName}")
|
||||
}
|
||||
private fun read(cursor: TestCursor): BrowserListing {
|
||||
val resolver = TestResolver(cursor)
|
||||
try { return readListing(resolver) } finally {
|
||||
check(cursor.closeCount == 1) { "Cursor must close exactly once" }
|
||||
check(resolver.queries == 1) { "Listing must issue exactly one query" }
|
||||
}
|
||||
}
|
||||
fun main() {
|
||||
var passed = 0
|
||||
var failed = 0
|
||||
fun test(name: String, block: () -> Unit) {
|
||||
try { block(); passed++; println("PASS $name") }
|
||||
catch (e: Throwable) { failed++; println("FAIL $name: ${e.javaClass.simpleName}: ${e.message}") }
|
||||
}
|
||||
test("normal rows without extras are not rejected") {
|
||||
val listing = read(TestCursor(listOf(Row("file"))))
|
||||
check(listing.entries.map { it.name } == listOf("sample.txt"))
|
||||
check(!listing.truncated && !listing.providerLoading && listing.information == null)
|
||||
}
|
||||
test("empty cursor without extras is a valid empty directory") {
|
||||
val listing = read(TestCursor(emptyList()))
|
||||
check(listing.entries.isEmpty() && !listing.truncated && !listing.providerLoading && listing.information == null)
|
||||
}
|
||||
test("information-only cursor keeps rows and message") {
|
||||
val listing = read(TestCursor(listOf(Row("file")), Extras(mapOf("info" to "Cached files"))))
|
||||
check(listing.entries.size == 1 && listing.information == "Cached files")
|
||||
}
|
||||
test("loading cursor without error keeps partial rows") {
|
||||
val listing = read(TestCursor(listOf(Row("file")), Extras(mapOf("loading" to true))))
|
||||
check(listing.entries.size == 1 && listing.providerLoading && listing.information == null)
|
||||
}
|
||||
test("loading cursor may initially contain no rows") {
|
||||
val listing = read(TestCursor(emptyList(), Extras(mapOf("loading" to true))))
|
||||
check(listing.entries.isEmpty() && listing.providerLoading && listing.information == null)
|
||||
}
|
||||
test("provider error takes precedence over info without discarding available rows") {
|
||||
val listing = read(TestCursor(listOf(Row("file")), Extras(mapOf("info" to "Cached files", "error" to "Some files are unavailable"))))
|
||||
check(listing.entries.size == 1 && listing.information == "Some files are unavailable")
|
||||
}
|
||||
test("actual null query result still raises the no-listing error") {
|
||||
val resolver = TestResolver(null)
|
||||
val error = expectThrows<PreviewException> { readListing(resolver) }
|
||||
check(error.message?.startsWith("[SAF-D1/NULL_CURSOR]") == true)
|
||||
check(error.message?.contains("Provider: fixture.provider") == true)
|
||||
check(error.message?.contains("Android API: 36; retained read grant: false") == true)
|
||||
check(error.message?.contains("fixture:root/children") == false)
|
||||
check(resolver.queries == 1)
|
||||
}
|
||||
test("cursor closes when row iteration throws") {
|
||||
val cursor = TestCursor(listOf(Row("file")), failOnMove = true)
|
||||
expectThrows<IOException> { read(cursor) }
|
||||
check(cursor.closeCount == 1)
|
||||
}
|
||||
test("cursor closes on cancellation during iteration") {
|
||||
val cursor = TestCursor(listOf(Row("file")))
|
||||
expectThrows<java.util.concurrent.CancellationException> { readListing(TestResolver(cursor), TestSignal(cancelled = true)) }
|
||||
check(cursor.closeCount == 1)
|
||||
}
|
||||
test("entry limit still marks an oversized directory truncated") {
|
||||
val listing = read(TestCursor((0..BrowserLimits.MAX_ENTRIES).map { Row("file-$it") }))
|
||||
check(listing.entries.size == BrowserLimits.MAX_ENTRIES && listing.truncated)
|
||||
}
|
||||
test("exactly the entry limit is not marked truncated") {
|
||||
val listing = read(TestCursor((0 until BrowserLimits.MAX_ENTRIES).map { Row("file-$it") }))
|
||||
check(listing.entries.size == BrowserLimits.MAX_ENTRIES && !listing.truncated)
|
||||
}
|
||||
test("duplicate provider IDs remain deduplicated") {
|
||||
val listing = read(TestCursor(listOf(Row("file"), Row("file"))))
|
||||
check(listing.entries.size == 1)
|
||||
}
|
||||
test("missing and oversized document IDs are skipped") {
|
||||
val listing = read(TestCursor(listOf(Row(null), Row("x".repeat(8193)), Row("file"))))
|
||||
check(listing.entries.size == 1 && listing.entries.single().id.endsWith("/file"))
|
||||
}
|
||||
test("provider messages still use the shared label sanitizer") {
|
||||
val listing = read(TestCursor(emptyList(), Extras(mapOf("error" to "Warning\nunsafe\u202E"))))
|
||||
check(listing.information == "Warning_unsafe_")
|
||||
}
|
||||
test("diagnostic reflects a retained read grant without treating it as successful access") {
|
||||
UriReadGrants.retainedRead = true
|
||||
try {
|
||||
val error = expectThrows<PreviewException> { readListing(TestResolver(null)) }
|
||||
check(error.message?.contains("retained read grant: true") == true)
|
||||
} finally { UriReadGrants.retainedRead = false }
|
||||
}
|
||||
test("unavailable permission diagnostic cannot replace the original null-cursor error") {
|
||||
UriReadGrants.failLookup = true
|
||||
try {
|
||||
val error = expectThrows<PreviewException> { readListing(TestResolver(null)) }
|
||||
check(error.message?.startsWith("[SAF-D1/NULL_CURSOR]") == true)
|
||||
check(error.message?.contains("retained read grant: unknown") == true)
|
||||
} finally { UriReadGrants.failLookup = false }
|
||||
}
|
||||
println("Folder-listing host regression: $passed passed, $failed failed. NOT an Android/device test.")
|
||||
check(failed == 0) { "$failed regression checks failed" }
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Regression checks for the release-pin guard; no Gradle execution or downloads."""
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
passed = 0
|
||||
cases = [
|
||||
("baseline", None, None, None, True),
|
||||
("stale Gradle distribution", "gradle/wrapper/gradle-wrapper.properties", "gradle-8.14.5-bin", "gradle-9.7.1-bin", False),
|
||||
("wrong daemon JVM", "gradle/gradle-daemon-jvm.properties", "toolchainVersion=17", "toolchainVersion=25", False),
|
||||
("duplicate JVM alias", "build.gradle.kts", "alias(libs.plugins.kotlin.jvm) apply false",
|
||||
"alias(libs.plugins.kotlin.jvm) apply false\n alias(libs.plugins.kotlin.jvm) apply false", False),
|
||||
("catalog maps two aliases to JVM", "gradle/libs.versions.toml", 'id = "org.jetbrains.kotlin.android"',
|
||||
'id = "org.jetbrains.kotlin.jvm"', False),
|
||||
("extra direct plugin request", "build.gradle.kts", "plugins {",
|
||||
'plugins {\n id("org.jetbrains.kotlin.jvm") apply false', False),
|
||||
("stale shell bootstrap", "scripts/bootstrap-wrapper.sh", "v8.14.5/", "v8.13.0/", False),
|
||||
("stale Windows bootstrap", "scripts/bootstrap-wrapper.ps1", "v8.14.5/", "v8.13.0/", False),
|
||||
]
|
||||
for name, relative, old, new, expect_success in cases:
|
||||
with tempfile.TemporaryDirectory(prefix="fileworkbench-build-config-") as work:
|
||||
target = Path(work) / "project"
|
||||
shutil.copytree(ROOT, target, ignore=shutil.ignore_patterns("out", "build", ".gradle", ".git", ".idea", "local.properties", "__pycache__"))
|
||||
if relative:
|
||||
file = target / relative
|
||||
source = file.read_text(encoding="utf-8")
|
||||
assert old in source, name + ": expected fixture content missing"
|
||||
file.write_text(source.replace(old, new, 1), encoding="utf-8")
|
||||
else:
|
||||
# Local IDE/SDK state is legitimate on the developer's machine.
|
||||
# The source-package check must not accidentally prohibit ordinary builds.
|
||||
(target / "local.properties").write_text("sdk.dir=/example/local/sdk\n", encoding="utf-8")
|
||||
(target / ".idea").mkdir()
|
||||
result = subprocess.run([sys.executable, str(target / "scripts/check-build-config.py")],
|
||||
text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
|
||||
if (result.returncode == 0) != expect_success:
|
||||
print(result.stdout)
|
||||
raise SystemExit("FAIL " + name)
|
||||
passed += 1
|
||||
print("PASS " + name + (" accepted" if expect_success else " rejected"))
|
||||
print(f"Build-guard regression: {passed}/{len(cases)} cases passed. Not a Gradle build.")
|
||||
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env bash
|
||||
# Offline verification of the production common core + JVM ZIP adapter.
|
||||
# Uses an installed Kotlin/JVM compiler and kotlin-test.jar; does not claim an Android Gradle build.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
command -v kotlinc >/dev/null || { echo 'Install Kotlin (kotlinc) and a JDK first.' >&2; exit 1; }
|
||||
KOTLIN_BIN="$(command -v kotlinc)"
|
||||
KOTLIN_DIR="$(cd "$(dirname "$KOTLIN_BIN")/.." && pwd)"
|
||||
TEST_JAR="$KOTLIN_DIR/lib/kotlin-test.jar"
|
||||
[ -f "$TEST_JAR" ] || { echo "kotlin-test.jar not found: $TEST_JAR" >&2; exit 1; }
|
||||
mkdir -p out/offline-tests
|
||||
python3 scripts/prepare-offline-tests.py
|
||||
SOURCES=()
|
||||
while IFS= read -r source; do SOURCES+=("$source"); done < <(find core/src/commonMain platform-jvm/src/main out/offline-tests/generated -name '*.kt' | sort)
|
||||
kotlinc "${SOURCES[@]}" -jvm-target 17 -cp "$TEST_JAR" -include-runtime -d out/offline-tests/tests.jar
|
||||
java -cp "out/offline-tests/tests.jar:$TEST_JAR" dev.fileworkbench.testing.OfflineRunnerKt | tee out/offline-tests/results.txt
|
||||
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run the actual SafBrowserStore cursor-reading block on JVM test doubles.
|
||||
|
||||
Requires a local Kotlin compiler and JDK; no SDK, Gradle, or downloads. This checks
|
||||
Kotlin control flow and resource cleanup, NOT Android API compatibility, Binder,
|
||||
URI grants, async IO, provider implementation, or device/UI behaviour.
|
||||
"""
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--source', type=Path, default=ROOT / 'androidApp/src/main/kotlin/dev/fileworkbench/android/browser/SafBrowserStore.kt',
|
||||
help='Optional historical source for reproducing the regression')
|
||||
args = parser.parse_args()
|
||||
compiler = shutil.which('kotlinc')
|
||||
java = shutil.which('java')
|
||||
if compiler is None or java is None:
|
||||
parser.error('Install kotlinc and a JDK, then rerun this offline check.')
|
||||
source = args.source.read_text(encoding='utf-8')
|
||||
# Copy verbatim, rather than maintaining a second corrected implementation.
|
||||
start_marker = ' val rows = ArrayList<BrowserEntry>()'
|
||||
end_marker = ' BrowserListing(rows.distinctBy { it.id }, truncated, loading, information)'
|
||||
if source.count(start_marker) != 1 or source.count(end_marker) != 1:
|
||||
parser.error('Listing method changed shape; review and update the harness extraction boundaries.')
|
||||
start = source.index(start_marker)
|
||||
end = source.index(end_marker, start) + len(end_marker)
|
||||
block = source[start:end]
|
||||
template = (ROOT / 'scripts/regressions/FolderListingHarness.kt.template').read_text(encoding='utf-8')
|
||||
assert template.count('/* PRODUCTION_LISTING_BLOCK */') == 1
|
||||
out = ROOT / 'out/folder-listing'
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
generated = out / 'FolderListingHarness.kt'
|
||||
generated.write_text(template.replace('/* PRODUCTION_LISTING_BLOCK */', block), encoding='utf-8')
|
||||
core = sorted(str(p) for p in (ROOT / 'core/src/commonMain').rglob('*.kt'))
|
||||
jar = out / 'regression.jar'
|
||||
print(f'Source under test: {args.source}', flush=True)
|
||||
print('Extracted production cursor-reading block + real common models; Android APIs are test doubles.', flush=True)
|
||||
compiled = subprocess.run([compiler, *core, str(generated), '-jvm-target', '17', '-include-runtime', '-d', str(jar)], cwd=ROOT)
|
||||
if compiled.returncode:
|
||||
raise SystemExit(compiled.returncode)
|
||||
raise SystemExit(subprocess.run([java, '-cp', str(jar), 'dev.fileworkbench.testing.FolderListingHarnessKt'], cwd=ROOT).returncode)
|
||||
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
python3 scripts/check-project.py
|
||||
./gradlew --no-daemon :core:jvmTest :platform-jvm:test :androidApp:assembleDebug :androidApp:assembleDebugAndroidTest :androidApp:lintDebug
|
||||
printf '%s\n' 'APK: androidApp/build/outputs/apk/debug/androidApp-debug.apk'
|
||||
Reference in New Issue
Block a user