-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathnode-plugin-examples.gradle.kts
More file actions
276 lines (264 loc) · 12.6 KB
/
Copy pathnode-plugin-examples.gradle.kts
File metadata and controls
276 lines (264 loc) · 12.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
import groovy.json.JsonOutput
import groovy.json.JsonSlurper
import java.io.File
private val nodePluginExampleKnownStatuses = setOf("stable", "partial", "disabled", "unsupported")
private val nodePluginExampleIgnoredDirectories = setOf("npm-ci-pure-js", "autojs6-pm-fixture")
private val nodePluginExampleKnownTags = setOf(
"main-app",
"packaged",
"smoke",
"commonjs",
"node_modules",
"scoped-fs",
"fs-promises",
"pure-js-npm",
"bridge",
"toast",
"app",
"requires-accessibility",
"requires-screen-capture",
"requires-image",
"permission-optional",
"security",
"disabled-features",
"typescript",
"esm",
"dynamic-import",
"network",
"raw-network",
"udp",
"websocket",
"ocr",
"long-running",
"scheduled-task",
"worker",
"node-test",
"database",
"plugin",
"ui",
"tsx",
"require-esm",
"http",
"filehandle",
"fs-watch",
"compile-cache",
"inspector",
"profiler",
"hardened-sandbox",
"design-gated",
"runtime-info",
"snapshot",
"heap-snapshot",
"multi-instance",
"sandbox",
"wasm",
"wasi",
"wasm-worker",
"wasm-plugin",
"storage",
"notifications",
"sensors",
"provider",
"capability-truth",
"crash",
"adapter",
"debug",
"queue",
"pro-parity",
"desktop-parity",
)
private val nodePluginExampleSupportedEntryExtensions = setOf("js", "cjs", "mjs", "ts", "cts", "mts")
private fun nodePluginExampleReadJsonObject(file: File): Map<*, *> =
JsonSlurper().parse(file) as? Map<*, *>
?: throw GradleException("Expected JSON object in ${file.absolutePath}")
private fun nodePluginExampleList(value: Any?): List<String> =
(value as? List<*>).orEmpty().map { it.toString() }
private fun nodePluginExampleMainHasNodeDirective(file: File): Boolean {
val firstMeaningful = file.readLines()
.map(String::trim)
.firstOrNull { it.isNotEmpty() && !it.startsWith("//") }
.orEmpty()
return firstMeaningful == "\"nodejs\";" ||
firstMeaningful == "'nodejs';" ||
firstMeaningful == "\"node\";" ||
firstMeaningful == "'node';"
}
private fun nodePluginExampleEntryFile(dir: File, name: String, projectJson: Map<*, *>): File {
val entry = projectJson["main"]?.toString().orEmpty()
if (entry.isBlank()) {
throw GradleException("Node plugin example '$name' project.json must declare a main entry.")
}
if (entry.contains('\u0000') || entry.startsWith("/") || entry.startsWith("\\") || Regex("""^[A-Za-z]:""").containsMatchIn(entry)) {
throw GradleException("Node plugin example '$name' project.json main must be a safe relative path: $entry")
}
val file = dir.resolve(entry).canonicalFile
val root = dir.canonicalFile
if (file != root && !file.toPath().startsWith(root.toPath())) {
throw GradleException("Node plugin example '$name' project.json main escapes the example directory: $entry")
}
val extension = file.extension.lowercase()
if (extension !in nodePluginExampleSupportedEntryExtensions) {
throw GradleException("Node plugin example '$name' project.json main uses unsupported extension '.$extension'.")
}
return file
}
tasks.register("verifyNodePluginExamples") {
group = "verification"
description = "Verifies plugin-owned sample/nodejs examples, metadata, expected outputs, and smoke classifications."
val examplesRootProvider = layout.projectDirectory.dir("sample/nodejs")
val jsonReport = layout.buildDirectory.file("reports/nodejs/plugin-examples.json")
val markdownReport = layout.buildDirectory.file("reports/nodejs/plugin-examples.md")
inputs.dir(examplesRootProvider)
outputs.file(jsonReport)
outputs.file(markdownReport)
doLast {
val examplesRoot = examplesRootProvider.asFile
val manifestFile = examplesRoot.resolve("examples.json")
if (!manifestFile.isFile) {
throw GradleException("Missing Node plugin example manifest: ${manifestFile.relativeTo(rootProject.projectDir)}")
}
val manifest = nodePluginExampleReadJsonObject(manifestFile)
if (manifest["schema"] != "autojs6-node-examples-v1") {
throw GradleException("Unsupported Node plugin example manifest schema: ${manifest["schema"]}")
}
val examples = (manifest["examples"] as? List<*>).orEmpty().map {
it as? Map<*, *> ?: throw GradleException("Each Node plugin example manifest entry must be an object.")
}
val manifestNames = examples.map { it["name"].toString() }
val duplicateNames = manifestNames.groupingBy { it }.eachCount().filterValues { it > 1 }.keys
if (duplicateNames.isNotEmpty()) {
throw GradleException("Duplicate Node plugin example manifest entries: ${duplicateNames.joinToString()}")
}
val directoryNames = examplesRoot.listFiles(File::isDirectory).orEmpty()
// Git does not track empty directories left after removing an example's files.
.filter { it.listFiles().orEmpty().isNotEmpty() }
.map(File::getName)
.filterNot(nodePluginExampleIgnoredDirectories::contains)
.sorted()
val missingManifest = directoryNames - manifestNames.toSet()
val missingDirectory = manifestNames.toSet() - directoryNames.toSet()
if (missingManifest.isNotEmpty() || missingDirectory.isNotEmpty()) {
throw GradleException(
"Node plugin example manifest mismatch; directories missing from manifest=${missingManifest.joinToString()}, " +
"manifest entries missing directories=${missingDirectory.joinToString()}"
)
}
val rows = mutableListOf<Map<String, Any?>>()
examples.forEach { entry ->
val name = entry["name"].toString()
val status = entry["status"].toString()
val reason = entry["reason"]?.toString().orEmpty().trim()
val tags = nodePluginExampleList(entry["tags"])
val mainAppSmoke = entry["mainAppSmoke"] as? Boolean ?: false
val packagedCompatible = entry["packagedCompatible"] as? Boolean ?: false
if (status !in nodePluginExampleKnownStatuses) {
throw GradleException("Node plugin example '$name' has unknown status '$status'.")
}
if (status != "stable" && reason.isBlank()) {
throw GradleException("Node plugin example '$name' with status '$status' must declare a non-blank reason.")
}
val unknownTags = tags.filterNot(nodePluginExampleKnownTags::contains)
if (unknownTags.isNotEmpty()) {
throw GradleException("Node plugin example '$name' has unknown tags: ${unknownTags.joinToString()}")
}
val dir = examplesRoot.resolve(name)
val requiredFiles = listOf("README.md", "project.json", "package.json", "expected-output.txt")
val missingFiles = requiredFiles.filterNot { dir.resolve(it).isFile }
if (missingFiles.isNotEmpty()) {
throw GradleException("Node plugin example '$name' is missing files: ${missingFiles.joinToString()}")
}
val readme = dir.resolve("README.md").readText()
if (!readme.lineSequence().firstOrNull().orEmpty().contains(name)) {
throw GradleException("Node plugin example '$name' README should start with a title containing the example name.")
}
val projectJson = nodePluginExampleReadJsonObject(dir.resolve("project.json"))
if (projectJson["name"] != name || projectJson["type"] != "node") {
throw GradleException("Node plugin example '$name' project.json must declare name='$name' and type='node'.")
}
val mainFile = nodePluginExampleEntryFile(dir, name, projectJson)
if (!mainFile.isFile) {
throw GradleException("Node plugin example '$name' is missing declared main entry: ${mainFile.relativeTo(dir)}")
}
if (projectJson.containsKey("example")) {
val exampleMetadata = projectJson["example"] as? Map<*, *>
?: throw GradleException("Node plugin example '$name' must declare valid project.json example metadata.")
val capabilities = nodePluginExampleList(exampleMetadata["capabilities"])
val limitations = nodePluginExampleList(exampleMetadata["securityLimitations"])
val expectedProvider = exampleMetadata["expectedProvider"]?.toString().orEmpty()
val packagedSupport = exampleMetadata["packagedSupport"]?.toString().orEmpty()
if (capabilities.isEmpty()) {
throw GradleException("Node plugin example '$name' must declare example.capabilities.")
}
if (expectedProvider.isBlank()) {
throw GradleException("Node plugin example '$name' must declare example.expectedProvider.")
}
if (packagedSupport.isBlank()) {
throw GradleException("Node plugin example '$name' must declare example.packagedSupport.")
}
if (limitations.isEmpty()) {
throw GradleException("Node plugin example '$name' must declare example.securityLimitations.")
}
}
val packageJson = nodePluginExampleReadJsonObject(dir.resolve("package.json"))
val packageName = packageJson["name"]?.toString().orEmpty()
if (!packageName.startsWith("@autojs6-sample/")) {
throw GradleException("Node plugin example '$name' package.json name must use @autojs6-sample/ scope.")
}
if (mainFile.extension.lowercase() !in setOf("mjs", "mts") && !nodePluginExampleMainHasNodeDirective(mainFile)) {
throw GradleException("Node plugin example '$name' ${mainFile.name} must start with a node directive.")
}
val expectedOutput = dir.resolve("expected-output.txt").readText()
val passMarker = "sample.$name=PASS"
if (!expectedOutput.contains(passMarker)) {
throw GradleException("Node plugin example '$name' expected-output.txt must contain '$passMarker'.")
}
if (!mainFile.readText().contains(passMarker)) {
throw GradleException("Node plugin example '$name' ${mainFile.name} must print '$passMarker'.")
}
if (packagedCompatible && "packaged" !in tags) {
throw GradleException("Node plugin example '$name' is packagedCompatible but is missing the packaged tag.")
}
if (mainAppSmoke && "main-app" !in tags) {
throw GradleException("Node plugin example '$name' is mainAppSmoke but is missing the main-app tag.")
}
rows += linkedMapOf(
"name" to name,
"status" to status,
"reason" to reason,
"tags" to tags,
"mainAppSmoke" to mainAppSmoke,
"packagedCompatible" to packagedCompatible,
"packageName" to packageName,
)
}
val report = linkedMapOf<String, Any?>(
"schema" to "autojs6-node-plugin-examples-report-v1",
"manifest" to manifestFile.relativeTo(rootProject.projectDir).invariantSeparatorsPath,
"exampleCount" to rows.size,
"mainAppSmoke" to rows.filter { it["mainAppSmoke"] == true }.map { it["name"] },
"packagedCompatible" to rows.filter { it["packagedCompatible"] == true }.map { it["name"] },
"examples" to rows,
)
jsonReport.get().asFile.apply {
parentFile.mkdirs()
writeText(JsonOutput.prettyPrint(JsonOutput.toJson(report)) + "\n")
}
markdownReport.get().asFile.apply {
parentFile.mkdirs()
writeText(
buildString {
appendLine("# AutoJs6 Node Plugin Example Validation")
appendLine()
appendLine("| Example | Status | Reason | Main app smoke | Packaged | Tags |")
appendLine("| --- | --- | --- | --- | --- | --- |")
rows.forEach { row ->
appendLine(
"| ${row["name"]} | ${row["status"]} | ${row["reason"]} | ${row["mainAppSmoke"]} | ${row["packagedCompatible"]} | " +
"${(row["tags"] as List<*>).joinToString(", ")} |"
)
}
}
)
}
}
}