| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707 | package nginximport (	"regexp"	"strings"	"testing")func TestModuleNameNormalization(t *testing.T) {	testCases := []struct {		name               string		loadModuleName     string		expectedNormalized string		configureArgName   string		expectedLoadName   string	}{		{			name:               "stream module",			loadModuleName:     "ngx_stream_module",			expectedNormalized: "stream",			configureArgName:   "stream",			expectedLoadName:   "ngx_stream_module",		},		{			name:               "http_geoip module",			loadModuleName:     "ngx_http_geoip_module",			expectedNormalized: "http_geoip",			configureArgName:   "http_geoip_module",			expectedLoadName:   "ngx_http_geoip_module",		},		{			name:               "stream_geoip module",			loadModuleName:     "ngx_stream_geoip_module",			expectedNormalized: "stream_geoip",			configureArgName:   "stream_geoip_module",			expectedLoadName:   "ngx_stream_geoip_module",		},		{			name:               "http_image_filter module",			loadModuleName:     "ngx_http_image_filter_module",			expectedNormalized: "http_image_filter",			configureArgName:   "http_image_filter_module",			expectedLoadName:   "ngx_http_image_filter_module",		},		{			name:               "mail module",			loadModuleName:     "ngx_mail_module",			expectedNormalized: "mail",			configureArgName:   "mail",			expectedLoadName:   "ngx_mail_module",		},	}	for _, tc := range testCases {		t.Run(tc.name, func(t *testing.T) {			// Test normalization from load_module name			normalizedFromLoad := normalizeModuleNameFromLoadModule(tc.loadModuleName)			if normalizedFromLoad != tc.expectedNormalized {				t.Errorf("normalizeModuleNameFromLoadModule(%s) = %s, expected %s",					tc.loadModuleName, normalizedFromLoad, tc.expectedNormalized)			}			// Test normalization from configure argument name			normalizedFromConfigure := normalizeModuleNameFromConfigure(tc.configureArgName)			if normalizedFromConfigure != tc.expectedNormalized {				t.Errorf("normalizeModuleNameFromConfigure(%s) = %s, expected %s",					tc.configureArgName, normalizedFromConfigure, tc.expectedNormalized)			}			// Test getting expected load_module name			expectedLoad := getExpectedLoadModuleName(tc.configureArgName)			if expectedLoad != tc.expectedLoadName {				t.Errorf("getExpectedLoadModuleName(%s) = %s, expected %s",					tc.configureArgName, expectedLoad, tc.expectedLoadName)			}		})	}}func TestGetLoadModuleRegex(t *testing.T) {	testCases := []struct {		name     string		input    string		expected []string // expected module names	}{		{			name:     "quoted absolute path",			input:    `load_module "/usr/local/nginx/modules/ngx_stream_module.so";`,			expected: []string{"ngx_stream_module"},		},		{			name:     "unquoted relative path",			input:    `load_module modules/ngx_http_upstream_fair_module.so;`,			expected: []string{"ngx_http_upstream_fair_module"},		},		{			name:     "quoted relative path",			input:    `load_module "modules/ngx_http_geoip_module.so";`,			expected: []string{"ngx_http_geoip_module"},		},		{			name:     "unquoted absolute path",			input:    `load_module /etc/nginx/modules/ngx_http_cache_purge_module.so;`,			expected: []string{"ngx_http_cache_purge_module"},		},		{			name:     "multiple modules",			input:    `load_module "/path/ngx_module1.so";\nload_module modules/ngx_module2.so;`,			expected: []string{"ngx_module1", "ngx_module2"},		},		{			name:     "with extra whitespace",			input:    `load_module    "modules/ngx_test_module.so"   ;`,			expected: []string{"ngx_test_module"},		},		{			name:     "no matches",			input:    `some other nginx config`,			expected: []string{},		},	}	regex := GetLoadModuleRegex()	for _, tc := range testCases {		t.Run(tc.name, func(t *testing.T) {			matches := regex.FindAllStringSubmatch(tc.input, -1)			if len(matches) != len(tc.expected) {				t.Errorf("Expected %d matches, got %d", len(tc.expected), len(matches))				return			}			for i, match := range matches {				if len(match) < 2 {					t.Errorf("Match %d should have at least 2 groups, got %d", i, len(match))					continue				}				moduleName := match[1]				expectedModule := tc.expected[i]				if moduleName != expectedModule {					t.Errorf("Expected module name %s, got %s", expectedModule, moduleName)				}			}		})	}}func TestModulesLoaded(t *testing.T) {	text := `load_module "/usr/local/nginx/modules/ngx_stream_module.so";load_module modules/ngx_http_upstream_fair_module.so;load_module "modules/ngx_http_geoip_module.so";load_module /etc/nginx/modules/ngx_http_cache_purge_module.so;`	loadModuleRe := GetLoadModuleRegex()	matches := loadModuleRe.FindAllStringSubmatch(text, -1)	t.Log("matches", matches)	// Expected module names	expectedModules := []string{		"ngx_stream_module",		"ngx_http_upstream_fair_module",		"ngx_http_geoip_module",		"ngx_http_cache_purge_module",	}	if len(matches) != len(expectedModules) {		t.Errorf("Expected %d matches, got %d", len(expectedModules), len(matches))	}	for i, match := range matches {		if len(match) < 2 {			t.Errorf("Match %d should have at least 2 groups, got %d", i, len(match))			continue		}		moduleName := match[1]		expectedModule := expectedModules[i]		t.Logf("Match %d: %s", i, moduleName)		if moduleName != expectedModule {			t.Errorf("Expected module name %s, got %s", expectedModule, moduleName)		}	}}func TestRealWorldModuleMapping(t *testing.T) {	// Simulate real nginx configuration scenarios	testScenarios := []struct {		name               string		configureArg       string // from nginx -V output		loadModuleStmt     string // from nginx -T output		expectedNormalized string // internal representation	}{		{			name:               "stream module - basic",			configureArg:       "--with-stream",			loadModuleStmt:     `load_module "/usr/lib/nginx/modules/ngx_stream_module.so";`,			expectedNormalized: "stream",		},		{			name:               "stream module - dynamic",			configureArg:       "--with-stream=dynamic",			loadModuleStmt:     `load_module modules/ngx_stream_module.so;`,			expectedNormalized: "stream",		},		{			name:               "http_geoip module",			configureArg:       "--with-http_geoip_module=dynamic",			loadModuleStmt:     `load_module "modules/ngx_http_geoip_module.so";`,			expectedNormalized: "http_geoip",		},		{			name:               "stream_geoip module",			configureArg:       "--with-stream_geoip_module=dynamic",			loadModuleStmt:     `load_module /usr/lib/nginx/modules/ngx_stream_geoip_module.so;`,			expectedNormalized: "stream_geoip",		},		{			name:               "http_image_filter module",			configureArg:       "--with-http_image_filter_module=dynamic",			loadModuleStmt:     `load_module modules/ngx_http_image_filter_module.so;`,			expectedNormalized: "http_image_filter",		},		{			name:               "mail module",			configureArg:       "--with-mail=dynamic",			loadModuleStmt:     `load_module "modules/ngx_mail_module.so";`,			expectedNormalized: "mail",		},	}	for _, scenario := range testScenarios {		t.Run(scenario.name, func(t *testing.T) {			// Test configure argument parsing			paramRe := regexp.MustCompile(`--with-([a-zA-Z0-9_-]+)(?:_module)?(?:=([^"'\s]+|"[^"]*"|'[^']*'))?`)			configMatches := paramRe.FindAllStringSubmatch(scenario.configureArg, -1)			if len(configMatches) == 0 {				t.Errorf("Failed to parse configure argument: %s", scenario.configureArg)				return			}			configModuleName := configMatches[0][1]			normalizedConfigName := normalizeModuleNameFromConfigure(configModuleName)			// Test load_module statement parsing			loadModuleRe := GetLoadModuleRegex()			loadMatches := loadModuleRe.FindAllStringSubmatch(scenario.loadModuleStmt, -1)			if len(loadMatches) == 0 {				t.Errorf("Failed to parse load_module statement: %s", scenario.loadModuleStmt)				return			}			loadModuleName := loadMatches[0][1]			normalizedLoadName := normalizeModuleNameFromLoadModule(loadModuleName)			// Verify both normalize to the same expected value			if normalizedConfigName != scenario.expectedNormalized {				t.Errorf("Configure arg normalization: expected %s, got %s",					scenario.expectedNormalized, normalizedConfigName)			}			if normalizedLoadName != scenario.expectedNormalized {				t.Errorf("Load module normalization: expected %s, got %s",					scenario.expectedNormalized, normalizedLoadName)			}			// Verify they match each other (this is the key test)			if normalizedConfigName != normalizedLoadName {				t.Errorf("Normalization mismatch: config=%s, load=%s",					normalizedConfigName, normalizedLoadName)			}			t.Logf("✓ %s: config=%s -> load=%s -> normalized=%s",				scenario.name, configModuleName, loadModuleName, scenario.expectedNormalized)		})	}}func TestAddLoadedDynamicModules(t *testing.T) {	// Test scenario: modules loaded via load_module but not in configure args	// This simulates the real-world case where external modules are installed	// and loaded dynamically without being compiled into nginx	// We can't directly test addLoadedDynamicModules since it depends on getNginxT()	// But we can test the logic by simulating the behavior	testLoadModuleOutput := `# Configuration file /etc/nginx/modules-enabled/50-mod-stream.conf:load_module modules/ngx_stream_module.so;# Configuration file /etc/nginx/modules-enabled/70-mod-stream-geoip2.conf:load_module modules/ngx_stream_geoip2_module.so;load_module "modules/ngx_http_geoip2_module.so";`	// Test the regex and normalization logic	loadModuleRe := GetLoadModuleRegex()	matches := loadModuleRe.FindAllStringSubmatch(testLoadModuleOutput, -1)	expectedModules := map[string]bool{		"stream":        false,		"stream_geoip2": false,		"http_geoip2":   false,	}	t.Logf("Found %d load_module matches", len(matches))	for _, match := range matches {		if len(match) > 1 {			loadModuleName := match[1]			normalizedName := normalizeModuleNameFromLoadModule(loadModuleName)			t.Logf("Load module: %s -> normalized: %s", loadModuleName, normalizedName)			if _, expected := expectedModules[normalizedName]; expected {				expectedModules[normalizedName] = true			} else {				t.Errorf("Unexpected module found: %s (from %s)", normalizedName, loadModuleName)			}		}	}	// Check that all expected modules were found	for moduleName, found := range expectedModules {		if !found {			t.Errorf("Expected module %s was not found", moduleName)		}	}}func TestExternalModuleDiscovery(t *testing.T) {	// Test the complete normalization pipeline for external modules	testCases := []struct {		name           string		loadModuleName string		expectedResult string	}{		{			name:           "stream_geoip2 module",			loadModuleName: "ngx_stream_geoip2_module",			expectedResult: "stream_geoip2",		},		{			name:           "http_geoip2 module",			loadModuleName: "ngx_http_geoip2_module",			expectedResult: "http_geoip2",		},		{			name:           "custom third-party module",			loadModuleName: "ngx_http_custom_module",			expectedResult: "http_custom",		},		{			name:           "simple module name",			loadModuleName: "ngx_custom_module",			expectedResult: "custom",		},	}	for _, tc := range testCases {		t.Run(tc.name, func(t *testing.T) {			result := normalizeModuleNameFromLoadModule(tc.loadModuleName)			if result != tc.expectedResult {				t.Errorf("normalizeModuleNameFromLoadModule(%s) = %s, expected %s",					tc.loadModuleName, result, tc.expectedResult)			}		})	}}func TestOpenRestyModuleParsing(t *testing.T) {	// Test case based on real OpenResty nginx -V output	openRestyOutput := `nginx version: openresty/1.25.3.1built by gcc 4.8.5 20150623 (Red Hat 4.8.5-44) (GCC) built with OpenSSL 1.0.2k-fips  26 Jan 2017TLS SNI support enabledconfigure arguments: --prefix=/usr/local/openresty/nginx --with-cc-opt=-O2 --add-module=../ngx_devel_kit-0.3.3 --add-module=../echo-nginx-module-0.63 --add-module=../xss-nginx-module-0.06 --add-module=../ngx_coolkit-0.2 --add-module=../set-misc-nginx-module-0.33 --add-module=../form-input-nginx-module-0.12 --add-module=../encrypted-session-nginx-module-0.09 --add-module=../srcache-nginx-module-0.33 --add-module=../ngx_lua-0.10.26 --add-module=../ngx_lua_upstream-0.07 --add-module=../headers-more-nginx-module-0.37 --add-module=../array-var-nginx-module-0.06 --add-module=../memc-nginx-module-0.20 --add-module=../redis2-nginx-module-0.15 --add-module=../redis-nginx-module-0.3.9 --add-module=../rds-json-nginx-module-0.16 --add-module=../rds-csv-nginx-module-0.09 --add-module=../ngx_stream_lua-0.0.14 --with-ld-opt=-Wl,-rpath,/usr/local/openresty/luajit/lib --with-http_ssl_module --with-http_v2_module --with-http_realip_module --with-stream --without-pcre2 --with-stream_ssl_module --with-stream_ssl_preread_module`	// Test parsing --add-module arguments	addModuleRe := regexp.MustCompile(`--add-module=([^/\s]+/)([^/\s-]+)-([0-9.]+)`)	matches := addModuleRe.FindAllStringSubmatch(openRestyOutput, -1)	expectedModules := map[string]bool{		"ngx_devel_kit":                  false,		"echo_nginx_module":              false,		"xss_nginx_module":               false,		"ngx_coolkit":                    false,		"set_misc_nginx_module":          false,		"form_input_nginx_module":        false,		"encrypted_session_nginx_module": false,		"srcache_nginx_module":           false,		"ngx_lua":                        false,		"ngx_lua_upstream":               false,		"headers_more_nginx_module":      false,		"array_var_nginx_module":         false,		"memc_nginx_module":              false,		"redis2_nginx_module":            false,		"redis_nginx_module":             false,		"rds_json_nginx_module":          false,		"rds_csv_nginx_module":           false,		"ngx_stream_lua":                 false,	}	t.Logf("Found %d --add-module matches", len(matches))	for _, match := range matches {		if len(match) > 2 {			moduleName := match[2]			t.Logf("Found add-module: %s", moduleName)			if _, expected := expectedModules[moduleName]; expected {				expectedModules[moduleName] = true			} else {				// This might be a valid module we didn't expect				t.Logf("Unexpected add-module found: %s", moduleName)			}		}	}	// Check that we found most expected modules	foundCount := 0	for moduleName, found := range expectedModules {		if found {			foundCount++		} else {			t.Logf("Expected add-module %s was not found", moduleName)		}	}	if foundCount == 0 {		t.Error("No add-modules were parsed successfully")	}	// Test parsing --with- arguments as well	withModuleRe := regexp.MustCompile(`--with-([a-zA-Z0-9_-]+)(?:_module)?(?:=([^"'\s]+|"[^"]*"|'[^']*'))?`)	withMatches := withModuleRe.FindAllStringSubmatch(openRestyOutput, -1)	expectedWithModules := map[string]bool{		"cc-opt":                    false,		"ld-opt":                    false,		"http_ssl_module":           false,		"http_v2_module":            false,		"http_realip_module":        false,		"stream":                    false,		"stream_ssl_module":         false,		"stream_ssl_preread_module": false,	}	t.Logf("Found %d --with- matches", len(withMatches))	for _, match := range withMatches {		if len(match) > 1 {			moduleName := match[1]			t.Logf("Found with-module: %s", moduleName)			if _, expected := expectedWithModules[moduleName]; expected {				expectedWithModules[moduleName] = true			}		}	}	// Verify we found the key --with- modules	withFoundCount := 0	for _, found := range expectedWithModules {		if found {			withFoundCount++		}	}	if withFoundCount < 3 { // At least stream, http_ssl_module, etc should be found		t.Errorf("Too few --with- modules found: %d", withFoundCount)	}}func TestAddModuleRegexParsing(t *testing.T) {	testCases := []struct {		name     string		input    string		expected []string // expected module names	}{		{			name:     "single add-module with version",			input:    "--add-module=../ngx_devel_kit-0.3.3",			expected: []string{"ngx_devel_kit"},		},		{			name:     "add-module with nginx in name",			input:    "--add-module=../echo-nginx-module-0.63",			expected: []string{"echo_nginx_module"},		},		{			name:     "multiple add-modules",			input:    "--add-module=../ngx_lua-0.10.26 --add-module=../headers-more-nginx-module-0.37",			expected: []string{"ngx_lua", "headers_more_nginx_module"},		},		{			name:     "add-module with different separators",			input:    "--add-module=../set-misc-nginx-module-0.33 --add-module=../ngx_coolkit-0.2",			expected: []string{"set_misc_nginx_module", "ngx_coolkit"},		},	}	// Regex to parse --add-module arguments	addModuleRe := regexp.MustCompile(`--add-module=(?:[^/\s]+/)?([^/\s-]+(?:-[^/\s-]+)*)-[0-9.]+`)	for _, tc := range testCases {		t.Run(tc.name, func(t *testing.T) {			matches := addModuleRe.FindAllStringSubmatch(tc.input, -1)			if len(matches) != len(tc.expected) {				t.Errorf("Expected %d matches, got %d", len(tc.expected), len(matches))				for i, match := range matches {					if len(match) > 1 {						t.Logf("Match %d: %s", i, match[1])					}				}				return			}			for i, match := range matches {				if len(match) < 2 {					t.Errorf("Match %d should have at least 2 groups, got %d", i, len(match))					continue				}				moduleName := match[1]				// Convert dashes to underscores for consistency				normalizedName := strings.ReplaceAll(moduleName, "-", "_")				expectedModule := tc.expected[i]				if normalizedName != expectedModule {					t.Errorf("Expected module name %s, got %s (normalized from %s)", expectedModule, normalizedName, moduleName)				}			}		})	}}func TestNormalizeAddModuleName(t *testing.T) {	testCases := []struct {		name           string		addModuleName  string		expectedResult string	}{		{			name:           "ngx_devel_kit",			addModuleName:  "ngx_devel_kit",			expectedResult: "devel_kit",		},		{			name:           "echo-nginx-module",			addModuleName:  "echo-nginx-module",			expectedResult: "echo_nginx",		},		{			name:           "headers-more-nginx-module",			addModuleName:  "headers-more-nginx-module",			expectedResult: "headers_more_nginx",		},		{			name:           "ngx_lua",			addModuleName:  "ngx_lua",			expectedResult: "lua",		},		{			name:           "set-misc-nginx-module",			addModuleName:  "set-misc-nginx-module",			expectedResult: "set_misc_nginx",		},		{			name:           "ngx_stream_lua",			addModuleName:  "ngx_stream_lua",			expectedResult: "stream_lua",		},	}	for _, tc := range testCases {		t.Run(tc.name, func(t *testing.T) {			result := normalizeAddModuleName(tc.addModuleName)			if result != tc.expectedResult {				t.Errorf("normalizeAddModuleName(%s) = %s, expected %s",					tc.addModuleName, result, tc.expectedResult)			}		})	}}func TestStreamConfigurationParsing(t *testing.T) {	// Test parsing of stream configuration to verify stream module is working	streamConfig := `stream {    log_format tcp_format '$time_local|$remote_addr|$protocol|$status|$bytes_sent|$bytes_received|$session_time|$upstream_addr|$upstream_bytes_sent|$upstream_bytes_received|$upstream_connect_time';    include /usr/local/openresty/nginx/conf/streams-enabled/*.conf;    default_type  application/octet-stream;        upstream sshd_63_stream {        server 192.168.1.63:22;    }        server {        listen 6001;        proxy_pass sshd_63_stream;    }}`	// Simple test to verify stream block can be detected (word boundary to avoid matching "upstream sshd_63_stream")	streamBlockRe := regexp.MustCompile(`\bstream\s*\{`)	matches := streamBlockRe.FindAllString(streamConfig, -1)	if len(matches) != 1 {		t.Errorf("Expected to find 1 stream block, found %d", len(matches))	}	// Test upstream parsing within stream	upstreamRe := regexp.MustCompile(`upstream\s+([a-zA-Z0-9_]+)\s*\{`)	upstreamMatches := upstreamRe.FindAllStringSubmatch(streamConfig, -1)	if len(upstreamMatches) != 1 {		t.Errorf("Expected to find 1 upstream, found %d", len(upstreamMatches))	} else if upstreamMatches[0][1] != "sshd_63_stream" {		t.Errorf("Expected upstream name 'sshd_63_stream', got '%s'", upstreamMatches[0][1])	}	// Test server block parsing within stream	serverRe := regexp.MustCompile(`server\s*\{[^}]*listen\s+(\d+)`)	serverMatches := serverRe.FindAllStringSubmatch(streamConfig, -1)	if len(serverMatches) != 1 {		t.Errorf("Expected to find 1 server with listen directive, found %d", len(serverMatches))	} else if serverMatches[0][1] != "6001" {		t.Errorf("Expected listen port '6001', got '%s'", serverMatches[0][1])	}}func TestIntegratedModuleDetection(t *testing.T) {	// This test simulates the complete flow of module detection for OpenResty	// This would test the integration between --add-module parsing and --with- parsing	// Mock nginx -V output combining both --add-module and --with- parameters	mockNginxV := `nginx version: openresty/1.25.3.1configure arguments: --prefix=/usr/local/openresty/nginx --with-cc-opt=-O2 --add-module=../ngx_devel_kit-0.3.3 --add-module=../ngx_lua-0.10.26 --with-http_ssl_module --with-stream --with-stream_ssl_module`	// Test both regex patterns work on the same input	withModuleRe := regexp.MustCompile(`--with-([a-zA-Z0-9_-]+)(?:_module)?(?:=([^"'\s]+|"[^"]*"|'[^']*'))?`)	addModuleRe := regexp.MustCompile(`--add-module=(?:[^/\s]+/)?([^/\s-]+(?:-[^/\s-]+)*)-[0-9.]+`)	withMatches := withModuleRe.FindAllStringSubmatch(mockNginxV, -1)	addMatches := addModuleRe.FindAllStringSubmatch(mockNginxV, -1)	t.Logf("Found %d --with- matches and %d --add-module matches", len(withMatches), len(addMatches))	// Verify we can parse both types	if len(withMatches) == 0 {		t.Error("Failed to parse any --with- modules")	}	if len(addMatches) == 0 {		t.Error("Failed to parse any --add-module modules")	}	// Build a combined module list like the actual code should do	allModules := make(map[string]bool)	// Process --with- modules	for _, match := range withMatches {		if len(match) > 1 {			moduleName := match[1]			normalized := normalizeModuleNameFromConfigure(moduleName)			allModules[normalized] = true			t.Logf("--with- module: %s -> %s", moduleName, normalized)		}	}	// Process --add-module modules	for _, match := range addMatches {		if len(match) > 1 {			moduleName := match[1]			normalized := normalizeAddModuleName(moduleName)			allModules[normalized] = true			t.Logf("--add-module: %s -> %s", moduleName, normalized)		}	}	// Verify we have both types of modules	expectedModules := []string{"stream", "http_ssl", "devel_kit", "lua"}	foundCount := 0	for _, expected := range expectedModules {		if allModules[expected] {			foundCount++			t.Logf("✓ Found expected module: %s", expected)		} else {			t.Logf("✗ Missing expected module: %s", expected)		}	}	if foundCount < 2 {		t.Errorf("Expected to find at least 2 modules, found %d", foundCount)	}}
 |